"""Forms for portfolio.""" import logging from django import forms from django.core.validators import RegexValidator from django.core.validators import MaxLengthValidator from registrar.forms.utility.combobox import ComboboxWidget from registrar.models import ( PortfolioInvitation, UserPortfolioPermission, DomainInformation, Portfolio, SeniorOfficial, ) from registrar.models.utility.portfolio_helper import ( UserPortfolioPermissionChoices, UserPortfolioRoleChoices, get_domain_requests_description_display, get_domain_requests_display, get_domains_description_display, get_domains_display, get_members_description_display, get_members_display, ) logger = logging.getLogger(__name__) class PortfolioOrgAddressForm(forms.ModelForm): """Form for updating the portfolio org mailing address.""" zipcode = forms.CharField( label="Zip code", validators=[ RegexValidator( "^[0-9]{5}(?:-[0-9]{4})?$|^$", message="Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.", ) ], error_messages={ "required": "Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.", }, ) state_territory = forms.ChoiceField( label="State, territory, or military post", required=True, choices=DomainInformation.StateTerritoryChoices.choices, error_messages={ "required": ("Select the state, territory, or military post where your organization is located.") }, widget=ComboboxWidget(attrs={"required": True}), ) class Meta: model = Portfolio fields = [ "address_line1", "address_line2", "city", "state_territory", "zipcode", # "urbanization", ] error_messages = { "address_line1": {"required": "Enter the street address of your organization."}, "city": {"required": "Enter the city where your organization is located."}, "zipcode": {"required": "Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789."}, } widgets = { "address_line1": forms.TextInput, "address_line2": forms.TextInput, "city": forms.TextInput, # "urbanization": forms.TextInput, } # the database fields have blank=True so ModelForm doesn't create # required fields by default. Use this list in __init__ to mark each # of these fields as required required = ["address_line1", "city", "state_territory", "zipcode"] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) for field_name in self.required: self.fields[field_name].required = True self.fields["state_territory"].widget.attrs.pop("maxlength", None) self.fields["zipcode"].widget.attrs.pop("maxlength", None) class PortfolioSeniorOfficialForm(forms.ModelForm): """ Form for updating the portfolio senior official. This form is readonly for now. """ JOIN = "senior_official" full_name = forms.CharField(label="Full name", required=False) class Meta: model = SeniorOfficial fields = [ "title", "email", ] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if self.instance and self.instance.id: self.fields["full_name"].initial = self.instance.get_formatted_name() def clean(self): """Clean override to remove unused fields""" cleaned_data = super().clean() cleaned_data.pop("full_name", None) return cleaned_data class BasePortfolioMemberForm(forms.ModelForm): """Base form for the PortfolioMemberForm and PortfolioInvitedMemberForm""" # The label for each of these has a red "required" star. We can just embed that here for simplicity. required_star = '*' role = forms.ChoiceField( choices=[ # Uses .value because the choice has a different label (on /admin) (UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value, "Admin access"), (UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, "Basic access"), ], widget=forms.RadioSelect, required=True, error_messages={ "required": "Select the level of access you would like to grant this member.", }, ) domain_permissions = forms.ChoiceField( choices=[ ( UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value, get_domains_display(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, None), ), ( UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value, get_domains_display( UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS] ), ), ], widget=forms.RadioSelect, required=False, initial=UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value, error_messages={ "required": "Domain permission is required.", }, ) domain_request_permissions = forms.ChoiceField( choices=[ ("no_access", get_domain_requests_display(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, None)), ( UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, get_domain_requests_display( UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS] ), ), ( UserPortfolioPermissionChoices.EDIT_REQUESTS.value, get_domain_requests_display( UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.EDIT_REQUESTS] ), ), ], widget=forms.RadioSelect, required=False, initial="no_access", error_messages={ "required": "Domain request permission is required.", }, ) member_permissions = forms.ChoiceField( choices=[ ("no_access", get_members_display(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, None)), ( UserPortfolioPermissionChoices.VIEW_MEMBERS.value, get_members_display( UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.VIEW_MEMBERS] ), ), ], widget=forms.RadioSelect, required=False, initial="no_access", error_messages={ "required": "Member permission is required.", }, ) # Tracks what form elements are required for a given role choice. # All of the fields included here have "required=False" by default as they are conditionally required. # see def clean() for more details. ROLE_REQUIRED_FIELDS = { UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [], UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [ "domain_permissions", "member_permissions", "domain_request_permissions", ], } class Meta: model = None fields = ["roles", "additional_permissions"] def __init__(self, *args, **kwargs): """ Override the form's initialization. Map existing model values to custom form fields. Update field descriptions. """ super().__init__(*args, **kwargs) # Adds a
description beneath each option self.fields["domain_permissions"].descriptions = { UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value: get_domains_description_display( UserPortfolioRoleChoices.ORGANIZATION_MEMBER, None ), UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value: get_domains_description_display( UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS] ), } self.fields["domain_request_permissions"].descriptions = { UserPortfolioPermissionChoices.EDIT_REQUESTS.value: ( get_domain_requests_description_display( UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.EDIT_REQUESTS] ) ), UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value: ( get_domain_requests_description_display( UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS] ) ), "no_access": get_domain_requests_description_display(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, None), } self.fields["member_permissions"].descriptions = { UserPortfolioPermissionChoices.VIEW_MEMBERS.value: get_members_description_display( UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.VIEW_MEMBERS] ), "no_access": get_members_description_display(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, None), } # Map model instance values to custom form fields if self.instance: self.map_instance_to_initial() def clean(self): """Validates form data based on selected role and its required fields. Updates roles and additional_permissions in cleaned_data so they can be properly mapped to the model. """ cleaned_data = super().clean() role = cleaned_data.get("role") # handle role cleaned_data["roles"] = [role] if role else [] # Get required fields for the selected role. Then validate all required fields for the role. required_fields = self.ROLE_REQUIRED_FIELDS.get(role, []) for field_name in required_fields: # Helpful error for if this breaks if field_name not in self.fields: raise ValueError(f"ROLE_REQUIRED_FIELDS referenced a non-existent field: {field_name}.") if not cleaned_data.get(field_name): self.add_error(field_name, self.fields.get(field_name).error_messages.get("required")) # Edgecase: Member uses a special form value for None called "no_access". if cleaned_data.get("domain_request_permissions") == "no_access": cleaned_data["domain_request_permissions"] = None # Edgecase: Member uses a special form value for None called "no_access". if cleaned_data.get("member_permissions") == "no_access": cleaned_data["member_permissions"] = None # Handle additional_permissions valid_fields = self.ROLE_REQUIRED_FIELDS.get(role, []) additional_permissions = {cleaned_data.get(field) for field in valid_fields if cleaned_data.get(field)} # Handle EDIT permissions (should be accompanied with a view permission) if UserPortfolioPermissionChoices.EDIT_MEMBERS in additional_permissions: additional_permissions.add(UserPortfolioPermissionChoices.VIEW_MEMBERS) if UserPortfolioPermissionChoices.EDIT_REQUESTS in additional_permissions: additional_permissions.add(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS) # Only set unique permissions not already defined in the base role role_permissions = UserPortfolioPermission.get_portfolio_permissions(cleaned_data["roles"], [], get_list=False) cleaned_data["additional_permissions"] = list(additional_permissions - role_permissions) return cleaned_data def map_instance_to_initial(self): """ Maps self.instance to self.initial, handling roles and permissions. Updates self.initial dictionary with appropriate permission levels based on user role: { "role": "organization_admin" or "organization_member", "member_permission_admin": permission level if admin, "domain_request_permission_admin": permission level if admin, "domain_request_permissions": permission level if member } """ if self.initial is None: self.initial = {} # Function variables perms = UserPortfolioPermission.get_portfolio_permissions( self.instance.roles, self.instance.additional_permissions, get_list=False ) # Get the available options for roles, domains, and member. roles = [ UserPortfolioRoleChoices.ORGANIZATION_ADMIN, UserPortfolioRoleChoices.ORGANIZATION_MEMBER, ] domain_request_perms = [ UserPortfolioPermissionChoices.EDIT_REQUESTS, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, ] domain_perms = [ UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, ] member_perms = [ UserPortfolioPermissionChoices.VIEW_MEMBERS, ] # Build form data based on role (which options are available). # Get which one should be "selected" by assuming that EDIT takes precedence over view, # and ADMIN takes precedence over MEMBER. roles = self.instance.roles or [] selected_role = next((role for role in roles if role in roles), None) self.initial["role"] = selected_role is_member = selected_role == UserPortfolioRoleChoices.ORGANIZATION_MEMBER if is_member: # Edgecase: Member and domain request use a special form value for None called "no_access". # This ensures a form selection. selected_domain_permission = next( (perm for perm in domain_perms if perm in perms), UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value, ) selected_domain_request_permission = next( (perm for perm in domain_request_perms if perm in perms), "no_access" ) selected_member_permission = next((perm for perm in member_perms if perm in perms), "no_access") self.initial["domain_request_permissions"] = selected_domain_request_permission self.initial["domain_permissions"] = selected_domain_permission self.initial["member_permissions"] = selected_member_permission def is_change_from_member_to_admin(self) -> bool: """ Checks if the roles have changed from not containing ORGANIZATION_ADMIN to containing ORGANIZATION_ADMIN. """ previous_roles = set(self.initial.get("roles", [])) # Initial roles before change new_roles = set(self.cleaned_data.get("roles", [])) # New roles after change return ( UserPortfolioRoleChoices.ORGANIZATION_ADMIN not in previous_roles and UserPortfolioRoleChoices.ORGANIZATION_ADMIN in new_roles ) def is_change_from_admin_to_member(self) -> bool: """ Checks if the roles have changed from containing ORGANIZATION_ADMIN to not containing ORGANIZATION_ADMIN. """ previous_roles = set(self.initial.get("roles", [])) # Initial roles before change new_roles = set(self.cleaned_data.get("roles", [])) # New roles after change return ( UserPortfolioRoleChoices.ORGANIZATION_ADMIN in previous_roles and UserPortfolioRoleChoices.ORGANIZATION_ADMIN not in new_roles ) def is_change(self) -> bool: """ Determines if the form has changed by comparing the initial data with the submitted cleaned data. Returns: bool: True if the form has changed, False otherwise. """ # Compare role values previous_roles = set(self.initial.get("roles", [])) new_roles = set(self.cleaned_data.get("roles", [])) # Compare additional permissions values previous_permissions = set(self.initial.get("additional_permissions") or []) new_permissions = set(self.cleaned_data.get("additional_permissions") or []) return previous_roles != new_roles or previous_permissions != new_permissions class PortfolioMemberForm(BasePortfolioMemberForm): """ Form for updating a portfolio member. """ class Meta: model = UserPortfolioPermission fields = ["roles", "additional_permissions"] class PortfolioInvitedMemberForm(BasePortfolioMemberForm): """ Form for updating a portfolio invited member. """ class Meta: model = PortfolioInvitation fields = ["roles", "additional_permissions"] class PortfolioNewMemberForm(BasePortfolioMemberForm): """ Form for adding a portfolio invited member. """ email = forms.EmailField( label="Email", 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 = PortfolioInvitation fields = ["portfolio", "email", "roles", "additional_permissions"]