diff --git a/src/.pa11yci b/src/.pa11yci index c18704c07..c42597fb4 100644 --- a/src/.pa11yci +++ b/src/.pa11yci @@ -20,6 +20,8 @@ "http://localhost:8080/request/anything_else/", "http://localhost:8080/request/requirements/", "http://localhost:8080/request/finished/", - "http://localhost:8080/user-profile/" + "http://localhost:8080/user-profile/", + "http://localhost:8080/members/", + "http://localhost:8080/members/new-member" ] } diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index fac25d2b0..486de17fa 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -297,28 +297,56 @@ function clearValidators(el) { * radio button is false (hides this element if true) * **/ function HookupYesNoListener(radioButtonName, elementIdToShowIfYes, elementIdToShowIfNo) { + HookupRadioTogglerListener(radioButtonName, { + 'True': elementIdToShowIfYes, + 'False': elementIdToShowIfNo + }); +} + +/** + * Hookup listeners for radio togglers in form fields. + * + * Parameters: + * - radioButtonName: The "name=" value for the radio buttons being used as togglers + * - valueToElementMap: An object where keys are the values of the radio buttons, + * and values are the corresponding DOM element IDs to show. All other elements will be hidden. + * + * Usage Example: + * Assuming you have radio buttons with values 'option1', 'option2', and 'option3', + * and corresponding DOM IDs 'section1', 'section2', 'section3'. + * + * HookupValueBasedListener('exampleRadioGroup', { + * 'option1': 'section1', + * 'option2': 'section2', + * 'option3': 'section3' + * }); + **/ +function HookupRadioTogglerListener(radioButtonName, valueToElementMap) { // Get the radio buttons let radioButtons = document.querySelectorAll('input[name="'+radioButtonName+'"]'); + + // Extract the list of all element IDs from the valueToElementMap + let allElementIds = Object.values(valueToElementMap); function handleRadioButtonChange() { - // Check the value of the selected radio button - // Attempt to find the radio button element that is checked + // Find the checked radio button let radioButtonChecked = document.querySelector('input[name="'+radioButtonName+'"]:checked'); - - // Check if the element exists before accessing its value let selectedValue = radioButtonChecked ? radioButtonChecked.value : null; - switch (selectedValue) { - case 'True': - toggleTwoDomElements(elementIdToShowIfYes, elementIdToShowIfNo, 1); - break; + // Hide all elements by default + allElementIds.forEach(function (elementId) { + let element = document.getElementById(elementId); + if (element) { + hideElement(element); + } + }); - case 'False': - toggleTwoDomElements(elementIdToShowIfYes, elementIdToShowIfNo, 2); - break; - - default: - toggleTwoDomElements(elementIdToShowIfYes, elementIdToShowIfNo, 0); + // Show the relevant element for the selected value + if (selectedValue && valueToElementMap[selectedValue]) { + let elementToShow = document.getElementById(valueToElementMap[selectedValue]); + if (elementToShow) { + showElement(elementToShow); + } } } @@ -328,11 +356,12 @@ function HookupYesNoListener(radioButtonName, elementIdToShowIfYes, elementIdToS radioButton.addEventListener('change', handleRadioButtonChange); }); - // initialize + // Initialize by checking the current state handleRadioButtonChange(); } } + // A generic display none/block toggle function that takes an integer param to indicate how the elements toggle function toggleTwoDomElements(ele1, ele2, index) { let element1 = document.getElementById(ele1); @@ -912,6 +941,18 @@ function setupUrbanizationToggle(stateTerritoryField) { HookupYesNoListener("additional_details-has_anything_else_text",'anything-else', null) })(); + +/** + * An IIFE that listens to the yes/no radio buttons on the anything else form and toggles form field visibility accordingly + * + */ +(function newMemberFormListener() { + HookupRadioTogglerListener('member_access_level', { + 'admin': 'new-member-admin-permissions', + 'basic': 'new-member-basic-permissions' + }); +})(); + /** * An IIFE that disables the delete buttons on nameserver forms on page load if < 3 forms * diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index f61e31e54..d289eaf90 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -120,6 +120,11 @@ urlpatterns = [ # views.PortfolioNoMembersView.as_view(), # name="no-portfolio-members", # ), + path( + "members/new-member/", + views.NewMemberView.as_view(), + name="new-member", + ), path( "requests/", views.PortfolioDomainRequestsView.as_view(), diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 7c8d2f171..5309f7263 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -3,6 +3,7 @@ import logging from django import forms from django.core.validators import RegexValidator +from django.core.validators import MaxLengthValidator from registrar.models import ( PortfolioInvitation, @@ -10,6 +11,7 @@ from registrar.models import ( DomainInformation, Portfolio, SeniorOfficial, + User, ) from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices @@ -160,3 +162,112 @@ class PortfolioInvitedMemberForm(forms.ModelForm): "roles", "additional_permissions", ] + + +class NewMemberForm(forms.ModelForm): + member_access_level = forms.ChoiceField( + label="Select permission", + choices=[("admin", "Admin Access"), ("basic", "Basic Access")], + widget=forms.RadioSelect(attrs={"class": "usa-radio__input usa-radio__input--tile"}), + required=True, + error_messages={ + "required": "Member access level is required", + }, + ) + admin_org_domain_request_permissions = forms.ChoiceField( + label="Select permission", + choices=[("view_only", "View all requests"), ("view_and_create", "View all requests plus create requests")], + widget=forms.RadioSelect, + required=True, + error_messages={ + "required": "Admin domain request permission is required", + }, + ) + admin_org_members_permissions = forms.ChoiceField( + label="Select permission", + choices=[("view_only", "View all members"), ("view_and_create", "View all members plus manage members")], + widget=forms.RadioSelect, + required=True, + error_messages={ + "required": "Admin member permission is required", + }, + ) + basic_org_domain_request_permissions = forms.ChoiceField( + label="Select permission", + choices=[ + ("view_only", "View all requests"), + ("view_and_create", "View all requests plus create requests"), + ("no_access", "No access"), + ], + widget=forms.RadioSelect, + required=True, + error_messages={ + "required": "Basic member permission is required", + }, + ) + + email = forms.EmailField( + label="Enter the email of the member you'd like to invite", + max_length=None, + error_messages={ + "invalid": ("Enter an email address in the required format, like name@example.com."), + "required": ("Enter an email address in the required format, like name@example.com."), + }, + validators=[ + MaxLengthValidator( + 320, + message="Response must be less than 320 characters.", + ) + ], + required=True, + ) + + class Meta: + model = User + fields = ["email"] + + def clean(self): + cleaned_data = super().clean() + + # Lowercase the value of the 'email' field + email_value = cleaned_data.get("email") + if email_value: + cleaned_data["email"] = email_value.lower() + + ########################################## + # TODO: future ticket + # (invite new member) + ########################################## + # Check for an existing user (if there isn't any, send an invite) + # if email_value: + # try: + # existingUser = User.objects.get(email=email_value) + # except User.DoesNotExist: + # raise forms.ValidationError("User with this email does not exist.") + + member_access_level = cleaned_data.get("member_access_level") + + # Intercept the error messages so that we don't validate hidden inputs + if not member_access_level: + # If no member access level has been selected, delete error messages + # for all hidden inputs (which is everything except the e-mail input + # and member access selection) + for field in self.fields: + if field in self.errors and field != "email" and field != "member_access_level": + del self.errors[field] + return cleaned_data + + basic_dom_req_error = "basic_org_domain_request_permissions" + admin_dom_req_error = "admin_org_domain_request_permissions" + admin_member_error = "admin_org_members_permissions" + + if member_access_level == "admin" and basic_dom_req_error in self.errors: + # remove the error messages pertaining to basic permission inputs + del self.errors[basic_dom_req_error] + elif member_access_level == "basic": + # remove the error messages pertaining to admin permission inputs + if admin_dom_req_error in self.errors: + del self.errors[admin_dom_req_error] + if admin_member_error in self.errors: + del self.errors[admin_member_error] + return cleaned_data diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py index 5f98197bd..7dadf26ac 100644 --- a/src/registrar/models/domain_information.py +++ b/src/registrar/models/domain_information.py @@ -425,3 +425,70 @@ class DomainInformation(TimeStampedModel): return self.domain.get_state_display() else: return None + + @property + def converted_organization_name(self): + if self.portfolio: + return self.portfolio.organization_name + return self.organization_name + + # ----- Portfolio Properties ----- + @property + def converted_generic_org_type(self): + if self.portfolio: + return self.portfolio.organization_type + return self.generic_org_type + + @property + def converted_federal_agency(self): + if self.portfolio: + return self.portfolio.federal_agency + return self.federal_agency + + @property + def converted_federal_type(self): + if self.portfolio: + return self.portfolio.federal_type + return self.federal_type + + @property + def converted_senior_official(self): + if self.portfolio: + return self.portfolio.senior_official + return self.senior_official + + @property + def converted_address_line1(self): + if self.portfolio: + return self.portfolio.address_line1 + return self.address_line1 + + @property + def converted_address_line2(self): + if self.portfolio: + return self.portfolio.address_line2 + return self.address_line2 + + @property + def converted_city(self): + if self.portfolio: + return self.portfolio.city + return self.city + + @property + def converted_state_territory(self): + if self.portfolio: + return self.portfolio.state_territory + return self.state_territory + + @property + def converted_zipcode(self): + if self.portfolio: + return self.portfolio.zipcode + return self.zipcode + + @property + def converted_urbanization(self): + if self.portfolio: + return self.portfolio.urbanization + return self.urbanization diff --git a/src/registrar/templates/includes/header_extended.html b/src/registrar/templates/includes/header_extended.html index 23b7d1be3..7c0f55dc3 100644 --- a/src/registrar/templates/includes/header_extended.html +++ b/src/registrar/templates/includes/header_extended.html @@ -95,7 +95,7 @@ {% if has_organization_members_flag %}
  • - + Members
  • diff --git a/src/registrar/templates/portfolio_members.html b/src/registrar/templates/portfolio_members.html index ffdb63099..3cd3aec44 100644 --- a/src/registrar/templates/portfolio_members.html +++ b/src/registrar/templates/portfolio_members.html @@ -21,7 +21,7 @@ {% if has_edit_members_portfolio_permission %}

    - Add a new member diff --git a/src/registrar/templates/portfolio_members_add_new.html b/src/registrar/templates/portfolio_members_add_new.html new file mode 100644 index 000000000..fe9cb9752 --- /dev/null +++ b/src/registrar/templates/portfolio_members_add_new.html @@ -0,0 +1,117 @@ +{% extends 'portfolio_base.html' %} +{% load static url_helpers %} +{% load field_helpers %} + +{% block title %} Members | New Member {% endblock %} + +{% block wrapper_class %} + {{ block.super }} dashboard--grey-1 +{% endblock %} + +{% block portfolio_content %} + + +{% include "includes/form_errors.html" with form=form %} +{% block messages %} + {% include "includes/form_messages.html" %} +{% endblock messages%} + + +

    + + +{% block new_member_header %} +

    Add a new member

    +{% endblock new_member_header %} + +{% include "includes/required_fields.html" %} + +
    +
    + +

    Email

    +
    + + {% csrf_token %} + {% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %} + {% input_with_errors form.email %} + {% endwith %} +
    + + +
    + +

    Member Access

    +
    + + Select the level of access for this member. * + + {% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %} +
    + {% for radio in form.member_access_level %} + {{ radio.tag }} + + {% endfor %} +
    + {% endwith %} + +
    + + +
    +

    Admin access permissions

    +

    Member permissions available for admin-level acccess.

    + +

    Organization domain requests

    + {% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %} + {% input_with_errors form.admin_org_domain_request_permissions %} + {% endwith %} + +

    Organization members

    + {% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %} + {% input_with_errors form.admin_org_members_permissions %} + {% endwith %} +
    + + +
    +

    Basic member permissions

    +

    Member permissions available for basic-level access

    + {% input_with_errors form.basic_org_domain_request_permissions %} +
    + + +
    + Cancel + + +
    +
    + +{% endblock portfolio_content%} + + diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index 6fb976d5c..1dbab2913 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -1,14 +1,9 @@ import logging from django.http import Http404 -from django.shortcuts import render +from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.contrib import messages -from registrar.forms.portfolio import ( - PortfolioInvitedMemberForm, - PortfolioMemberForm, - PortfolioOrgAddressForm, - PortfolioSeniorOfficialForm, -) +from registrar.forms import portfolio as portfolioForms from registrar.models import Portfolio, User from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.user_portfolio_permission import UserPortfolioPermission @@ -25,7 +20,6 @@ from registrar.views.utility.permission_views import ( ) from django.views.generic import View from django.views.generic.edit import FormMixin -from django.shortcuts import get_object_or_404, redirect logger = logging.getLogger(__name__) @@ -51,15 +45,6 @@ class PortfolioDomainRequestsView(PortfolioDomainRequestsPermissionView, View): return render(request, "portfolio_requests.html") -class PortfolioMembersView(PortfolioMembersPermissionView, View): - - template_name = "portfolio_members.html" - - def get(self, request): - """Add additional context data to the template.""" - return render(request, "portfolio_members.html") - - class PortfolioMemberView(PortfolioMemberPermissionView, View): template_name = "portfolio_member.html" @@ -101,7 +86,7 @@ class PortfolioMemberView(PortfolioMemberPermissionView, View): class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View): template_name = "portfolio_member_permissions.html" - form_class = PortfolioMemberForm + form_class = portfolioForms.PortfolioMemberForm def get(self, request, pk): portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk) @@ -197,7 +182,7 @@ class PortfolioInvitedMemberView(PortfolioMemberPermissionView, View): class PortfolioInvitedMemberEditView(PortfolioMemberEditPermissionView, View): template_name = "portfolio_member_permissions.html" - form_class = PortfolioInvitedMemberForm + form_class = portfolioForms.PortfolioInvitedMemberForm def get(self, request, pk): portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk) @@ -310,7 +295,7 @@ class PortfolioOrganizationView(PortfolioBasePermissionView, FormMixin): model = Portfolio template_name = "portfolio_organization.html" - form_class = PortfolioOrgAddressForm + form_class = portfolioForms.PortfolioOrgAddressForm context_object_name = "portfolio" def get_context_data(self, **kwargs): @@ -373,7 +358,7 @@ class PortfolioSeniorOfficialView(PortfolioBasePermissionView, FormMixin): model = Portfolio template_name = "portfolio_senior_official.html" - form_class = PortfolioSeniorOfficialForm + form_class = portfolioForms.PortfolioSeniorOfficialForm context_object_name = "portfolio" def get_object(self, queryset=None): @@ -394,3 +379,177 @@ class PortfolioSeniorOfficialView(PortfolioBasePermissionView, FormMixin): self.object = self.get_object() form = self.get_form() return self.render_to_response(self.get_context_data(form=form)) + + +class PortfolioMembersView(PortfolioMembersPermissionView, View): + + template_name = "portfolio_members.html" + + def get(self, request): + """Add additional context data to the template.""" + return render(request, "portfolio_members.html") + + +class NewMemberView(PortfolioMembersPermissionView, FormMixin): + + template_name = "portfolio_members_add_new.html" + form_class = portfolioForms.NewMemberForm + + def get_object(self, queryset=None): + """Get the portfolio object based on the session.""" + portfolio = self.request.session.get("portfolio") + if portfolio is None: + raise Http404("No organization found for this user") + return portfolio + + def get_form_kwargs(self): + """Include the instance in the form kwargs.""" + kwargs = super().get_form_kwargs() + kwargs["instance"] = self.get_object() + return kwargs + + def get(self, request, *args, **kwargs): + """Handle GET requests to display the form.""" + self.object = self.get_object() + form = self.get_form() + return self.render_to_response(self.get_context_data(form=form)) + + def post(self, request, *args, **kwargs): + """Handle POST requests to process form submission.""" + self.object = self.get_object() + form = self.get_form() + if form.is_valid(): + return self.form_valid(form) + else: + return self.form_invalid(form) + + def form_invalid(self, form): + """Handle the case when the form is invalid.""" + return self.render_to_response(self.get_context_data(form=form)) + + def get_success_url(self): + """Redirect to members table.""" + return reverse("members") + + ########################################## + # TODO: future ticket #2854 + # (save/invite new member) + ########################################## + + # def _send_domain_invitation_email(self, email: str, requestor: User, add_success=True): + # """Performs the sending of the member invitation email + # email: string- email to send to + # add_success: bool- default True indicates: + # adding a success message to the view if the email sending succeeds + + # raises EmailSendingError + # """ + + # # Set a default email address to send to for staff + # requestor_email = settings.DEFAULT_FROM_EMAIL + + # # Check if the email requestor has a valid email address + # if not requestor.is_staff and requestor.email is not None and requestor.email.strip() != "": + # requestor_email = requestor.email + # elif not requestor.is_staff: + # messages.error(self.request, "Can't send invitation email. No email is associated with your account.") + # logger.error( + # f"Can't send email to '{email}' on domain '{self.object}'." + # f"No email exists for the requestor '{requestor.username}'.", + # exc_info=True, + # ) + # return None + + # # Check to see if an invite has already been sent + # try: + # invite = MemberInvitation.objects.get(email=email, domain=self.object) + # # check if the invite has already been accepted + # if invite.status == MemberInvitation.MemberInvitationStatus.RETRIEVED: + # add_success = False + # messages.warning( + # self.request, + # f"{email} is already a manager for this domain.", + # ) + # else: + # add_success = False + # # else if it has been sent but not accepted + # messages.warning(self.request, f"{email} has already been invited to this domain") + # except Exception: + # logger.error("An error occured") + + # try: + # send_templated_email( + # "emails/member_invitation.txt", + # "emails/member_invitation_subject.txt", + # to_address=email, + # context={ + # "portfolio": self.object, + # "requestor_email": requestor_email, + # }, + # ) + # except EmailSendingError as exc: + # logger.warn( + # "Could not sent email invitation to %s for domain %s", + # email, + # self.object, + # exc_info=True, + # ) + # raise EmailSendingError("Could not send email invitation.") from exc + # else: + # if add_success: + # messages.success(self.request, f"{email} has been invited to this domain.") + + # def _make_invitation(self, email_address: str, requestor: User): + # """Make a Member invitation for this email and redirect with a message.""" + # try: + # self._send_member_invitation_email(email=email_address, requestor=requestor) + # except EmailSendingError: + # messages.warning(self.request, "Could not send email invitation.") + # else: + # # (NOTE: only create a MemberInvitation if the e-mail sends correctly) + # MemberInvitation.objects.get_or_create(email=email_address, domain=self.object) + # return redirect(self.get_success_url()) + + # def form_valid(self, form): + + # """Add the specified user as a member + # for this portfolio. + # Throws EmailSendingError.""" + # requested_email = form.cleaned_data["email"] + # requestor = self.request.user + # # look up a user with that email + # try: + # requested_user = User.objects.get(email=requested_email) + # except User.DoesNotExist: + # # no matching user, go make an invitation + # return self._make_invitation(requested_email, requestor) + # else: + # # if user already exists then just send an email + # try: + # self._send_member_invitation_email(requested_email, requestor, add_success=False) + # except EmailSendingError: + # logger.warn( + # "Could not send email invitation (EmailSendingError)", + # self.object, + # exc_info=True, + # ) + # messages.warning(self.request, "Could not send email invitation.") + # except Exception: + # logger.warn( + # "Could not send email invitation (Other Exception)", + # self.object, + # exc_info=True, + # ) + # messages.warning(self.request, "Could not send email invitation.") + + # try: + # UserPortfolioPermission.objects.create( + # user=requested_user, + # portfolio=self.object, + # role=UserDomainRole.Roles.MANAGER, + # ) + # except IntegrityError: + # messages.warning(self.request, f"{requested_email} is already a member of this portfolio") + # else: + # messages.success(self.request, f"Added user {requested_email}.") + # return redirect(self.get_success_url())