From b61d3936b596066aae9c9c1ebfbeee8ffc3d00b1 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Mon, 21 Oct 2024 22:08:35 -0600 Subject: [PATCH] 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 8a07b3f27..6be718c23 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 4d1be6f31..26c3f516e 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -86,11 +86,11 @@ urlpatterns = [ 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 999d76d51..4bc7ec046 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 @@ class PortfolioSeniorOfficialForm(forms.ModelForm): 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 82e06c808..5cddc026f 100644 --- a/src/registrar/templates/portfolio_members.html +++ b/src/registrar/templates/portfolio_members.html @@ -20,7 +20,7 @@

- 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..97e92b560 --- /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 75ff3a91f..bc42a0e39 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 552fdb6ff..49925b2ef 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 @@ 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 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 @@ 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 = 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 414e58275..6175b6104 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`.