diff --git a/.github/workflows/clone-staging.yaml b/.github/workflows/clone-staging.yaml index 2a6c33410..df943f138 100644 --- a/.github/workflows/clone-staging.yaml +++ b/.github/workflows/clone-staging.yaml @@ -22,7 +22,16 @@ jobs: CF_PASSWORD: CF_MS_PASSWORD steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + cache: 'pip' # caching pip dependencies + + - name: Install CG Tool + run: pip install git+https://github.com/cloud-gov/cg-manage-rds.git - name: Share DB Service uses: cloud-gov/cg-cli-tools@main @@ -36,7 +45,7 @@ jobs: - name: Clone Database uses: cloud-gov/cg-cli-tools@main with: - cf_username: ${{ secrets.CF_MS_USERNAM }} + cf_username: ${{ secrets.CF_MS_USERNAME }} cf_password: ${{ secrets.CF_MS_PASSWORD }} cf_org: cisa-dotgov cf_space: ${{ env.SOURCE_ENVIRONMENT }} @@ -45,8 +54,8 @@ jobs: - name: Unshare DB Service uses: cloud-gov/cg-cli-tools@main with: - cf_username: ${{ secrets.CF_MS_USERNAM }} + cf_username: ${{ secrets.CF_MS_USERNAME }} cf_password: ${{ secrets.CF_MS_PASSWORD }} cf_org: cisa-dotgov cf_space: ${{ env.SOURCE_ENVIRONMENT }} - cf_command: unshare-service getgov-${{ env.DESTINATION_ENVIRONMENT }}-database -s ${{ env.SOURCE_ENVIRONMENT }} \ No newline at end of file + cf_command: unshare-service getgov-${{ env.DESTINATION_ENVIRONMENT }}-database -s ${{ env.SOURCE_ENVIRONMENT }} diff --git a/src/.pa11yci b/src/.pa11yci index c18704c07..6a5ce4f26 100644 --- a/src/.pa11yci +++ b/src/.pa11yci @@ -20,6 +20,9 @@ "http://localhost:8080/request/anything_else/", "http://localhost:8080/request/requirements/", "http://localhost:8080/request/finished/", - "http://localhost:8080/user-profile/" + "http://localhost:8080/request/requesting_entity/", + "http://localhost:8080/user-profile/", + "http://localhost:8080/members/", + "http://localhost:8080/members/new-member" ] } diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 77ecb079a..8a0a458f8 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -29,6 +29,7 @@ from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial from registrar.utility.constants import BranchChoices from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes +from registrar.utility.waffle import flag_is_active_for_user from registrar.views.utility.mixins import OrderableFieldsMixin from django.contrib.admin.views.main import ORDER_VAR from registrar.widgets import NoAutocompleteFilteredSelectMultiple @@ -1479,7 +1480,18 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): search_help_text = "Search by domain." fieldsets = [ - (None, {"fields": ["portfolio", "sub_organization", "creator", "domain_request", "notes"]}), + ( + None, + { + "fields": [ + "portfolio", + "sub_organization", + "creator", + "domain_request", + "notes", + ] + }, + ), (".gov domain", {"fields": ["domain"]}), ("Contacts", {"fields": ["senior_official", "other_contacts", "no_other_contacts_rationale"]}), ("Background info", {"fields": ["anything_else"]}), @@ -1749,6 +1761,9 @@ def status_history(self, obj): "fields": [ "portfolio", "sub_organization", + "requested_suborganization", + "suborganization_city", + "suborganization_state_territory", "status_history", "status", "rejection_reason", @@ -1850,6 +1865,9 @@ def status_history(self, obj): "cisa_representative_first_name", "cisa_representative_last_name", "cisa_representative_email", + "requested_suborganization", + "suborganization_city", + "suborganization_state_territory", ] autocomplete_fields = [ "approved_domain", @@ -1869,6 +1887,25 @@ def status_history(self, obj): change_form_template = "django/admin/domain_request_change_form.html" + def get_fieldsets(self, request, obj=None): + fieldsets = super().get_fieldsets(request, obj) + + # Hide certain suborg fields behind the organization feature flag + # if it is not enabled + if not flag_is_active_for_user(request.user, "organization_feature"): + excluded_fields = [ + "requested_suborganization", + "suborganization_city", + "suborganization_state_territory", + ] + modified_fieldsets = [] + for name, data in fieldsets: + fields = data.get("fields", []) + fields = tuple(field for field in fields if field not in excluded_fields) + modified_fieldsets.append((name, {**data, "fields": fields})) + return modified_fieldsets + return fieldsets + # Trigger action when a fieldset is changed def save_model(self, request, obj, form, change): """Custom save_model definition that handles edge cases""" diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index a3c8b1410..a5c55acb1 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -47,10 +47,49 @@ function addOrRemoveSessionBoolean(name, add){ // <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> // Event handlers. +/** Helper function that handles business logic for the suborganization field. + * Can be used anywhere the suborganization dropdown exists +*/ +function handleSuborganizationFields( + portfolioDropdownSelector="#id_portfolio", + suborgDropdownSelector="#id_sub_organization", + requestedSuborgFieldSelector=".field-requested_suborganization", + suborgCitySelector=".field-suborganization_city", + suborgStateTerritorySelector=".field-suborganization_state_territory" +) { + // These dropdown are select2 fields so they must be interacted with via jquery + const portfolioDropdown = django.jQuery(portfolioDropdownSelector) + const suborganizationDropdown = django.jQuery(suborgDropdownSelector) + const requestedSuborgField = document.querySelector(requestedSuborgFieldSelector); + const suborgCity = document.querySelector(suborgCitySelector); + const suborgStateTerritory = document.querySelector(suborgStateTerritorySelector); + if (!suborganizationDropdown || !requestedSuborgField || !suborgCity || !suborgStateTerritory) { + console.error("Requested suborg fields not found."); + return; + } + + function toggleSuborganizationFields() { + if (portfolioDropdown.val() && !suborganizationDropdown.val()) { + showElement(requestedSuborgField); + showElement(suborgCity); + showElement(suborgStateTerritory); + }else { + hideElement(requestedSuborgField); + hideElement(suborgCity); + hideElement(suborgStateTerritory); + } + } + + // Run the function once on page startup, then attach an event listener + toggleSuborganizationFields(); + suborganizationDropdown.on("change", toggleSuborganizationFields); + portfolioDropdown.on("change", toggleSuborganizationFields); +} // <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> // Initialization code. + /** An IIFE for pages in DjangoAdmin that use modals. * Dja strips out form elements, and modals generate their content outside * of the current form scope, so we need to "inject" these inputs. @@ -1173,3 +1212,28 @@ document.addEventListener('DOMContentLoaded', function() { }; } })(); + +/** An IIFE for dynamic DomainRequest fields +*/ +(function dynamicDomainRequestFields(){ + const domainRequestPage = document.getElementById("domainrequest_form"); + if (domainRequestPage) { + handleSuborganizationFields(); + } +})(); + + +/** An IIFE for dynamic DomainInformation fields +*/ +(function dynamicDomainInformationFields(){ + const domainInformationPage = document.getElementById("domaininformation_form"); + // DomainInformation is embedded inside domain so this should fire there too + const domainPage = document.getElementById("domain_form"); + if (domainInformationPage) { + handleSuborganizationFields(); + } + + if (domainPage) { + handleSuborganizationFields(portfolioDropdownSelector="#id_domain_info-0-portfolio", suborgDropdownSelector="#id_domain_info-0-sub_organization"); + } +})(); diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index fac25d2b0..adcc21d2a 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -26,8 +26,8 @@ const hideElement = (element) => { }; /** - * Show element - * +* Show element +* */ const showElement = (element) => { element.classList.remove('display-none'); @@ -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) { + hideElement(element); + } + }); - 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 * @@ -2734,3 +2775,48 @@ document.addEventListener('DOMContentLoaded', function() { } } })(); + +/** An IIFE that intializes the requesting entity page. + * This page has a radio button that dynamically toggles some fields + * Within that, the dropdown also toggles some additional form elements. +*/ +(function handleRequestingEntityFieldset() { + // Sadly, these ugly ids are the auto generated with this prefix + const formPrefix = "portfolio_requesting_entity" + const radioFieldset = document.getElementById(`id_${formPrefix}-requesting_entity_is_suborganization__fieldset`); + const radios = radioFieldset?.querySelectorAll(`input[name="${formPrefix}-requesting_entity_is_suborganization"]`); + const select = document.getElementById(`id_${formPrefix}-sub_organization`); + const suborgContainer = document.getElementById("suborganization-container"); + const suborgDetailsContainer = document.getElementById("suborganization-container__details"); + if (!radios || !select || !suborgContainer || !suborgDetailsContainer) return; + + // requestingSuborganization: This just broadly determines if they're requesting a suborg at all + // requestingNewSuborganization: This variable determines if the user is trying to *create* a new suborganization or not. + var requestingSuborganization = Array.from(radios).find(radio => radio.checked)?.value === "True"; + var requestingNewSuborganization = document.getElementById(`id_${formPrefix}-is_requesting_new_suborganization`); + + function toggleSuborganization(radio=null) { + if (radio != null) requestingSuborganization = radio?.checked && radio.value === "True"; + requestingSuborganization ? showElement(suborgContainer) : hideElement(suborgContainer); + requestingNewSuborganization.value = requestingSuborganization && select.value === "other" ? "True" : "False"; + requestingNewSuborganization.value === "True" ? showElement(suborgDetailsContainer) : hideElement(suborgDetailsContainer); + } + + // Add fake "other" option to sub_organization select + if (select && !Array.from(select.options).some(option => option.value === "other")) { + select.add(new Option("Other (enter your organization manually)", "other")); + } + + if (requestingNewSuborganization.value === "True") { + select.value = "other"; + } + + // Add event listener to is_suborganization radio buttons, and run for initial display + toggleSuborganization(); + radios.forEach(radio => { + radio.addEventListener("click", () => toggleSuborganization(radio)); + }); + + // Add event listener to the suborg dropdown to show/hide the suborg details section + select.addEventListener("change", () => toggleSuborganization()); +})(); 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 @@ # 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/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index 3e413e8d8..bfbc22124 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -13,7 +13,7 @@ BaseYesNoForm, BaseDeletableRegistrarForm, ) -from registrar.models import Contact, DomainRequest, DraftDomain, Domain, FederalAgency +from registrar.models import Contact, DomainRequest, DraftDomain, Domain, FederalAgency, Suborganization from registrar.templatetags.url_helpers import public_site_url from registrar.utility.enums import ValidationReturnType from registrar.utility.constants import BranchChoices @@ -22,11 +22,147 @@ class RequestingEntityForm(RegistrarForm): - organization_name = forms.CharField( - label="Organization name", - error_messages={"required": "Enter the name of your organization."}, + """The requesting entity form contains a dropdown for suborganizations, + and some (hidden by default) input fields that allow the user to request for a suborganization. + All of these fields are not required by default, but as we use javascript to conditionally show + and hide some of these, they then become required in certain circumstances.""" + + # IMPORTANT: This is tied to DomainRequest.is_requesting_new_suborganization(). + # This is due to the from_database method on DomainRequestWizard. + # Add a hidden field to store if the user is requesting a new suborganization. + # This hidden boolean is used for our javascript to communicate to us and to it. + # If true, the suborganization form will auto select a js value "Other". + # If this selection is made on the form (tracked by js), then it will toggle the form value of this. + # In other words, this essentially tracks if the suborganization field == "Other". + # "Other" is just an imaginary value that is otherwise invalid. + # Note the logic in `def clean` and `handleRequestingEntityFieldset` in get-gov.js + is_requesting_new_suborganization = forms.BooleanField(required=False, widget=forms.HiddenInput()) + + sub_organization = forms.ModelChoiceField( + label="Suborganization name", + required=False, + queryset=Suborganization.objects.none(), + empty_label="--Select--", + ) + requested_suborganization = forms.CharField( + label="Requested suborganization", + required=False, + ) + suborganization_city = forms.CharField( + label="City", + required=False, + ) + suborganization_state_territory = forms.ChoiceField( + label="State, territory, or military post", + required=False, + choices=[("", "--Select--")] + DomainRequest.StateTerritoryChoices.choices, ) + def __init__(self, *args, **kwargs): + """Override of init to add the suborganization queryset""" + super().__init__(*args, **kwargs) + + if self.domain_request.portfolio: + self.fields["sub_organization"].queryset = Suborganization.objects.filter( + portfolio=self.domain_request.portfolio + ) + + def clean_sub_organization(self): + """On suborganization clean, set the suborganization value to None if the user is requesting + a custom suborganization (as it doesn't exist yet)""" + + # If it's a new suborganization, return None (equivalent to selecting nothing) + if self.cleaned_data.get("is_requesting_new_suborganization"): + return None + + # Otherwise just return the suborg as normal + return self.cleaned_data.get("sub_organization") + + def full_clean(self): + """Validation logic to remove the custom suborganization value before clean is triggered. + Without this override, the form will throw an 'invalid option' error.""" + # Remove the custom other field before cleaning + data = self.data.copy() if self.data else None + + # Remove the 'other' value from suborganization if it exists. + # This is a special value that tracks if the user is requesting a new suborg. + suborganization = self.data.get("portfolio_requesting_entity-sub_organization") + if suborganization and "other" in suborganization: + data["portfolio_requesting_entity-sub_organization"] = "" + + # Set the modified data back to the form + self.data = data + + # Call the parent's full_clean method + super().full_clean() + + def clean(self): + """Custom clean implementation to handle our desired logic flow for suborganization. + Given that these fields often rely on eachother, we need to do this in the parent function.""" + cleaned_data = super().clean() + + # Do some custom error validation if the requesting entity is a suborg. + # Otherwise, just validate as normal. + suborganization = self.cleaned_data.get("sub_organization") + is_requesting_new_suborganization = self.cleaned_data.get("is_requesting_new_suborganization") + + # Get the value of the yes/no checkbox from RequestingEntityYesNoForm. + # Since self.data stores this as a string, we need to convert "True" => True. + requesting_entity_is_suborganization = self.data.get( + "portfolio_requesting_entity-requesting_entity_is_suborganization" + ) + if requesting_entity_is_suborganization == "True": + if is_requesting_new_suborganization: + # Validate custom suborganization fields + if not cleaned_data.get("requested_suborganization"): + self.add_error("requested_suborganization", "Requested suborganization is required.") + if not cleaned_data.get("suborganization_city"): + self.add_error("suborganization_city", "City is required.") + if not cleaned_data.get("suborganization_state_territory"): + self.add_error("suborganization_state_territory", "State, territory, or military post is required.") + elif not suborganization: + self.add_error("sub_organization", "Suborganization is required.") + + return cleaned_data + + +class RequestingEntityYesNoForm(BaseYesNoForm): + """The yes/no field for the RequestingEntity form.""" + + # This first option will change dynamically + form_choices = ((False, "Current Organization"), (True, "A suborganization. (choose from list)")) + + # IMPORTANT: This is tied to DomainRequest.is_requesting_new_suborganization(). + # This is due to the from_database method on DomainRequestWizard. + field_name = "requesting_entity_is_suborganization" + required_error_message = "Requesting entity is required." + + def __init__(self, *args, **kwargs): + """Extend the initialization of the form from RegistrarForm __init__""" + super().__init__(*args, **kwargs) + if self.domain_request.portfolio: + self.form_choices = ( + (False, self.domain_request.portfolio), + (True, "A suborganization (choose from list)"), + ) + self.fields[self.field_name] = self.get_typed_choice_field() + + @property + def form_is_checked(self): + """ + Determines the initial checked state of the form. + Returns True (checked) if the requesting entity is a suborganization, + and False if it is a portfolio. Returns None if neither condition is met. + """ + # True means that the requesting entity is a suborganization, + # whereas False means that the requesting entity is a portfolio. + if self.domain_request.requesting_entity_is_suborganization(): + return True + elif self.domain_request.requesting_entity_is_portfolio(): + return False + else: + return None + class OrganizationTypeForm(RegistrarForm): generic_org_type = forms.ChoiceField( 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 @@ DomainInformation, Portfolio, SeniorOfficial, + User, ) from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices @@ -160,3 +162,112 @@ class Meta: "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/migrations/0136_domainrequest_requested_suborganization_and_more.py b/src/registrar/migrations/0136_domainrequest_requested_suborganization_and_more.py new file mode 100644 index 000000000..e1b130b4f --- /dev/null +++ b/src/registrar/migrations/0136_domainrequest_requested_suborganization_and_more.py @@ -0,0 +1,95 @@ +# Generated by Django 4.2.10 on 2024-11-01 17:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0135_alter_federalagency_agency_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="domainrequest", + name="requested_suborganization", + field=models.CharField(blank=True, null=True), + ), + migrations.AddField( + model_name="domainrequest", + name="suborganization_city", + field=models.CharField(blank=True, null=True), + ), + migrations.AddField( + model_name="domainrequest", + name="suborganization_state_territory", + field=models.CharField( + blank=True, + choices=[ + ("AL", "Alabama (AL)"), + ("AK", "Alaska (AK)"), + ("AS", "American Samoa (AS)"), + ("AZ", "Arizona (AZ)"), + ("AR", "Arkansas (AR)"), + ("CA", "California (CA)"), + ("CO", "Colorado (CO)"), + ("CT", "Connecticut (CT)"), + ("DE", "Delaware (DE)"), + ("DC", "District of Columbia (DC)"), + ("FL", "Florida (FL)"), + ("GA", "Georgia (GA)"), + ("GU", "Guam (GU)"), + ("HI", "Hawaii (HI)"), + ("ID", "Idaho (ID)"), + ("IL", "Illinois (IL)"), + ("IN", "Indiana (IN)"), + ("IA", "Iowa (IA)"), + ("KS", "Kansas (KS)"), + ("KY", "Kentucky (KY)"), + ("LA", "Louisiana (LA)"), + ("ME", "Maine (ME)"), + ("MD", "Maryland (MD)"), + ("MA", "Massachusetts (MA)"), + ("MI", "Michigan (MI)"), + ("MN", "Minnesota (MN)"), + ("MS", "Mississippi (MS)"), + ("MO", "Missouri (MO)"), + ("MT", "Montana (MT)"), + ("NE", "Nebraska (NE)"), + ("NV", "Nevada (NV)"), + ("NH", "New Hampshire (NH)"), + ("NJ", "New Jersey (NJ)"), + ("NM", "New Mexico (NM)"), + ("NY", "New York (NY)"), + ("NC", "North Carolina (NC)"), + ("ND", "North Dakota (ND)"), + ("MP", "Northern Mariana Islands (MP)"), + ("OH", "Ohio (OH)"), + ("OK", "Oklahoma (OK)"), + ("OR", "Oregon (OR)"), + ("PA", "Pennsylvania (PA)"), + ("PR", "Puerto Rico (PR)"), + ("RI", "Rhode Island (RI)"), + ("SC", "South Carolina (SC)"), + ("SD", "South Dakota (SD)"), + ("TN", "Tennessee (TN)"), + ("TX", "Texas (TX)"), + ("UM", "United States Minor Outlying Islands (UM)"), + ("UT", "Utah (UT)"), + ("VT", "Vermont (VT)"), + ("VI", "Virgin Islands (VI)"), + ("VA", "Virginia (VA)"), + ("WA", "Washington (WA)"), + ("WV", "West Virginia (WV)"), + ("WI", "Wisconsin (WI)"), + ("WY", "Wyoming (WY)"), + ("AA", "Armed Forces Americas (AA)"), + ("AE", "Armed Forces Africa, Canada, Europe, Middle East (AE)"), + ("AP", "Armed Forces Pacific (AP)"), + ], + max_length=2, + null=True, + verbose_name="state, territory, or military post", + ), + ), + ] 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 @@ 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/models/domain_request.py b/src/registrar/models/domain_request.py index 7f793f3e0..ca4b322be 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -13,6 +13,8 @@ from registrar.utility.constants import BranchChoices from auditlog.models import LogEntry +from registrar.utility.waffle import flag_is_active_for_user + from .utility.time_stamped_model import TimeStampedModel from ..utility.email import send_templated_email, EmailSendingError from itertools import chain @@ -344,6 +346,24 @@ def get_action_needed_reason_label(cls, action_needed_reason: str): verbose_name="Suborganization", ) + requested_suborganization = models.CharField( + null=True, + blank=True, + ) + + suborganization_city = models.CharField( + null=True, + blank=True, + ) + + suborganization_state_territory = models.CharField( + max_length=2, + choices=StateTerritoryChoices.choices, + null=True, + blank=True, + verbose_name="state, territory, or military post", + ) + # This is the domain request user who created this domain request. creator = models.ForeignKey( "registrar.User", @@ -823,10 +843,13 @@ def _send_status_update_email( try: if not context: + has_organization_feature_flag = flag_is_active_for_user(recipient, "organization_feature") + is_org_user = has_organization_feature_flag and recipient.has_base_portfolio_permission(self.portfolio) context = { "domain_request": self, # This is the user that we refer to in the email "recipient": recipient, + "is_org_user": is_org_user, } if custom_email_content: @@ -1102,7 +1125,61 @@ def reject_with_prejudice(self): self.creator.restrict_user() - # ## Form policies ### + def requesting_entity_is_portfolio(self) -> bool: + """Determines if this record is requesting that a portfolio be their organization. + Used for the RequestingEntity page. + Returns True if the portfolio exists and if organization_name matches portfolio.organization_name. + """ + if self.portfolio and self.organization_name == self.portfolio.organization_name: + return True + return False + + def requesting_entity_is_suborganization(self) -> bool: + """Determines if this record is also requesting that it be tied to a suborganization. + Used for the RequestingEntity page. + Returns True if portfolio exists and either sub_organization exists, + or if is_requesting_new_suborganization() is true. + Returns False otherwise. + """ + if self.portfolio and (self.sub_organization or self.is_requesting_new_suborganization()): + return True + return False + + def is_requesting_new_suborganization(self) -> bool: + """Determines if a user is trying to request + a new suborganization using the domain request form, rather than one that already exists. + Used for the RequestingEntity page. + + Returns True if a sub_organization does not exist and if requested_suborganization, + suborganization_city, and suborganization_state_territory all exist. + Returns False otherwise. + """ + + # If a suborganization already exists, it can't possibly be a new one. + # As well, we need all required fields to exist. + required_fields = [ + self.requested_suborganization, + self.suborganization_city, + self.suborganization_state_territory, + ] + if not self.sub_organization and all(required_fields): + return True + return False + + # ## Form unlocking steps ## # + # + # These methods control the conditions in which we should unlock certain domain wizard steps. + + def unlock_requesting_entity(self) -> bool: + """Unlocks the requesting entity step. Used for the RequestingEntity page. + Returns true if requesting_entity_is_suborganization() and requesting_entity_is_portfolio(). + Returns False otherwise. + """ + if self.requesting_entity_is_suborganization() or self.requesting_entity_is_portfolio(): + return True + return False + + # ## Form policies ## # # # These methods control what questions need to be answered by applicants # during the domain request flow. They are policies about the domain request so diff --git a/src/registrar/models/portfolio.py b/src/registrar/models/portfolio.py index 8d820e105..82afcd4d6 100644 --- a/src/registrar/models/portfolio.py +++ b/src/registrar/models/portfolio.py @@ -2,6 +2,8 @@ from registrar.models.domain_request import DomainRequest from registrar.models.federal_agency import FederalAgency +from registrar.models.user import User +from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices from .utility.time_stamped_model import TimeStampedModel @@ -131,6 +133,17 @@ def federal_type(self): def get_federal_type(cls, federal_agency): return federal_agency.federal_type if federal_agency else None + @property + def portfolio_admin_users(self): + """Gets all users with the role organization_admin for this particular portfolio. + Returns a queryset of User.""" + admin_ids = self.portfolio_users.filter( + roles__overlap=[ + UserPortfolioRoleChoices.ORGANIZATION_ADMIN, + ], + ).values_list("user__id", flat=True) + return User.objects.filter(id__in=admin_ids) + # == Getters for domains == # def get_domains(self, order_by=None): """Returns all DomainInformations associated with this portfolio""" diff --git a/src/registrar/templates/domain_request_requesting_entity.html b/src/registrar/templates/domain_request_requesting_entity.html index ed8dd771c..d09e8ab89 100644 --- a/src/registrar/templates/domain_request_requesting_entity.html +++ b/src/registrar/templates/domain_request_requesting_entity.html @@ -2,15 +2,58 @@ {% load field_helpers url_helpers %} {% block form_instructions %} -

🛸🛸🛸🛸 Placeholder content 🛸🛸🛸🛸

+

To help with our review, we need to understand whether the domain you're requesting will be used by the Department of Energy or by one of its suborganizations.

+

We define a suborganization as any entity (agency, bureau, office) that falls under the overarching organization.

{% endblock %} {% block form_fields %}
-

What is the name of your space vessel?

+

Who will use the domain you’re requesting?

- {% input_with_errors forms.0.organization_name %} +

+ Select one. * +

+ + {# forms.0 is a small yes/no form that toggles the visibility of "requesting entity" formset #} + {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} + {% with attr_required=True %} + {% input_with_errors forms.0.requesting_entity_is_suborganization %} + {% endwith %} + {% endwith %} + + {% comment %} Add an invisible form element to track whether the custom value "other" + was selected or not. This allows for persistence across page reloads without using session variables. + {% endcomment %} + {% with add_group_class="display-none" %} + {% input_with_errors forms.1.is_requesting_new_suborganization %} + {% endwith %} + +
+

Add suborganization information

+

+ This information will be published in .gov’s public data. If you don’t see your suborganization in the list, + select “other” and enter the name or your suborganization. +

+ {% with attr_required=True %} + {% input_with_errors forms.1.sub_organization %} + {% endwith %} + + {% comment %} This will be toggled if a special value, "other", is selected. + Otherwise this field is invisible. + {% endcomment %} +
+ {% with attr_required=True %} + {% input_with_errors forms.1.requested_suborganization %} + {% endwith %} + {% with attr_required=True %} + {% input_with_errors forms.1.suborganization_city %} + {% endwith %} + {% with attr_required=True %} + {% input_with_errors forms.1.suborganization_state_territory %} + {% endwith %} +
+
{% endblock %} diff --git a/src/registrar/templates/emails/includes/portfolio_domain_request_summary.txt b/src/registrar/templates/emails/includes/portfolio_domain_request_summary.txt new file mode 100644 index 000000000..866fde50f --- /dev/null +++ b/src/registrar/templates/emails/includes/portfolio_domain_request_summary.txt @@ -0,0 +1,28 @@ +{% load custom_filters %}SUMMARY OF YOUR DOMAIN REQUEST + +Requesting entity: {# if blockmakes a newline #} +{{ domain_request|display_requesting_entity }} +{% if domain_request.current_websites.exists %} +Current websites: {% for site in domain_request.current_websites.all %} +{% spaceless %}{{ site.website }}{% endspaceless %} +{% endfor %}{% endif %} +.gov domain: +{{ domain_request.requested_domain.name }} +{% if domain_request.alternative_domains.all %} +Alternative domains: +{% for site in domain_request.alternative_domains.all %}{% spaceless %}{{ site.website }}{% endspaceless %} +{% endfor %}{% endif %} +Purpose of your domain: +{{ domain_request.purpose }} +{% if domain_request.anything_else %} +Additional details: +{{ domain_request.anything_else }} +{% endif %} +{% if recipient %} +Your contact information: +{% spaceless %}{% include "emails/includes/contact.txt" with contact=recipient %}{% endspaceless %} +{% endif %} + +Administrators from your organization:{% for admin in domain_request.portfolio.portfolio_admin_users %} +{% spaceless %}{% if admin != recipient %}{% include "emails/includes/contact.txt" with contact=admin %}{% endif %}{% endspaceless %} +{% endfor %} \ No newline at end of file diff --git a/src/registrar/templates/emails/submission_confirmation.txt b/src/registrar/templates/emails/submission_confirmation.txt index c8ff4c7eb..ef9736a9d 100644 --- a/src/registrar/templates/emails/submission_confirmation.txt +++ b/src/registrar/templates/emails/submission_confirmation.txt @@ -31,7 +31,7 @@ THANK YOU ---------------------------------------------------------------- -{% include 'emails/includes/domain_request_summary.txt' %} +{% if is_org_user %}{% include 'emails/includes/portfolio_domain_request_summary.txt' %}{% else %}{% include 'emails/includes/domain_request_summary.txt' %}{% endif %} ---------------------------------------------------------------- The .gov team diff --git a/src/registrar/templates/includes/header_extended.html b/src/registrar/templates/includes/header_extended.html index 23b7d1be3..a954eb30f 100644 --- a/src/registrar/templates/includes/header_extended.html +++ b/src/registrar/templates/includes/header_extended.html @@ -93,9 +93,9 @@ {% endif %} - {% if has_organization_members_flag %} + {% if has_organization_members_flag and not hide_members %}
  • - + Members
  • diff --git a/src/registrar/templates/includes/portfolio_request_review_steps.html b/src/registrar/templates/includes/portfolio_request_review_steps.html index 9d3c5bdeb..d0bdd7ba0 100644 --- a/src/registrar/templates/includes/portfolio_request_review_steps.html +++ b/src/registrar/templates/includes/portfolio_request_review_steps.html @@ -8,15 +8,24 @@ {% endif %} {% if step == Step.REQUESTING_ENTITY %} - {% if domain_request.organization_name %} - {% with title=form_titles|get_item:step value=domain_request %} - {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=is_editable edit_link=domain_request_url address='true' %} - {% endwith %} + {% with title=form_titles|get_item:step %} + {% if domain_request.sub_organization %} + {% include "includes/summary_item.html" with value=domain_request.sub_organization edit_link=domain_request_url %} + {% comment %} We don't have city or state_territory for suborganizations yet, so no data should display {% endcomment %} + {% elif domain_request.requesting_entity_is_suborganization %} + {% include "includes/summary_item.html" with value=domain_request.requested_suborganization edit_link=domain_request_url %} +

    {{domain_request.suborganization_city}}, {{domain_request.suborganization_state_territory}}

    + {% elif domain_request.requesting_entity_is_portfolio %} + {% include "includes/summary_item.html" with value=domain_request.portfolio.organization_name edit_link=domain_request_url %} + {% if domain_request.portfolio.city and domain_request.portfolio.state_territory %} +

    {{domain_request.portfolio.city}}, {{domain_request.portfolio.state_territory}}

    + {% endif %} {% else %} - {% with title=form_titles|get_item:step value="Incomplete"|safe %} - {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=is_editable edit_link=domain_request_url %} + {% with value="Incomplete"|safe %} + {% include "includes/summary_item.html" with edit_link=domain_request_url %} {% endwith %} - {% endif%} + {% endif %} + {% endwith %} {% endif %} {% if step == Step.CURRENT_SITES %} diff --git a/src/registrar/templates/includes/request_status_manage.html b/src/registrar/templates/includes/request_status_manage.html index 2a254df4b..fc2fd8f12 100644 --- a/src/registrar/templates/includes/request_status_manage.html +++ b/src/registrar/templates/includes/request_status_manage.html @@ -145,92 +145,96 @@

    Summary of your domain request

    {% endblock request_summary_header%} {% block request_summary %} - {% with heading_level='h3' %} - {% with org_type=DomainRequest.get_generic_org_type_display %} - {% include "includes/summary_item.html" with title='Type of organization' value=org_type heading_level=heading_level %} - {% endwith %} + {% if portfolio %} + {% include "includes/portfolio_request_review_steps.html" with is_editable=False domain_request=DomainRequest %} + {% else %} + {% with heading_level='h3' %} + {% with org_type=DomainRequest.get_generic_org_type_display %} + {% include "includes/summary_item.html" with title='Type of organization' value=org_type heading_level=heading_level %} + {% endwith %} - {% if DomainRequest.tribe_name %} - {% include "includes/summary_item.html" with title='Tribal government' value=DomainRequest.tribe_name heading_level=heading_level %} + {% if DomainRequest.tribe_name %} + {% include "includes/summary_item.html" with title='Tribal government' value=DomainRequest.tribe_name heading_level=heading_level %} - {% if DomainRequest.federally_recognized_tribe %} -

    Federally-recognized tribe

    - {% endif %} + {% if DomainRequest.federally_recognized_tribe %} +

    Federally-recognized tribe

    + {% endif %} - {% if DomainRequest.state_recognized_tribe %} -

    State-recognized tribe

    - {% endif %} + {% if DomainRequest.state_recognized_tribe %} +

    State-recognized tribe

    + {% endif %} - {% endif %} + {% endif %} - {% if DomainRequest.get_federal_type_display %} - {% include "includes/summary_item.html" with title='Federal government branch' value=DomainRequest.get_federal_type_display heading_level=heading_level %} - {% endif %} + {% if DomainRequest.get_federal_type_display %} + {% include "includes/summary_item.html" with title='Federal government branch' value=DomainRequest.get_federal_type_display heading_level=heading_level %} + {% endif %} - {% if DomainRequest.is_election_board %} - {% with value=DomainRequest.is_election_board|yesno:"Yes,No,Incomplete" %} - {% include "includes/summary_item.html" with title='Election office' value=value heading_level=heading_level %} - {% endwith %} - {% endif %} + {% if DomainRequest.is_election_board %} + {% with value=DomainRequest.is_election_board|yesno:"Yes,No,Incomplete" %} + {% include "includes/summary_item.html" with title='Election office' value=value heading_level=heading_level %} + {% endwith %} + {% endif %} - {% if DomainRequest.organization_name %} - {% include "includes/summary_item.html" with title='Organization' value=DomainRequest address='true' heading_level=heading_level %} - {% endif %} + {% if DomainRequest.organization_name %} + {% include "includes/summary_item.html" with title='Organization' value=DomainRequest address='true' heading_level=heading_level %} + {% endif %} - {% if DomainRequest.about_your_organization %} - {% include "includes/summary_item.html" with title='About your organization' value=DomainRequest.about_your_organization heading_level=heading_level %} - {% endif %} + {% if DomainRequest.about_your_organization %} + {% include "includes/summary_item.html" with title='About your organization' value=DomainRequest.about_your_organization heading_level=heading_level %} + {% endif %} - {% if DomainRequest.senior_official %} - {% include "includes/summary_item.html" with title='Senior official' value=DomainRequest.senior_official contact='true' heading_level=heading_level %} - {% endif %} + {% if DomainRequest.senior_official %} + {% include "includes/summary_item.html" with title='Senior official' value=DomainRequest.senior_official contact='true' heading_level=heading_level %} + {% endif %} - {% if DomainRequest.current_websites.all %} - {% include "includes/summary_item.html" with title='Current websites' value=DomainRequest.current_websites.all list='true' heading_level=heading_level %} - {% endif %} + {% if DomainRequest.current_websites.all %} + {% include "includes/summary_item.html" with title='Current websites' value=DomainRequest.current_websites.all list='true' heading_level=heading_level %} + {% endif %} - {% if DomainRequest.requested_domain %} - {% include "includes/summary_item.html" with title='.gov domain' value=DomainRequest.requested_domain heading_level=heading_level %} - {% endif %} + {% if DomainRequest.requested_domain %} + {% include "includes/summary_item.html" with title='.gov domain' value=DomainRequest.requested_domain heading_level=heading_level %} + {% endif %} - {% if DomainRequest.alternative_domains.all %} - {% include "includes/summary_item.html" with title='Alternative domains' value=DomainRequest.alternative_domains.all list='true' heading_level=heading_level %} - {% endif %} + {% if DomainRequest.alternative_domains.all %} + {% include "includes/summary_item.html" with title='Alternative domains' value=DomainRequest.alternative_domains.all list='true' heading_level=heading_level %} + {% endif %} - {% if DomainRequest.purpose %} - {% include "includes/summary_item.html" with title='Purpose of your domain' value=DomainRequest.purpose heading_level=heading_level %} - {% endif %} + {% if DomainRequest.purpose %} + {% include "includes/summary_item.html" with title='Purpose of your domain' value=DomainRequest.purpose heading_level=heading_level %} + {% endif %} - {% if DomainRequest.creator %} - {% include "includes/summary_item.html" with title='Your contact information' value=DomainRequest.creator contact='true' heading_level=heading_level %} - {% endif %} + {% if DomainRequest.creator %} + {% include "includes/summary_item.html" with title='Your contact information' value=DomainRequest.creator contact='true' heading_level=heading_level %} + {% endif %} - {% if DomainRequest.other_contacts.all %} - {% include "includes/summary_item.html" with title='Other employees from your organization' value=DomainRequest.other_contacts.all contact='true' list='true' heading_level=heading_level %} - {% else %} - {% include "includes/summary_item.html" with title='Other employees from your organization' value=DomainRequest.no_other_contacts_rationale heading_level=heading_level %} - {% endif %} + {% if DomainRequest.other_contacts.all %} + {% include "includes/summary_item.html" with title='Other employees from your organization' value=DomainRequest.other_contacts.all contact='true' list='true' heading_level=heading_level %} + {% else %} + {% include "includes/summary_item.html" with title='Other employees from your organization' value=DomainRequest.no_other_contacts_rationale heading_level=heading_level %} + {% endif %} - {# We always show this field even if None #} - {% if DomainRequest %} -

    CISA Regional Representative

    - -

    Anything else

    - - {% endif %} - {% endwith %} + {# We always show this field even if None #} + {% if DomainRequest %} +

    CISA Regional Representative

    + +

    Anything else

    + + {% endif %} + {% endwith %} + {% endif %} {% endblock request_summary%} \ No newline at end of file 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 @@

    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 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/templatetags/custom_filters.py b/src/registrar/templatetags/custom_filters.py index b29dccb08..bd977d581 100644 --- a/src/registrar/templatetags/custom_filters.py +++ b/src/registrar/templatetags/custom_filters.py @@ -257,3 +257,28 @@ def portfolio_role_summary(user, portfolio): return user.portfolio_role_summary(portfolio) else: return [] + + +@register.filter(name="display_requesting_entity") +def display_requesting_entity(domain_request): + """Workaround for a newline issue in .txt files (our emails) as if statements + count as a newline to the file. + Will output something that looks like: + MyOrganizationName + Boise, ID + """ + display = "" + if domain_request.sub_organization: + display = domain_request.sub_organization + elif domain_request.requesting_entity_is_suborganization(): + display = ( + f"{domain_request.requested_suborganization}\n" + f"{domain_request.suborganization_city}, {domain_request.suborganization_state_territory}" + ) + elif domain_request.requesting_entity_is_portfolio(): + display = ( + f"{domain_request.portfolio.organization_name}\n" + f"{domain_request.portfolio.city}, {domain_request.portfolio.state_territory}" + ) + + return display diff --git a/src/registrar/tests/test_admin_request.py b/src/registrar/tests/test_admin_request.py index 217756359..57d7d9ac6 100644 --- a/src/registrar/tests/test_admin_request.py +++ b/src/registrar/tests/test_admin_request.py @@ -1642,6 +1642,9 @@ def test_readonly_when_restricted_creator(self): "federal_agency", "portfolio", "sub_organization", + "requested_suborganization", + "suborganization_city", + "suborganization_state_territory", "creator", "investigator", "generic_org_type", @@ -1686,7 +1689,7 @@ def test_readonly_fields_for_analyst(self): request.user = self.staffuser readonly_fields = self.admin.get_readonly_fields(request) - + self.maxDiff = None expected_fields = [ "other_contacts", "current_websites", @@ -1706,6 +1709,9 @@ def test_readonly_fields_for_analyst(self): "cisa_representative_first_name", "cisa_representative_last_name", "cisa_representative_email", + "requested_suborganization", + "suborganization_city", + "suborganization_state_territory", ] self.assertEqual(readonly_fields, expected_fields) diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index 2213d6339..402d23b70 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -2,6 +2,7 @@ from api.tests.common import less_console_noise_decorator from registrar.config import settings from registrar.models import Portfolio, SeniorOfficial +from unittest.mock import MagicMock from django_webtest import WebTest # type: ignore from registrar.models import ( DomainRequest, @@ -9,13 +10,15 @@ DomainInformation, UserDomainRole, User, + Suborganization, + AllowedEmail, ) from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.user_group import UserGroup from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.tests.test_views import TestWithUser -from .common import MockSESClient, completed_domain_request, create_test_user +from .common import MockSESClient, completed_domain_request, create_test_user, create_user from waffle.testutils import override_flag from django.contrib.sessions.middleware import SessionMiddleware import boto3_mocking # type: ignore @@ -1592,3 +1595,284 @@ def test_member_domains_not_found(self): # Make sure the response is not found self.assertEqual(response.status_code, 404) + + +class TestRequestingEntity(WebTest): + """The requesting entity page is a domain request form that only exists + within the context of a portfolio.""" + + def setUp(self): + super().setUp() + self.client = Client() + self.user = create_user() + self.portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California") + self.portfolio_2, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel Alaska") + self.suborganization, _ = Suborganization.objects.get_or_create( + name="Rocky road", + portfolio=self.portfolio, + ) + self.suborganization_2, _ = Suborganization.objects.get_or_create( + name="Vanilla", + portfolio=self.portfolio, + ) + self.unrelated_suborganization, _ = Suborganization.objects.get_or_create( + name="Cold", + portfolio=self.portfolio_2, + ) + self.portfolio_role = UserPortfolioPermission.objects.create( + portfolio=self.portfolio, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + # Login the current user + self.app.set_user(self.user.username) + + self.mock_client_class = MagicMock() + self.mock_client = self.mock_client_class.return_value + + def tearDown(self): + UserDomainRole.objects.all().delete() + DomainRequest.objects.all().delete() + DomainInformation.objects.all().delete() + Domain.objects.all().delete() + UserPortfolioPermission.objects.all().delete() + Suborganization.objects.all().delete() + Portfolio.objects.all().delete() + User.objects.all().delete() + super().tearDown() + + @override_flag("organization_feature", active=True) + @override_flag("organization_requests", active=True) + @less_console_noise_decorator + def test_requesting_entity_page_new_request(self): + """Tests that the requesting entity page loads correctly when a new request is started""" + + response = self.app.get(reverse("domain-request:")) + + # Navigate past the intro page + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + intro_form = response.forms[0] + response = intro_form.submit().follow() + + # Test the requesting entiy page + self.assertContains(response, "Who will use the domain you’re requesting?") + self.assertContains(response, "Add suborganization information") + # We expect to see the portfolio name in two places: + # the header, and as one of the radio button options. + self.assertContains(response, self.portfolio.organization_name, count=2) + + # We expect the dropdown list to contain the suborganizations that currently exist on this portfolio + self.assertContains(response, self.suborganization.name, count=1) + self.assertContains(response, self.suborganization_2.name, count=1) + + # However, we should only see suborgs that are on the actual portfolio + self.assertNotContains(response, self.unrelated_suborganization.name) + + @override_flag("organization_feature", active=True) + @override_flag("organization_requests", active=True) + @less_console_noise_decorator + def test_requesting_entity_page_existing_suborg_submission(self): + """Tests that you can submit a form on this page and set a suborg""" + response = self.app.get(reverse("domain-request:")) + + # Navigate past the intro page + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + form = response.forms[0] + response = form.submit().follow() + + # Check that we're on the right page + self.assertContains(response, "Who will use the domain you’re requesting?") + form = response.forms[0] + + # Test selecting an existing suborg + form["portfolio_requesting_entity-requesting_entity_is_suborganization"] = True + form["portfolio_requesting_entity-sub_organization"] = f"{self.suborganization.id}" + form["portfolio_requesting_entity-is_requesting_new_suborganization"] = False + + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + response = form.submit().follow() + + # Ensure that the post occurred successfully by checking that we're on the following page. + self.assertContains(response, "Current websites") + created_domain_request_exists = DomainRequest.objects.filter( + organization_name__isnull=True, sub_organization=self.suborganization + ).exists() + self.assertTrue(created_domain_request_exists) + + @override_flag("organization_feature", active=True) + @override_flag("organization_requests", active=True) + @less_console_noise_decorator + def test_requesting_entity_page_new_suborg_submission(self): + """Tests that you can submit a form on this page and set a new suborg""" + response = self.app.get(reverse("domain-request:")) + + # Navigate past the intro page + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + form = response.forms[0] + response = form.submit().follow() + + # Check that we're on the right page + self.assertContains(response, "Who will use the domain you’re requesting?") + form = response.forms[0] + + form["portfolio_requesting_entity-requesting_entity_is_suborganization"] = True + form["portfolio_requesting_entity-is_requesting_new_suborganization"] = True + form["portfolio_requesting_entity-sub_organization"] = "" + + form["portfolio_requesting_entity-requested_suborganization"] = "moon" + form["portfolio_requesting_entity-suborganization_city"] = "kepler" + form["portfolio_requesting_entity-suborganization_state_territory"] = "AL" + + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + response = form.submit().follow() + + # Ensure that the post occurred successfully by checking that we're on the following page. + self.assertContains(response, "Current websites") + created_domain_request_exists = DomainRequest.objects.filter( + organization_name__isnull=True, + sub_organization__isnull=True, + requested_suborganization="moon", + suborganization_city="kepler", + suborganization_state_territory=DomainRequest.StateTerritoryChoices.ALABAMA, + ).exists() + self.assertTrue(created_domain_request_exists) + + @override_flag("organization_feature", active=True) + @override_flag("organization_requests", active=True) + @less_console_noise_decorator + def test_requesting_entity_page_organization_submission(self): + """Tests submitting an organization on the requesting org form""" + response = self.app.get(reverse("domain-request:")) + + # Navigate past the intro page + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + form = response.forms[0] + response = form.submit().follow() + + # Check that we're on the right page + self.assertContains(response, "Who will use the domain you’re requesting?") + form = response.forms[0] + + # Test selecting an existing suborg + form["portfolio_requesting_entity-requesting_entity_is_suborganization"] = False + + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + response = form.submit().follow() + + # Ensure that the post occurred successfully by checking that we're on the following page. + self.assertContains(response, "Current websites") + created_domain_request_exists = DomainRequest.objects.filter( + organization_name=self.portfolio.organization_name, + ).exists() + self.assertTrue(created_domain_request_exists) + + @override_flag("organization_feature", active=True) + @override_flag("organization_requests", active=True) + @less_console_noise_decorator + def test_requesting_entity_page_errors(self): + """Tests that we get the expected form errors on requesting entity""" + domain_request = completed_domain_request(user=self.user, portfolio=self.portfolio) + response = self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk})).follow() + form = response.forms[0] + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + # Test missing suborganization selection + form["portfolio_requesting_entity-requesting_entity_is_suborganization"] = True + form["portfolio_requesting_entity-sub_organization"] = "" + + response = form.submit() + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + self.assertContains(response, "Suborganization is required.", status_code=200) + + # Test missing custom suborganization details + form["portfolio_requesting_entity-is_requesting_new_suborganization"] = True + response = form.submit() + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + self.assertContains(response, "Requested suborganization is required.", status_code=200) + self.assertContains(response, "City is required.", status_code=200) + self.assertContains(response, "State, territory, or military post is required.", status_code=200) + + @override_flag("organization_feature", active=True) + @override_flag("organization_requests", active=True) + @boto3_mocking.patching + @less_console_noise_decorator + def test_requesting_entity_submission_email_sent(self): + """Tests that an email is sent out on successful form submission""" + AllowedEmail.objects.create(email=self.user.email) + domain_request = completed_domain_request( + user=self.user, + # This is the additional details field + has_anything_else=True, + ) + domain_request.portfolio = self.portfolio + domain_request.requested_suborganization = "moon" + domain_request.suborganization_city = "kepler" + domain_request.suborganization_state_territory = DomainRequest.StateTerritoryChoices.ALABAMA + domain_request.save() + domain_request.refresh_from_db() + + with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): + domain_request.submit() + _, kwargs = self.mock_client.send_email.call_args + body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"] + + self.assertNotIn("Anything else", body) + self.assertIn("kepler, AL", body) + self.assertIn("Requesting entity:", body) + self.assertIn("Administrators from your organization:", body) + + @override_flag("organization_feature", active=True) + @override_flag("organization_requests", active=True) + @boto3_mocking.patching + @less_console_noise_decorator + def test_requesting_entity_viewonly(self): + """Tests the review steps page on under our viewonly context""" + domain_request = completed_domain_request( + user=create_test_user(), + # This is the additional details field + has_anything_else=True, + ) + domain_request.portfolio = self.portfolio + domain_request.requested_suborganization = "moon" + domain_request.suborganization_city = "kepler" + domain_request.suborganization_state_territory = DomainRequest.StateTerritoryChoices.ALABAMA + domain_request.save() + domain_request.refresh_from_db() + + domain_request.submit() + + response = self.app.get(reverse("domain-request-status-viewonly", kwargs={"pk": domain_request.pk})) + self.assertContains(response, "Requesting entity") + self.assertContains(response, "moon") + self.assertContains(response, "kepler, AL") + + @override_flag("organization_feature", active=True) + @override_flag("organization_requests", active=True) + @boto3_mocking.patching + @less_console_noise_decorator + def test_requesting_entity_manage(self): + """Tests the review steps page on under our manage context""" + domain_request = completed_domain_request( + user=self.user, + # This is the additional details field + has_anything_else=True, + ) + domain_request.portfolio = self.portfolio + domain_request.requested_suborganization = "moon" + domain_request.suborganization_city = "kepler" + domain_request.suborganization_state_territory = DomainRequest.StateTerritoryChoices.ALABAMA + domain_request.save() + domain_request.refresh_from_db() + + domain_request.submit() + + response = self.app.get(reverse("domain-request-status", kwargs={"pk": domain_request.pk})) + self.assertContains(response, "Requesting entity") + self.assertContains(response, "moon") + self.assertContains(response, "kepler, AL") diff --git a/src/registrar/tests/test_views_request.py b/src/registrar/tests/test_views_request.py index 17e6bcbe6..73e538df3 100644 --- a/src/registrar/tests/test_views_request.py +++ b/src/registrar/tests/test_views_request.py @@ -2887,8 +2887,6 @@ def test_domain_request_withdraw_portfolio_redirects_correctly(self): detail_page = self.app.get(f"/domain-request/{domain_request.id}") self.assertContains(detail_page, "city.gov") self.assertContains(detail_page, "city1.gov") - self.assertContains(detail_page, "Chief Tester") - self.assertContains(detail_page, "testy@town.com") self.assertContains(detail_page, "Status:") # click the "Withdraw request" button mock_client = MockSESClient() diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py index 7bbe78775..b1c5c9c89 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -137,7 +137,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView): } PORTFOLIO_UNLOCKING_STEPS = { - PortfolioDomainRequestStep.REQUESTING_ENTITY: lambda self: self.domain_request.organization_name is not None, + PortfolioDomainRequestStep.REQUESTING_ENTITY: lambda w: w.from_model("unlock_requesting_entity", False), PortfolioDomainRequestStep.CURRENT_SITES: lambda self: ( self.domain_request.current_websites.exists() or self.domain_request.requested_domain is not None ), @@ -319,7 +319,15 @@ def get(self, request, *args, **kwargs): # Clear context so the prop getter won't create a request here. # Creating a request will be handled in the post method for the # intro page. - return render(request, "domain_request_intro.html", {"hide_requests": True, "hide_domains": True}) + return render( + request, + "domain_request_intro.html", + { + "hide_requests": True, + "hide_domains": True, + "hide_members": True, + }, + ) else: return self.goto(self.steps.first) @@ -588,7 +596,43 @@ class PortfolioDomainRequestWizard(DomainRequestWizard): # Portfolio pages class RequestingEntity(DomainRequestWizard): template_name = "domain_request_requesting_entity.html" - forms = [forms.RequestingEntityForm] + forms = [forms.RequestingEntityYesNoForm, forms.RequestingEntityForm] + + def save(self, forms: list): + """Override of save to clear or associate certain suborganization data + depending on what the user wishes to do. For instance, we want to add a suborganization + if the user selects one.""" + + # Get the yes/no dropdown value + yesno_form = forms[0] + yesno_cleaned_data = yesno_form.cleaned_data + requesting_entity_is_suborganization = yesno_cleaned_data.get("requesting_entity_is_suborganization") + + # Get the suborg value, and the requested suborg value + requesting_entity_form = forms[1] + cleaned_data = requesting_entity_form.cleaned_data + sub_organization = cleaned_data.get("sub_organization") + requested_suborganization = cleaned_data.get("requested_suborganization") + + # Do some data cleanup, depending on what option was checked + if requesting_entity_is_suborganization and (sub_organization or requested_suborganization): + # Cleanup the organization name field, as this isn't for suborganizations. + requesting_entity_form.cleaned_data.update({"organization_name": None}) + else: + # If the user doesn't intend to create a suborg, simply don't make one and do some data cleanup + requesting_entity_form.cleaned_data.update( + { + "organization_name": ( + self.domain_request.portfolio.organization_name if self.domain_request.portfolio else None + ), + "sub_organization": None, + "requested_suborganization": None, + "suborganization_city": None, + "suborganization_state_territory": None, + } + ) + + super().save(forms) class PortfolioAdditionalDetails(DomainRequestWizard): @@ -834,6 +878,18 @@ def has_permission(self): return True + def get_context_data(self, **kwargs): + """Context override to add a step list to the context""" + context = super().get_context_data(**kwargs) + # Create a temp wizard object to grab the step list + if self.request.user.is_org_user(self.request): + wizard = PortfolioDomainRequestWizard() + wizard.request = self.request + context["Step"] = PortfolioDomainRequestStep.__members__ + context["steps"] = request_step_list(wizard, PortfolioDomainRequestStep) + context["form_titles"] = wizard.titles + return context + class DomainRequestWithdrawConfirmation(DomainRequestPermissionWithdrawView): """This page will ask user to confirm if they want to withdraw 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 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 @@ 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 PortfolioMemberView(PortfolioMemberPermissionView, View): template_name = "portfolio_member.html" @@ -101,7 +86,7 @@ def get(self, request, pk): 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 @@ def get(self, request, pk): 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 @@ 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_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())