Merge remote-tracking branch 'origin/main' into nl/2907-search-screenreader-output

This commit is contained in:
CocoByte 2024-11-04 10:45:31 -07:00
commit 051736dd2f
No known key found for this signature in database
GPG key ID: BBFAA2526384C97F
31 changed files with 1702 additions and 195 deletions

View file

@ -18,35 +18,30 @@ jobs:
clone-database:
runs-on: ubuntu-latest
env:
CF_USERNAME: CF_MS_USERNAME
CF_PASSWORD: CF_MS_PASSWORD
CF_USERNAME: ${{ secrets[env.CF_USERNAME] }}
CF_PASSWORD: ${{ secrets[env.CF_PASSWORD] }}
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Share DB Service
uses: cloud-gov/cg-cli-tools@main
with:
cf_username: ${{ secrets[env.CF_USERNAME] }}
cf_password: ${{ secrets[env.CF_PASSWORD] }}
cf_org: cisa-dotgov
cf_space: ${{ env.DESTINATION_ENVIRONMENT }}
cf_command: share-service getgov-${{ env.DESTINATION_ENVIRONMENT }}-database -s ${{ env.SOURCE_ENVIRONMENT }}
- name: Clone Database
uses: cloud-gov/cg-cli-tools@main
with:
cf_username: ${{ secrets.CF_MS_USERNAME }}
cf_password: ${{ secrets.CF_MS_PASSWORD }}
cf_org: cisa-dotgov
cf_space: ${{ env.SOURCE_ENVIRONMENT }}
command: cg-manage-rds clone getgov-${{ env.SOURCE_ENVIRONMENT }}-database getgov-${{ env.DESTINATION_ENVIRONMENT }}-database
- name: Unshare DB Service
uses: cloud-gov/cg-cli-tools@main
with:
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 }}
- name: Clone Database
run: |
# install cf cli and other tools
wget -q -O - https://packages.cloudfoundry.org/debian/cli.cloudfoundry.org.key | sudo gpg --dearmor -o /usr/share/keyrings/cli.cloudfoundry.org.gpg
echo "deb [signed-by=/usr/share/keyrings/cli.cloudfoundry.org.gpg] https://packages.cloudfoundry.org/debian stable main" | sudo tee /etc/apt/sources.list.d/cloudfoundry-cli.list
sudo apt-get update
sudo apt-get install cf8-cli postgresql-client
# install cg-manage-rds tool
pip install git+https://github.com/cloud-gov/cg-manage-rds.git
# Authenticate and target CF org and space.
cf api api.fr.cloud.gov
cf auth $CF_USERNAME $CF_PASSWORD
cf target -o cisa-dotgov -s $DESTINATION_ENVIRONMENT
# share the target db with the source space
cf share-service getgov-$DESTINATION_ENVIRONMENT-database -s $SOURCE_ENVIRONMENT
# clone from source to destination
cg-manage-rds clone getgov-$DESTINATION_ENVIRONMENT-database getgov-$SOURCE_ENVIRONMENT-database
# unshare the service
cf unshare-service getgov-$DESTINATION_ENVIRONMENT-database -s $SOURCE_ENVIRONMENT

View file

@ -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"
]
}

View file

@ -5,6 +5,7 @@ from django import forms
from django.db.models import Value, CharField, Q
from django.db.models.functions import Concat, Coalesce
from django.http import HttpResponseRedirect
from registrar.models.federal_agency import FederalAgency
from registrar.utility.admin_helpers import (
get_action_needed_reason_default_email,
get_rejection_reason_default_email,
@ -28,6 +29,7 @@ from waffle.models import Sample, Switch
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
@ -1478,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"]}),
@ -1748,6 +1761,9 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"fields": [
"portfolio",
"sub_organization",
"requested_suborganization",
"suborganization_city",
"suborganization_state_territory",
"status_history",
"status",
"rejection_reason",
@ -1849,6 +1865,9 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"cisa_representative_first_name",
"cisa_representative_last_name",
"cisa_representative_email",
"requested_suborganization",
"suborganization_city",
"suborganization_state_territory",
]
autocomplete_fields = [
"approved_domain",
@ -1868,6 +1887,25 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
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"""
@ -3206,6 +3244,14 @@ class PortfolioAdmin(ListHeaderAdmin):
# straightforward and the readonly_fields list can control their behavior
readonly_fields.extend([field.name for field in self.model._meta.fields])
# Make senior_official readonly for federal organizations
if obj and obj.organization_type == obj.OrganizationChoices.FEDERAL:
if "senior_official" not in readonly_fields:
readonly_fields.append("senior_official")
elif "senior_official" in readonly_fields:
# Remove senior_official from readonly_fields if org is non-federal
readonly_fields.remove("senior_official")
if request.user.has_perm("registrar.full_access_permission"):
return readonly_fields
@ -3228,12 +3274,11 @@ class PortfolioAdmin(ListHeaderAdmin):
extra_context["domain_requests"] = obj.get_domain_requests(order_by=["requested_domain__name"])
return super().change_view(request, object_id, form_url, extra_context)
def save_model(self, request, obj, form, change):
def save_model(self, request, obj: Portfolio, form, change):
if hasattr(obj, "creator") is False:
# ---- update creator ----
# Set the creator field to the current admin user
obj.creator = request.user if request.user.is_authenticated else None
obj.creator = request.user if request.user.is_authenticated else None # type: ignore
# ---- update organization name ----
# org name will be the same as federal agency, if it is federal,
# otherwise it will be the actual org name. If nothing is entered for
@ -3243,12 +3288,19 @@ class PortfolioAdmin(ListHeaderAdmin):
if is_federal and obj.organization_name is None:
obj.organization_name = obj.federal_agency.agency
# Remove this line when senior_official is no longer readonly in /admin.
if obj.federal_agency:
if obj.federal_agency.so_federal_agency.exists():
obj.senior_official = obj.federal_agency.so_federal_agency.first()
else:
obj.senior_official = None
# Set the senior official field to the senior official on the federal agency
# when federal - otherwise, clear the field.
if obj.organization_type == obj.OrganizationChoices.FEDERAL:
if obj.federal_agency:
if obj.federal_agency.so_federal_agency.exists():
obj.senior_official = obj.federal_agency.so_federal_agency.first()
else:
obj.senior_official = None
else:
if obj.federal_agency and obj.federal_agency.agency != "Non-Federal Agency":
if obj.federal_agency.so_federal_agency.first() == obj.senior_official:
obj.senior_official = None
obj.federal_agency = FederalAgency.objects.filter(agency="Non-Federal Agency").first() # type: ignore
super().save_model(request, obj, form, change)

View file

@ -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.
@ -927,6 +966,7 @@ document.addEventListener('DOMContentLoaded', function() {
// This is the additional information that exists beneath the SO element.
var contactList = document.querySelector(".field-senior_official .dja-address-contact-list");
const federalAgencyContainer = document.querySelector(".field-federal_agency");
document.addEventListener('DOMContentLoaded', function() {
let isPortfolioPage = document.getElementById("portfolio_form");
@ -975,11 +1015,13 @@ document.addEventListener('DOMContentLoaded', function() {
let selectedValue = organizationType.value;
if (selectedValue === "federal") {
hideElement(organizationNameContainer);
showElement(federalAgencyContainer);
if (federalType) {
showElement(federalType);
}
} else {
showElement(organizationNameContainer);
hideElement(federalAgencyContainer);
if (federalType) {
hideElement(federalType);
}
@ -1170,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");
}
})();

View file

@ -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;
// Hide all elements by default
allElementIds.forEach(function (elementId) {
let element = document.getElementById(elementId);
if (element) {
hideElement(element);
}
});
case 'False':
toggleTwoDomElements(elementIdToShowIfYes, elementIdToShowIfNo, 2);
break;
default:
toggleTwoDomElements(elementIdToShowIfYes, elementIdToShowIfNo, 0);
// Show the relevant element for the selected value
if (selectedValue && valueToElementMap[selectedValue]) {
let elementToShow = document.getElementById(valueToElementMap[selectedValue]);
if (elementToShow) {
showElement(elementToShow);
}
}
}
@ -328,11 +356,12 @@ function HookupYesNoListener(radioButtonName, elementIdToShowIfYes, elementIdToS
radioButton.addEventListener('change', handleRadioButtonChange);
});
// initialize
// Initialize by checking the current state
handleRadioButtonChange();
}
}
// A generic display none/block toggle function that takes an integer param to indicate how the elements toggle
function toggleTwoDomElements(ele1, ele2, index) {
let element1 = document.getElementById(ele1);
@ -912,6 +941,18 @@ function setupUrbanizationToggle(stateTerritoryField) {
HookupYesNoListener("additional_details-has_anything_else_text",'anything-else', null)
})();
/**
* An IIFE that listens to the yes/no radio buttons on the anything else form and toggles form field visibility accordingly
*
*/
(function newMemberFormListener() {
HookupRadioTogglerListener('member_access_level', {
'admin': 'new-member-admin-permissions',
'basic': 'new-member-basic-permissions'
});
})();
/**
* An IIFE that disables the delete buttons on nameserver forms on page load if < 3 forms
*
@ -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());
})();

View file

@ -120,6 +120,11 @@ urlpatterns = [
# views.PortfolioNoMembersView.as_view(),
# name="no-portfolio-members",
# ),
path(
"members/new-member/",
views.NewMemberView.as_view(),
name="new-member",
),
path(
"requests/",
views.PortfolioDomainRequestsView.as_view(),

View file

@ -13,7 +13,7 @@ from registrar.forms.utility.wizard_form_helper import (
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,10 +22,146 @@ logger = logging.getLogger(__name__)
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):

View file

@ -3,6 +3,7 @@
import logging
from django import forms
from django.core.validators import RegexValidator
from django.core.validators import MaxLengthValidator
from registrar.models import (
PortfolioInvitation,
@ -10,6 +11,7 @@ from registrar.models import (
DomainInformation,
Portfolio,
SeniorOfficial,
User,
)
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
@ -160,3 +162,112 @@ class PortfolioInvitedMemberForm(forms.ModelForm):
"roles",
"additional_permissions",
]
class NewMemberForm(forms.ModelForm):
member_access_level = forms.ChoiceField(
label="Select permission",
choices=[("admin", "Admin Access"), ("basic", "Basic Access")],
widget=forms.RadioSelect(attrs={"class": "usa-radio__input usa-radio__input--tile"}),
required=True,
error_messages={
"required": "Member access level is required",
},
)
admin_org_domain_request_permissions = forms.ChoiceField(
label="Select permission",
choices=[("view_only", "View all requests"), ("view_and_create", "View all requests plus create requests")],
widget=forms.RadioSelect,
required=True,
error_messages={
"required": "Admin domain request permission is required",
},
)
admin_org_members_permissions = forms.ChoiceField(
label="Select permission",
choices=[("view_only", "View all members"), ("view_and_create", "View all members plus manage members")],
widget=forms.RadioSelect,
required=True,
error_messages={
"required": "Admin member permission is required",
},
)
basic_org_domain_request_permissions = forms.ChoiceField(
label="Select permission",
choices=[
("view_only", "View all requests"),
("view_and_create", "View all requests plus create requests"),
("no_access", "No access"),
],
widget=forms.RadioSelect,
required=True,
error_messages={
"required": "Basic member permission is required",
},
)
email = forms.EmailField(
label="Enter the email of the member you'd like to invite",
max_length=None,
error_messages={
"invalid": ("Enter an email address in the required format, like name@example.com."),
"required": ("Enter an email address in the required format, like name@example.com."),
},
validators=[
MaxLengthValidator(
320,
message="Response must be less than 320 characters.",
)
],
required=True,
)
class Meta:
model = User
fields = ["email"]
def clean(self):
cleaned_data = super().clean()
# Lowercase the value of the 'email' field
email_value = cleaned_data.get("email")
if email_value:
cleaned_data["email"] = email_value.lower()
##########################################
# TODO: future ticket
# (invite new member)
##########################################
# Check for an existing user (if there isn't any, send an invite)
# if email_value:
# try:
# existingUser = User.objects.get(email=email_value)
# except User.DoesNotExist:
# raise forms.ValidationError("User with this email does not exist.")
member_access_level = cleaned_data.get("member_access_level")
# Intercept the error messages so that we don't validate hidden inputs
if not member_access_level:
# If no member access level has been selected, delete error messages
# for all hidden inputs (which is everything except the e-mail input
# and member access selection)
for field in self.fields:
if field in self.errors and field != "email" and field != "member_access_level":
del self.errors[field]
return cleaned_data
basic_dom_req_error = "basic_org_domain_request_permissions"
admin_dom_req_error = "admin_org_domain_request_permissions"
admin_member_error = "admin_org_members_permissions"
if member_access_level == "admin" and basic_dom_req_error in self.errors:
# remove the error messages pertaining to basic permission inputs
del self.errors[basic_dom_req_error]
elif member_access_level == "basic":
# remove the error messages pertaining to admin permission inputs
if admin_dom_req_error in self.errors:
del self.errors[admin_dom_req_error]
if admin_member_error in self.errors:
del self.errors[admin_member_error]
return cleaned_data

View file

@ -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",
),
),
]

View file

@ -425,3 +425,70 @@ class DomainInformation(TimeStampedModel):
return self.domain.get_state_display()
else:
return None
@property
def converted_organization_name(self):
if self.portfolio:
return self.portfolio.organization_name
return self.organization_name
# ----- Portfolio Properties -----
@property
def converted_generic_org_type(self):
if self.portfolio:
return self.portfolio.organization_type
return self.generic_org_type
@property
def converted_federal_agency(self):
if self.portfolio:
return self.portfolio.federal_agency
return self.federal_agency
@property
def converted_federal_type(self):
if self.portfolio:
return self.portfolio.federal_type
return self.federal_type
@property
def converted_senior_official(self):
if self.portfolio:
return self.portfolio.senior_official
return self.senior_official
@property
def converted_address_line1(self):
if self.portfolio:
return self.portfolio.address_line1
return self.address_line1
@property
def converted_address_line2(self):
if self.portfolio:
return self.portfolio.address_line2
return self.address_line2
@property
def converted_city(self):
if self.portfolio:
return self.portfolio.city
return self.city
@property
def converted_state_territory(self):
if self.portfolio:
return self.portfolio.state_territory
return self.state_territory
@property
def converted_zipcode(self):
if self.portfolio:
return self.portfolio.zipcode
return self.zipcode
@property
def converted_urbanization(self):
if self.portfolio:
return self.portfolio.urbanization
return self.urbanization

View file

@ -13,6 +13,8 @@ from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
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 @@ class DomainRequest(TimeStampedModel):
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 @@ class DomainRequest(TimeStampedModel):
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 @@ class DomainRequest(TimeStampedModel):
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

View file

@ -2,6 +2,8 @@ from django.db import models
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 @@ class Portfolio(TimeStampedModel):
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"""

View file

@ -4,6 +4,7 @@
{# hint: spacing in the class string matters #}
class="{{ uswds_input_class }}{% if classes %} {{ classes }}{% endif %}"
{% if widget.value != None %}value="{{ widget.value|stringformat:'s' }}"{% endif %}
{% if aria_label %}aria-label="{{ aria_label }} {{ label }}"{% endif %}
{% if sublabel_text %}aria-describedby="{{ widget.attrs.id }}__sublabel"{% endif %}
{% include "django/forms/widgets/attrs.html" %}
/>

View file

@ -63,11 +63,12 @@
<div class="grid-row margin-top-1">
<div class="grid-col">
<button type="button" class="usa-button usa-button--unstyled usa-button--with-icon float-right-tablet delete-record text-secondary line-height-sans-5">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
</svg>Delete
</button>
<button type="button" id="button label" class="usa-button usa-button--unstyled usa-button--with-icon float-right-tablet delete-record text-secondary line-height-sans-5">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
</svg>Delete
<span class="sr-only">DS data record {{forloop.counter}}</span>
</button>
</div>
</div>

View file

@ -47,7 +47,7 @@
{% endwith %}
</div>
<div class="tablet:grid-col-5">
{% with sublabel_text="Example: 86.124.49.54 or 2001:db8::1234:5678" add_group_class="usa-form-group--unstyled-error" %}
{% with label_text=form.ip.label sublabel_text="Example: 86.124.49.54 or 2001:db8::1234:5678" add_group_class="usa-form-group--unstyled-error" add_aria_label="Name server "|concat:forloop.counter|concat:" "|concat:form.ip.label %}
{% input_with_errors form.ip %}
{% endwith %}
</div>
@ -56,6 +56,7 @@
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
</svg>Delete
<span class="sr-only">Name server {{forloop.counter}}</span>
</button>
</div>
</div>

View file

@ -2,15 +2,58 @@
{% load field_helpers url_helpers %}
{% block form_instructions %}
<p>🛸🛸🛸🛸 Placeholder content 🛸🛸🛸🛸</p>
<p>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.</p>
<p>We define a suborganization as any entity (agency, bureau, office) that falls under the overarching organization.</p>
{% endblock %}
{% block form_fields %}
<fieldset class="usa-fieldset">
<legend>
<h2>What is the name of your space vessel?</h2>
<h2>Who will use the domain youre requesting?</h2>
</legend>
{% input_with_errors forms.0.organization_name %}
<p class="margin-bottom-0 margin-top-1">
<em>Select one. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></em>
</p>
{# 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 %}
<div id="suborganization-container" class="margin-top-4">
<h2>Add suborganization information</h2>
<p>
This information will be published in <a class="usa-link usa-link--always-blue" href="{% public_site_url 'about/data' %}">.govs public data</a>. If you dont see your suborganization in the list,
select “other” and enter the name or your suborganization.
</p>
{% 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 %}
<div id="suborganization-container__details">
{% 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 %}
</div>
</div>
</fieldset>
{% endblock %}

View file

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

View file

@ -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

View file

@ -93,9 +93,9 @@
</li>
{% endif %}
{% if has_organization_members_flag %}
{% if has_organization_members_flag and not hide_members %}
<li class="usa-nav__primary-item">
<a href="/members/" class="usa-nav-link {% if path|is_members_subpage %} usa-current{% endif %}">
<a href="{% url 'members' %}" class="usa-nav-link {% if path|is_members_subpage %} usa-current{% endif %}">
Members
</a>
</li>

View file

@ -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 %}
<p class="margin-y-0">{{domain_request.suborganization_city}}, {{domain_request.suborganization_state_territory}}</p>
{% 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 %}
<p class="margin-y-0">{{domain_request.portfolio.city}}, {{domain_request.portfolio.state_territory}}</p>
{% endif %}
{% else %}
{% with title=form_titles|get_item:step value="<span class='text-bold text-secondary-dark'>Incomplete</span>"|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="<span class='text-bold text-secondary-dark'>Incomplete</span>"|safe %}
{% include "includes/summary_item.html" with edit_link=domain_request_url %}
{% endwith %}
{% endif%}
{% endif %}
{% endwith %}
{% endif %}
{% if step == Step.CURRENT_SITES %}

View file

@ -145,92 +145,96 @@
{% 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 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 %}
<p>Federally-recognized tribe</p>
{% endif %}
{% if DomainRequest.state_recognized_tribe %}
<p>State-recognized tribe</p>
{% 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.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.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.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.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.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 %}
{% if portfolio %}
{% include "includes/portfolio_request_review_steps.html" with is_editable=False domain_request=DomainRequest %}
{% else %}
{% include "includes/summary_item.html" with title='Other employees from your organization' value=DomainRequest.no_other_contacts_rationale heading_level=heading_level %}
{% endif %}
{% 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 %}
{# We always show this field even if None #}
{% if DomainRequest %}
<h3 class="register-form-review-header">CISA Regional Representative</h3>
<ul class="usa-list usa-list--unstyled margin-top-0">
{% if DomainRequest.cisa_representative_first_name %}
{{ DomainRequest.get_formatted_cisa_rep_name }}
{% else %}
No
{% 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 %}
<p>Federally-recognized tribe</p>
{% endif %}
</ul>
<h3 class="register-form-review-header">Anything else</h3>
<ul class="usa-list usa-list--unstyled margin-top-0">
{% if DomainRequest.anything_else %}
{{DomainRequest.anything_else}}
{% else %}
No
{% if DomainRequest.state_recognized_tribe %}
<p>State-recognized tribe</p>
{% endif %}
</ul>
{% endif %}
{% endwith %}
{% 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.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.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.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.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.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 %}
<h3 class="register-form-review-header">CISA Regional Representative</h3>
<ul class="usa-list usa-list--unstyled margin-top-0">
{% if DomainRequest.cisa_representative_first_name %}
{{ DomainRequest.get_formatted_cisa_rep_name }}
{% else %}
No
{% endif %}
</ul>
<h3 class="register-form-review-header">Anything else</h3>
<ul class="usa-list usa-list--unstyled margin-top-0">
{% if DomainRequest.anything_else %}
{{DomainRequest.anything_else}}
{% else %}
No
{% endif %}
</ul>
{% endif %}
{% endwith %}
{% endif %}
{% endblock request_summary%}
</div>
</main>

View file

@ -21,7 +21,7 @@
{% if has_edit_members_portfolio_permission %}
<div class="mobile:grid-col-12 tablet:grid-col-6">
<p class="float-right-tablet tablet:margin-y-0">
<a href="#" class="usa-button"
<a href="{% url 'new-member' %}" class="usa-button"
>
Add a new member
</a>

View file

@ -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 %}
<!-- Form mesages -->
{% include "includes/form_errors.html" with form=form %}
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock messages%}
<!-- Navigation breadcrumbs -->
<nav class="usa-breadcrumb padding-top-0" aria-label="Domain request breadcrumb">
<ol class="usa-breadcrumb__list">
<li class="usa-breadcrumb__list-item">
<a href="{% url 'members' %}" class="usa-breadcrumb__link"><span>Members</span></a>
</li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>Add a new member</span>
</li>
</ol>
</nav>
<!-- Page header -->
{% block new_member_header %}
<h1>Add a new member</h1>
{% endblock new_member_header %}
{% include "includes/required_fields.html" %}
<form class="usa-form usa-form--large" method="post" novalidate>
<fieldset class="usa-fieldset margin-top-2">
<legend>
<h2>Email</h2>
</legend>
<!-- Member email -->
{% csrf_token %}
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
{% input_with_errors form.email %}
{% endwith %}
</fieldset>
<!-- Member access radio buttons (Toggles other sections) -->
<fieldset class="usa-fieldset margin-top-2">
<legend>
<h2>Member Access</h2>
</legend>
<em>Select the level of access for this member. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></em>
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
<div class="usa-radio">
{% for radio in form.member_access_level %}
{{ radio.tag }}
<label class="usa-radio__label usa-legend" for="{{ radio.id_for_label }}">
{{ radio.choice_label }}
<p class="margin-0 margin-top-2">
{% if radio.choice_label == "Admin Access" %}
Grants this member access to the organization-wide information on domains, domain requests, and members. Domain management can be assigned separately.
{% else %}
Grants this member access to the organization. They can be given extra permissions to view all organization domain requests and submit domain requests on behalf of the organization. Basic access members cant view all members of an organization or manage them. Domain management can be assigned separately.
{% endif %}
</p>
</label>
{% endfor %}
</div>
{% endwith %}
</fieldset>
<!-- Admin access form -->
<div id="new-member-admin-permissions" class="margin-top-2">
<h2>Admin access permissions</h2>
<p>Member permissions available for admin-level acccess.</p>
<h3 class="margin-bottom-0">Organization domain requests</h3>
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
{% input_with_errors form.admin_org_domain_request_permissions %}
{% endwith %}
<h3 class="margin-bottom-0 margin-top-3">Organization members</h3>
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
{% input_with_errors form.admin_org_members_permissions %}
{% endwith %}
</div>
<!-- Basic access form -->
<div id="new-member-basic-permissions" class="margin-top-2">
<h2>Basic member permissions</h2>
<p>Member permissions available for basic-level access</p>
{% input_with_errors form.basic_org_domain_request_permissions %}
</div>
<!-- Submit/cancel buttons -->
<div class="margin-top-3">
<a
type="button"
href="{% url 'members' %}"
class="usa-button usa-button--outline"
name="btn-cancel-click"
aria-label="Cancel adding new member"
>Cancel
</a>
<button type="submit" class="usa-button">Invite Member</button>
</div>
</form>
{% endblock portfolio_content%}

View file

@ -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

View file

@ -24,6 +24,7 @@ def input_with_errors(context, field=None): # noqa: C901
add_label_class: append to input element's label's `class` attribute
add_legend_class: append to input element's legend's `class` attribute
add_group_class: append to input element's surrounding tag's `class` attribute
add_aria_label: append to input element's `aria_label` attribute
attr_* - adds or replaces any single html attribute for the input
add_error_attr_* - like `attr_*` but only if field.errors is not empty
toggleable_input: shows a simple edit button, and adds display-none to the input field.
@ -55,6 +56,7 @@ def input_with_errors(context, field=None): # noqa: C901
label_classes = []
legend_classes = []
group_classes = []
aria_labels = []
# this will be converted to an attribute string
described_by = []
@ -98,6 +100,9 @@ def input_with_errors(context, field=None): # noqa: C901
if "display-none" not in classes:
classes.append("display-none")
elif key == "add_aria_label":
aria_labels.append(value)
attrs["id"] = field.auto_id
# do some work for various edge cases
@ -151,7 +156,10 @@ def input_with_errors(context, field=None): # noqa: C901
# ensure we don't overwrite existing attribute value
if "aria-describedby" in attrs:
described_by.append(attrs["aria-describedby"])
attrs["aria-describedby"] = " ".join(described_by)
attrs["aria_describedby"] = " ".join(described_by)
if aria_labels:
context["aria_label"] = " ".join(aria_labels)
# ask Django to give us the widget dict
# see Widget.get_context() on

View file

@ -2103,6 +2103,66 @@ class TestPortfolioAdmin(TestCase):
display_members = self.admin.display_members(self.portfolio)
self.assertIn(f'<a href="{url}">2 members</a>', display_members)
@less_console_noise_decorator
def test_senior_official_readonly_for_federal_org(self):
"""Test that senior_official field is readonly for federal organizations"""
request = self.factory.get("/")
request.user = self.superuser
# Create a federal portfolio
portfolio = Portfolio.objects.create(
organization_name="Test Federal Org",
organization_type=DomainRequest.OrganizationChoices.FEDERAL,
creator=self.superuser,
)
readonly_fields = self.admin.get_readonly_fields(request, portfolio)
self.assertIn("senior_official", readonly_fields)
# Change to non-federal org
portfolio.organization_type = DomainRequest.OrganizationChoices.CITY
readonly_fields = self.admin.get_readonly_fields(request, portfolio)
self.assertNotIn("senior_official", readonly_fields)
@less_console_noise_decorator
def test_senior_official_auto_assignment(self):
"""Test automatic senior official assignment based on organization type and federal agency"""
request = self.factory.get("/")
request.user = self.superuser
# Create a federal agency with a senior official
federal_agency = FederalAgency.objects.create(agency="Test Agency")
senior_official = SeniorOfficial.objects.create(
first_name="Test",
last_name="Official",
title="Some guy",
email="test@example.gov",
federal_agency=federal_agency,
)
# Create a federal portfolio
portfolio = Portfolio.objects.create(
organization_name="Test Federal Org",
organization_type=DomainRequest.OrganizationChoices.FEDERAL,
creator=self.superuser,
)
# Test that the federal org gets senior official from agency when federal
portfolio.federal_agency = federal_agency
self.admin.save_model(request, portfolio, form=None, change=False)
self.assertEqual(portfolio.senior_official, senior_official)
# Test non-federal org clears senior official when not city
portfolio.organization_type = DomainRequest.OrganizationChoices.CITY
self.admin.save_model(request, portfolio, form=None, change=True)
self.assertIsNone(portfolio.senior_official)
self.assertEqual(portfolio.federal_agency.agency, "Non-Federal Agency")
# Cleanup
senior_official.delete()
federal_agency.delete()
portfolio.delete()
class TestTransferUser(WebTest):
"""User transfer custom admin page"""

View file

@ -1642,6 +1642,9 @@ class TestDomainRequestAdmin(MockEppLib):
"federal_agency",
"portfolio",
"sub_organization",
"requested_suborganization",
"suborganization_city",
"suborganization_state_territory",
"creator",
"investigator",
"generic_org_type",
@ -1686,7 +1689,7 @@ class TestDomainRequestAdmin(MockEppLib):
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 @@ class TestDomainRequestAdmin(MockEppLib):
"cisa_representative_first_name",
"cisa_representative_last_name",
"cisa_representative_email",
"requested_suborganization",
"suborganization_city",
"suborganization_state_territory",
]
self.assertEqual(readonly_fields, expected_fields)

View file

@ -2,6 +2,7 @@ from django.urls import reverse
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 @@ from registrar.models import (
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 @@ class TestPortfolioInvitedMemberDomainsView(TestWithUser, WebTest):
# 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 youre 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 youre 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 youre 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 youre 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")

View file

@ -2887,8 +2887,6 @@ class DomainRequestTestDifferentStatuses(TestWithUser, WebTest):
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()

View file

@ -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 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
# 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 @@ class DomainRequestStatus(DomainRequestPermissionView):
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

View file

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