From b61d3936b596066aae9c9c1ebfbeee8ffc3d00b1 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Mon, 21 Oct 2024 22:08:35 -0600 Subject: [PATCH 01/10] New Member form is assembled (just needs validation) --- src/registrar/assets/js/get-gov.js | 9 + src/registrar/config/urls.py | 10 +- src/registrar/forms/portfolio.py | 71 ++++- .../templates/portfolio_members.html | 2 +- .../templates/portfolio_members_add_new.html | 117 ++++++++ .../templates/portfolio_no_domains.html | 2 +- src/registrar/views/portfolios.py | 255 +++++++++++++++++- .../views/utility/permission_views.py | 2 +- 8 files changed, 446 insertions(+), 22 deletions(-) create mode 100644 src/registrar/templates/portfolio_members_add_new.html diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 8a07b3f276..6be718c23d 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -912,6 +912,15 @@ 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() { + HookupYesNoListener("new_member-permission_level",'new-member-admin-permissions', '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 4d1be6f319..26c3f516eb 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -86,11 +86,11 @@ views.PortfolioMembersView.as_view(), name="members", ), - # path( - # "no-organization-members/", - # 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 999d76d511..4bc7ec0464 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -3,8 +3,11 @@ import logging from django import forms from django.core.validators import RegexValidator +from django.core.validators import MaxLengthValidator -from ..models import DomainInformation, Portfolio, SeniorOfficial +from registrar.models.user_portfolio_permission import UserPortfolioPermission + +from ..models import DomainInformation, Portfolio, SeniorOfficial, User logger = logging.getLogger(__name__) @@ -99,3 +102,69 @@ def clean(self): cleaned_data = super().clean() cleaned_data.pop("full_name", None) return cleaned_data + + +class NewMemberForm(forms.ModelForm): + 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) + 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) + 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) + + 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'] #, 'grade', 'sport'] + + def __init__(self, *args, **kwargs): + super(NewMemberForm, self).__init__(*args, **kwargs) + # self.fields['sport'].choices = [] + + 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() + + # 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 existingUser.DoesNotExist: + raise forms.ValidationError("User with this email does not exist.") + + # grade = cleaned_data.get('grade') + # sport = cleaned_data.get('sport') + + # # Handle sport options based on grade + # if grade == 'Junior': + # self.fields['sport'].choices = [('Basketball', 'Basketball'), ('Football', 'Football')] + # elif grade == 'Varsity': + # self.fields['sport'].choices = [('Swimming', 'Swimming'), ('Tennis', 'Tennis')] + + # # Ensure both sport and grade are selected and valid + # if not grade or not sport: + # raise forms.ValidationError("Both grade and sport must be selected.") + + return cleaned_data \ No newline at end of file diff --git a/src/registrar/templates/portfolio_members.html b/src/registrar/templates/portfolio_members.html index 82e06c8089..5cddc026f3 100644 --- a/src/registrar/templates/portfolio_members.html +++ b/src/registrar/templates/portfolio_members.html @@ -20,7 +20,7 @@

Members

- 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 0000000000..97e92b5609 --- /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 %} +{% 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" %} + +{% block form_fields %} +
+
+ +

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. * +
+ + + + +
+
+ + + +
+ +

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 %} +
+ +
+ +
+ + +
+ +
+{% endblock form_fields%} +{% endblock portfolio_content%} + + diff --git a/src/registrar/templates/portfolio_no_domains.html b/src/registrar/templates/portfolio_no_domains.html index 75ff3a91f0..bc42a0e391 100644 --- a/src/registrar/templates/portfolio_no_domains.html +++ b/src/registrar/templates/portfolio_no_domains.html @@ -1,4 +1,4 @@ -{% extends 'portfolio_base.html' %} +{% extends 'portfolio_no_domains.html' %} {% load static %} diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index 552fdb6ff4..49925b2efe 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -1,12 +1,15 @@ import logging +from django.db import IntegrityError 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 PortfolioOrgAddressForm, PortfolioSeniorOfficialForm +from registrar.forms import portfolio as portfolioForms from registrar.models import Portfolio, User +from registrar.models.user_domain_role import UserDomainRole from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices +from registrar.utility.email import EmailSendingError from registrar.views.utility.permission_views import ( PortfolioDomainRequestsPermissionView, PortfolioDomainsPermissionView, @@ -42,15 +45,6 @@ def get(self, request): 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 PortfolioNoDomainsView(NoPortfolioDomainsPermissionView, View): """Some users have access to the underlying portfolio, but not any domains. This is a custom view which explains that to the user - and denotes who to contact. @@ -116,7 +110,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): @@ -179,7 +173,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): @@ -200,3 +194,238 @@ def get(self, request, *args, **kwargs): 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 = portfolioForms.NewMemberForm #[forms.NewMemberToggleForm, forms.OtherContactsFormSet, forms.NoOtherContactsForm] + + model = UserPortfolioPermission + template_name = "portfolio_members_add_new.html" + form_class = portfolioForms.NewMemberForm + context_object_name = "userPortfolioPermission" + + 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)) + + ########################################## + # TODO: future ticket + # (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()) + + + + + + + + + + + + + +# class NewMemberView(PortfolioMembersPermissionView, FormMixin): +# form = portfolioForms.NewMemberForm +# template_name = 'portfolio_members_add_new.html' # Assuming you have a template file for the form + +# # model = UserPortfolioPermission +# # template_name = "portfolio_members_add_new.html" +# # form_class = portfolioForms.NewMemberForm +# # context_object_name = "userPortfolioPermission" + +# def get_success_url(self): +# return reverse('success') # Redirect after successful submission + +# def get_context_data(self, **kwargs): +# """Add additional context data to the template.""" +# #TODO: Add permissions to context +# context = super().get_context_data(**kwargs) +# portfolio = self.request.session.get("portfolio") +# context["has_invite_members_permission"] = self.request.user.has_edit_members_portfolio_permission(portfolio) +# return context + +# def form_valid(self, form): +# # Get the cleaned data from the form +# cleaned_data = form.cleaned_data +# email = cleaned_data.get('email') +# # grade = cleaned_data.get('grade') +# # sport = cleaned_data.get('sport') + +# ########################################## +# # TODO: future ticket +# # (validate and save/invite new member here) +# ########################################## + +# # Lookup member by email +# # member = get_object_or_404(User, email=email) + +# # Check existing portfolio permissions +# # TODO: future ticket -- check for existing portfolio permissions, multipe portfolio flags, etc. +# # school = self.get_context_data()['school'] + +# # Update student school information +# # student.school = school +# # student.save() + +# # Create or update the SportEnrollment for this student +# # SportEnrollment.objects.create( +# # student=student, +# # grade=grade, +# # sport=sport +# # ) + +# return super().form_valid(form) + +# def form_invalid(self, form): +# # If the form is invalid, show errors +# return self.render_to_response(self.get_context_data(form=form)) + + +# def get(self, request): +# return render(request, "portfolio_members_add_new.html") + diff --git a/src/registrar/views/utility/permission_views.py b/src/registrar/views/utility/permission_views.py index 414e58275a..6175b61046 100644 --- a/src/registrar/views/utility/permission_views.py +++ b/src/registrar/views/utility/permission_views.py @@ -253,7 +253,7 @@ class PortfolioDomainRequestsPermissionView(PortfolioDomainRequestsPermission, P class PortfolioMembersPermissionView(PortfolioMembersPermission, PortfolioBasePermissionView, abc.ABC): - """Abstract base view for portfolio domain request views that enforces permissions. + """Abstract base view for portfolio members views that enforces permissions. This abstract view cannot be instantiated. Actual views must specify `template_name`. From 14d0876ed4b2d4e31bf43d484df0a056936a9968 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Mon, 21 Oct 2024 22:28:09 -0600 Subject: [PATCH 02/10] We have basic validation.....but there is a snag with hidden fields --- src/registrar/forms/portfolio.py | 4 --- .../templates/portfolio_members_add_new.html | 18 +++++++------ src/registrar/views/portfolios.py | 26 +++++++++++++++++++ 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 4bc7ec0464..ab3b3a2695 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -135,10 +135,6 @@ class Meta: model = User fields = ['email'] #, 'grade', 'sport'] - def __init__(self, *args, **kwargs): - super(NewMemberForm, self).__init__(*args, **kwargs) - # self.fields['sport'].choices = [] - def clean(self): cleaned_data = super().clean() diff --git a/src/registrar/templates/portfolio_members_add_new.html b/src/registrar/templates/portfolio_members_add_new.html index 97e92b5609..29f6fc2d79 100644 --- a/src/registrar/templates/portfolio_members_add_new.html +++ b/src/registrar/templates/portfolio_members_add_new.html @@ -9,10 +9,13 @@ {% endblock %} {% block portfolio_content %} + + {% 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" %} -{% block form_fields %} -
+

Email

@@ -45,12 +48,12 @@

Email

+

Member Access

- Select the level of access for this member. *
@@ -70,8 +73,7 @@

Member Access

- - +

Admin access permissions

@@ -89,7 +91,7 @@

Organization Members

{% endwith %}
- +

Basic member permissions

@@ -99,6 +101,7 @@

Basic member permissions

+
-
-{% endblock form_fields%} + {% endblock portfolio_content%} diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index 49925b2efe..0674080d13 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -233,6 +233,32 @@ def get(self, request, *args, **kwargs): 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_valid(self, form): + """Handle the case when the form is valid.""" + # self.object = form.save(commit=False) + # self.object.creator = self.request.user + # self.object.save() + # messages.success(self.request, "The organization information for this portfolio has been updated.") + return super().form_valid(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 the overview page for the portfolio.""" + return reverse("members") + ########################################## # TODO: future ticket From 0af4727c56684faa53a7c64d1f71697d897ca92b Mon Sep 17 00:00:00 2001 From: CocoByte Date: Mon, 21 Oct 2024 23:17:22 -0600 Subject: [PATCH 03/10] linted again (and some experiments with form validation) --- src/registrar/templates/portfolio_members_add_new.html | 1 + src/registrar/views/portfolios.py | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/registrar/templates/portfolio_members_add_new.html b/src/registrar/templates/portfolio_members_add_new.html index 29f6fc2d79..67d3da3dd4 100644 --- a/src/registrar/templates/portfolio_members_add_new.html +++ b/src/registrar/templates/portfolio_members_add_new.html @@ -11,6 +11,7 @@ {% block portfolio_content %} +{% include "includes/form_errors.html" with form=form %} {% block messages %} {% include "includes/form_messages.html" %} {% endblock messages%} diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index 98e0fc43cf..92e6f82a57 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -357,10 +357,8 @@ def get(self, request): class NewMemberView(PortfolioMembersPermissionView, FormMixin): - model = UserPortfolioPermission template_name = "portfolio_members_add_new.html" form_class = portfolioForms.NewMemberForm - context_object_name = "userPortfolioPermission" def get_object(self, queryset=None): """Get the portfolio object based on the session.""" From de0e0d55c6288f0a2423c0648a42faa70b901df9 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Wed, 23 Oct 2024 23:58:48 -0600 Subject: [PATCH 04/10] finally created overrides for those custom radio buttons that work --- src/registrar/assets/js/get-gov.js | 3 +- src/registrar/forms/portfolio.py | 35 +++++++++++++++++++ .../templates/portfolio_members_add_new.html | 33 +++++++++-------- src/registrar/views/portfolios.py | 2 +- 4 files changed, 56 insertions(+), 17 deletions(-) diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index cca25bbfa9..e6f1e3e6c5 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -918,7 +918,8 @@ function setupUrbanizationToggle(stateTerritoryField) { * */ (function newMemberFormListener() { - HookupYesNoListener("new_member-permission_level",'new-member-admin-permissions', 'new-member-basic-permissions') + // HookupYesNoListener("new_member-permission_level",'new-member-admin-permissions', 'new-member-basic-permissions') + HookupYesNoListener("member_access_level",'new-member-admin-permissions', 'new-member-basic-permissions') })(); /** diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index b5c93ab59e..c675865296 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -165,17 +165,32 @@ class Meta: class NewMemberForm(forms.ModelForm): + member_access_level = forms.ChoiceField( + label="Select permission", + choices=[("True", "Admin Access"), ("False", "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": "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": "Member permission is required", + }, ) basic_org_domain_request_permissions = forms.ChoiceField( label="Select permission", @@ -186,6 +201,9 @@ class NewMemberForm(forms.ModelForm): ], widget=forms.RadioSelect, required=True, + error_messages={ + "required": "Member permission is required", + }, ) email = forms.EmailField( @@ -223,4 +241,21 @@ def clean(self): # except User.DoesNotExist: # raise forms.ValidationError("User with this email does not exist.") + # Get the grade and sport from POST data + permission_level = cleaned_data.get("member_access_level") + # permission_level = self.data.get('new_member-permission_level') + if not permission_level: + 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 + + # Validate the sport based on the selected grade + if permission_level == "True": + #remove the error messages pertaining to basic permission inputs + del self.errors["basic_org_domain_request_permissions"] + else: + #remove the error messages pertaining to admin permission inputs + del self.errors["admin_org_domain_request_permissions"] + del self.errors["admin_org_members_permissions"] return cleaned_data diff --git a/src/registrar/templates/portfolio_members_add_new.html b/src/registrar/templates/portfolio_members_add_new.html index 67d3da3dd4..3a390a4705 100644 --- a/src/registrar/templates/portfolio_members_add_new.html +++ b/src/registrar/templates/portfolio_members_add_new.html @@ -56,22 +56,25 @@

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 %} + @@ -86,7 +89,7 @@

Organization domain requests

{% input_with_errors form.admin_org_domain_request_permissions %} {% endwith %} -

Organization Members

+

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 %} diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index 92e6f82a57..bd9a10dd8f 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -401,7 +401,7 @@ def form_invalid(self, form): return self.render_to_response(self.get_context_data(form=form)) def get_success_url(self): - """Redirect to the overview page for the portfolio.""" + """Redirect to members table.""" return reverse("members") ########################################## From 5250cee1a036286974c611321c04460a35c98757 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Thu, 24 Oct 2024 00:12:03 -0600 Subject: [PATCH 05/10] refactored radio toggler listeners to be more flexible --- src/registrar/assets/js/get-gov.js | 65 ++++++++++++++++++++++-------- src/registrar/forms/portfolio.py | 2 +- 2 files changed, 49 insertions(+), 18 deletions(-) diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index e6f1e3e6c5..6f8ab2fa66 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; - - case 'False': - toggleTwoDomElements(elementIdToShowIfYes, elementIdToShowIfNo, 2); - break; + // Hide all elements by default + allElementIds.forEach(function (elementId) { + let element = document.getElementById(elementId); + if (element) { + element.style.display = 'none'; + } + }); - 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) { + elementToShow.style.display = 'block'; + } } } @@ -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); @@ -918,8 +947,10 @@ function setupUrbanizationToggle(stateTerritoryField) { * */ (function newMemberFormListener() { - // HookupYesNoListener("new_member-permission_level",'new-member-admin-permissions', 'new-member-basic-permissions') - HookupYesNoListener("member_access_level",'new-member-admin-permissions', 'new-member-basic-permissions') + HookupRadioTogglerListener('member_access_level', { + 'admin': 'new-member-admin-permissions', + 'basic': 'new-member-basic-permissions' + }); })(); /** diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index c675865296..4d59110edb 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -167,7 +167,7 @@ class Meta: class NewMemberForm(forms.ModelForm): member_access_level = forms.ChoiceField( label="Select permission", - choices=[("True", "Admin Access"), ("False", "Basic Access")], + choices=[("admin", "Admin Access"), ("basic", "Basic Access")], widget=forms.RadioSelect(attrs={'class': 'usa-radio__input usa-radio__input--tile'}), required=True, error_messages={ From 5fc0342426faa72d6584d0baf19f1d398876b242 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Thu, 24 Oct 2024 00:57:36 -0600 Subject: [PATCH 06/10] linted --- src/registrar/forms/portfolio.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 4d59110edb..e99c2db71a 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -168,7 +168,7 @@ 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'}), + widget=forms.RadioSelect(attrs={"class": "usa-radio__input usa-radio__input--tile"}), required=True, error_messages={ "required": "Member access level is required", @@ -252,10 +252,10 @@ def clean(self): # Validate the sport based on the selected grade if permission_level == "True": - #remove the error messages pertaining to basic permission inputs + # remove the error messages pertaining to basic permission inputs del self.errors["basic_org_domain_request_permissions"] else: - #remove the error messages pertaining to admin permission inputs + # remove the error messages pertaining to admin permission inputs del self.errors["admin_org_domain_request_permissions"] del self.errors["admin_org_members_permissions"] return cleaned_data From ba7de7c67815343623a73be5b376e2c87d1e1477 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Wed, 30 Oct 2024 15:40:00 -0600 Subject: [PATCH 07/10] Implemented PR feedback --- src/registrar/assets/js/get-gov.js | 4 +- src/registrar/forms/portfolio.py | 39 +++++++---- .../templates/portfolio_members_add_new.html | 4 +- src/registrar/views/portfolios.py | 70 +------------------ 4 files changed, 31 insertions(+), 86 deletions(-) diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 6f8ab2fa66..602d9cf790 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -337,7 +337,7 @@ function HookupRadioTogglerListener(radioButtonName, valueToElementMap) { allElementIds.forEach(function (elementId) { let element = document.getElementById(elementId); if (element) { - element.style.display = 'none'; + hideElement(element); } }); @@ -345,7 +345,7 @@ function HookupRadioTogglerListener(radioButtonName, valueToElementMap) { if (selectedValue && valueToElementMap[selectedValue]) { let elementToShow = document.getElementById(valueToElementMap[selectedValue]); if (elementToShow) { - elementToShow.style.display = 'block'; + showElement(elementToShow); } } } diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index e99c2db71a..4d6ec81cbf 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -180,7 +180,7 @@ class NewMemberForm(forms.ModelForm): widget=forms.RadioSelect, required=True, error_messages={ - "required": "Domain request permission is required", + "required": "Admin domain request permission is required", }, ) admin_org_members_permissions = forms.ChoiceField( @@ -189,7 +189,7 @@ class NewMemberForm(forms.ModelForm): widget=forms.RadioSelect, required=True, error_messages={ - "required": "Member permission is required", + "required": "Admin member permission is required", }, ) basic_org_domain_request_permissions = forms.ChoiceField( @@ -202,7 +202,7 @@ class NewMemberForm(forms.ModelForm): widget=forms.RadioSelect, required=True, error_messages={ - "required": "Member permission is required", + "required": "Basic member permission is required", }, ) @@ -234,6 +234,11 @@ def clean(self): 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: @@ -241,21 +246,29 @@ def clean(self): # except User.DoesNotExist: # raise forms.ValidationError("User with this email does not exist.") - # Get the grade and sport from POST data - permission_level = cleaned_data.get("member_access_level") - # permission_level = self.data.get('new_member-permission_level') - if not permission_level: + 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" - # Validate the sport based on the selected grade - if permission_level == "True": + 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_org_domain_request_permissions"] - else: + del self.errors[basic_dom_req_error] + elif member_access_level == "basic": # remove the error messages pertaining to admin permission inputs - del self.errors["admin_org_domain_request_permissions"] - del self.errors["admin_org_members_permissions"] + 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/templates/portfolio_members_add_new.html b/src/registrar/templates/portfolio_members_add_new.html index 3a390a4705..6d8e449f68 100644 --- a/src/registrar/templates/portfolio_members_add_new.html +++ b/src/registrar/templates/portfolio_members_add_new.html @@ -65,9 +65,9 @@

Member Access

{{ radio.choice_label }}

{% if radio.choice_label == "Admin Access" %} - Grants this member access to the organization-wide information on domains, domain requests, and members. Domain management can be assigned separately. + Grants this member access to the organization-wide information on domains, domain requests, and members. Domain management can be assigned separately. {% else %} - Grants this member access to the organization. They can be given extra permissions to view all organization domain requests and submit domain requests on behald of the organization. Basica access members can't view all members of an organization or manage them. Domain management can be assigned separacterly. + Grants this member access to the organization. They can be given extra permissions to view all organization domain requests and submit domain requests on behalf of the organization. Basic access members can’t view all members of an organization or manage them. Domain management can be assigned separately. {% endif %}

diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index bd9a10dd8f..ebb90cda12 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -388,13 +388,6 @@ def post(self, request, *args, **kwargs): else: return self.form_invalid(form) - def form_valid(self, form): - """Handle the case when the form is valid.""" - # self.object = form.save(commit=False) - # self.object.creator = self.request.user - # self.object.save() - # messages.success(self.request, "The organization information for this portfolio has been updated.") - return super().form_valid(form) def form_invalid(self, form): """Handle the case when the form is invalid.""" @@ -405,7 +398,7 @@ def get_success_url(self): return reverse("members") ########################################## - # TODO: future ticket + # TODO: future ticket #2854 # (save/invite new member) ########################################## @@ -526,64 +519,3 @@ def get_success_url(self): # else: # messages.success(self.request, f"Added user {requested_email}.") # return redirect(self.get_success_url()) - - -# class NewMemberView(PortfolioMembersPermissionView, FormMixin): -# form = portfolioForms.NewMemberForm -# template_name = 'portfolio_members_add_new.html' # Assuming you have a template file for the form - -# # model = UserPortfolioPermission -# # template_name = "portfolio_members_add_new.html" -# # form_class = portfolioForms.NewMemberForm -# # context_object_name = "userPortfolioPermission" - -# def get_success_url(self): -# return reverse('success') # Redirect after successful submission - -# def get_context_data(self, **kwargs): -# """Add additional context data to the template.""" -# #TODO: Add permissions to context -# context = super().get_context_data(**kwargs) -# portfolio = self.request.session.get("portfolio") -# context["has_invite_members_permission"] = self.request.user.has_edit_members_portfolio_permission(portfolio) -# return context - -# def form_valid(self, form): -# # Get the cleaned data from the form -# cleaned_data = form.cleaned_data -# email = cleaned_data.get('email') -# # grade = cleaned_data.get('grade') -# # sport = cleaned_data.get('sport') - -# ########################################## -# # TODO: future ticket -# # (validate and save/invite new member here) -# ########################################## - -# # Lookup member by email -# # member = get_object_or_404(User, email=email) - -# # Check existing portfolio permissions -# # TODO: future ticket -- check for existing portfolio permissions, multipe portfolio flags, etc. -# # school = self.get_context_data()['school'] - -# # Update student school information -# # student.school = school -# # student.save() - -# # Create or update the SportEnrollment for this student -# # SportEnrollment.objects.create( -# # student=student, -# # grade=grade, -# # sport=sport -# # ) - -# return super().form_valid(form) - -# def form_invalid(self, form): -# # If the form is invalid, show errors -# return self.render_to_response(self.get_context_data(form=form)) - - -# def get(self, request): -# return render(request, "portfolio_members_add_new.html") From 0153627a9e29fd4f7d72a18d34ef6344e907f565 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Wed, 30 Oct 2024 15:47:09 -0600 Subject: [PATCH 08/10] linted --- src/registrar/forms/portfolio.py | 7 +++---- src/registrar/views/portfolios.py | 1 - 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 4d6ec81cbf..5309f72630 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -234,7 +234,6 @@ def clean(self): if email_value: cleaned_data["email"] = email_value.lower() - ########################################## # TODO: future ticket # (invite new member) @@ -257,7 +256,7 @@ def clean(self): 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" @@ -267,8 +266,8 @@ def clean(self): 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: + if admin_dom_req_error in self.errors: del self.errors[admin_dom_req_error] - if admin_member_error in self.errors: + if admin_member_error in self.errors: del self.errors[admin_member_error] return cleaned_data diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index 9e4d2c5a6c..1dbab29130 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -423,7 +423,6 @@ def post(self, request, *args, **kwargs): 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)) From 612b7b68be5f5d64883fdfebca7694c86a7ad629 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Thu, 31 Oct 2024 13:31:13 -0600 Subject: [PATCH 09/10] Implemented feedback (plus fixed bug where cancel button validates form) --- src/.pa11yci | 4 ++- .../templates/includes/header_extended.html | 2 +- .../templates/portfolio_members.html | 2 +- .../templates/portfolio_members_add_new.html | 32 ++++++++----------- 4 files changed, 19 insertions(+), 21 deletions(-) diff --git a/src/.pa11yci b/src/.pa11yci index c18704c077..c42597fb4c 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/templates/includes/header_extended.html b/src/registrar/templates/includes/header_extended.html index 23b7d1be38..7c0f55dc3c 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 b13027c3de..3cd3aec447 100644 --- a/src/registrar/templates/portfolio_members.html +++ b/src/registrar/templates/portfolio_members.html @@ -21,7 +21,7 @@

    Members

    {% 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 index 6d8e449f68..381a4548a5 100644 --- a/src/registrar/templates/portfolio_members_add_new.html +++ b/src/registrar/templates/portfolio_members_add_new.html @@ -79,41 +79,37 @@

    Member Access

    -

    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 %} -
    +

    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 %} -
    -
    - +
    From 8df16346e9676a96e7f933340a7bd4b3d1509e40 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Thu, 31 Oct 2024 15:16:54 -0600 Subject: [PATCH 10/10] Fixed error --- src/registrar/models/domain_information.py | 67 +++++++++++++++++++ .../templates/portfolio_members_add_new.html | 4 +- .../templates/portfolio_no_domains.html | 2 +- 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py index 5f98197bd8..7dadf26aca 100644 --- a/src/registrar/models/domain_information.py +++ b/src/registrar/models/domain_information.py @@ -425,3 +425,70 @@ def get_state_display_of_domain(self): 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/portfolio_members_add_new.html b/src/registrar/templates/portfolio_members_add_new.html index 381a4548a5..fe9cb97522 100644 --- a/src/registrar/templates/portfolio_members_add_new.html +++ b/src/registrar/templates/portfolio_members_add_new.html @@ -20,7 +20,7 @@