diff --git a/.github/ISSUE_TEMPLATE/designer-onboarding.md b/.github/ISSUE_TEMPLATE/designer-onboarding.md
index 2a4cab3c2..f6518109f 100644
--- a/.github/ISSUE_TEMPLATE/designer-onboarding.md
+++ b/.github/ISSUE_TEMPLATE/designer-onboarding.md
@@ -30,8 +30,9 @@ Welcome to the .gov team! We're excited to have you here. Please follow the step
- [ ] Review our [design tools](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit?pli=1#heading=h.aprurp3z4gmv).
- [ ] Accept invitation to our [Figma workspace](https://www.figma.com/files/1287135731043703282/team/1299882813146449644).
- [ ] Follow the steps in [preparing for your sandbox](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit?pli=1#heading=h.au66hq5e0l8s) section on the onboarding doc.
-- [ ] Schedule coffee chats with Design Lead, other designers, scrum master, and product manager ([team directory](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit?pli=1#heading=h.1vq6r8e52e9f)).
+- [ ] Schedule coffee chats with Design Leads and other teammates ([team directory](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit?pli=1#heading=h.1vq6r8e52e9f)).
- [ ] Look over [recommended reading](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit?pli=1#heading=h.7ox9ee7v5q5n) and [relevant links](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit?pli=1#heading=h.d9pac1gc751t).
+- [ ] Fill out the [Emails](https://docs.google.com/spreadsheets/d/1a6wj8I7FzWGP1AyIhAwP7yL84mXzORao8D_Q3c1xs-g/edit?gid=1637270167#gid=1637270167) tab of the Team OOO spreadsheet. To create a scheduling link, follow these instructions: [Outlook](https://learn.microsoft.com/en-us/microsoft-365/bookings/create-new-meeting-type?view=o365-worldwide) or [Google Calendar](https://support.google.com/calendar/answer/10729749?hl=en)
- [ ] Fill out your own Personal Operating Manual (POM) on the [team norming board](https://miro.com/app/board/uXjVMxMu1SA=/). OPTIONAL: Present it on the next team coffee meeting.
- [ ] FOR FEDERAL EMPLOYEES: Check in with your manager on the CISA onboarding process and getting your PIV card.
- [ ] FOR CONTRACTORS: Check with your manager on your EOD clearance process.
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 18c1052fc..3ad5e3ea0 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -9,8 +9,7 @@ from django.db.models.functions import Concat, Coalesce
from django.http import HttpResponseRedirect
from django.shortcuts import redirect
from django_fsm import get_available_FIELD_transitions, FSMField
-from registrar.models.domain_group import DomainGroup
-from registrar.models.suborganization import Suborganization
+from registrar.models.domain_information import DomainInformation
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from waffle.decorators import flag_is_active
from django.contrib import admin, messages
@@ -23,6 +22,7 @@ from registrar.models.user_domain_role import UserDomainRole
from waffle.admin import FlagAdmin
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.views.utility.mixins import OrderableFieldsMixin
from django.contrib.admin.views.main import ORDER_VAR
@@ -39,7 +39,7 @@ from import_export import resources
from import_export.admin import ImportExportModelAdmin
from django.core.exceptions import ObjectDoesNotExist
from django.contrib.admin.widgets import FilteredSelectMultiple
-
+from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
logger = logging.getLogger(__name__)
@@ -2864,16 +2864,112 @@ class VerifiedByStaffAdmin(ListHeaderAdmin):
class PortfolioAdmin(ListHeaderAdmin):
-
change_form_template = "django/admin/portfolio_change_form.html"
+ fieldsets = [
+ # created_on is the created_at field, and portfolio_type is f"{organization_type} - {federal_type}"
+ (None, {"fields": ["portfolio_type", "organization_name", "creator", "created_on", "notes"]}),
+ # TODO - uncomment in #2521
+ # ("Portfolio members", {
+ # "classes": ("collapse", "closed"),
+ # "fields": ["administrators", "members"]}
+ # ),
+ ("Portfolio domains", {"classes": ("collapse", "closed"), "fields": ["domains", "domain_requests"]}),
+ ("Type of organization", {"fields": ["organization_type", "federal_type"]}),
+ (
+ "Organization name and mailing address",
+ {
+ "fields": [
+ "federal_agency",
+ "state_territory",
+ "address_line1",
+ "address_line2",
+ "city",
+ "zipcode",
+ "urbanization",
+ ]
+ },
+ ),
+ ("Suborganizations", {"fields": ["suborganizations"]}),
+ ("Senior official", {"fields": ["senior_official"]}),
+ ]
+
+ # This is the fieldset display when adding a new model
+ add_fieldsets = [
+ (None, {"fields": ["organization_name", "creator", "notes"]}),
+ ("Type of organization", {"fields": ["organization_type"]}),
+ (
+ "Organization name and mailing address",
+ {
+ "fields": [
+ "federal_agency",
+ "state_territory",
+ "address_line1",
+ "address_line2",
+ "city",
+ "zipcode",
+ "urbanization",
+ ]
+ },
+ ),
+ ("Senior official", {"fields": ["senior_official"]}),
+ ]
list_display = ("organization_name", "federal_agency", "creator")
search_fields = ["organization_name"]
search_help_text = "Search by organization name."
readonly_fields = [
- "creator",
+ # This is the created_at field
+ "created_on",
+ # Custom fields such as these must be defined as readonly.
+ "federal_type",
+ "domains",
+ "domain_requests",
+ "suborganizations",
+ "portfolio_type",
]
+ def federal_type(self, obj: models.Portfolio):
+ """Returns the federal_type field"""
+ return BranchChoices.get_branch_label(obj.federal_type) if obj.federal_type else "-"
+
+ federal_type.short_description = "Federal type" # type: ignore
+
+ def created_on(self, obj: models.Portfolio):
+ """Returns the created_at field, with a different short description"""
+ # Format: Dec 12, 2024
+ return obj.created_at.strftime("%b %d, %Y") if obj.created_at else "-"
+
+ created_on.short_description = "Created on" # type: ignore
+
+ def portfolio_type(self, obj: models.Portfolio):
+ """Returns the portfolio type, or "-" if the result is empty"""
+ return obj.portfolio_type if obj.portfolio_type else "-"
+
+ portfolio_type.short_description = "Portfolio type" # type: ignore
+
+ def suborganizations(self, obj: models.Portfolio):
+ """Returns a list of links for each related suborg"""
+ queryset = obj.get_suborganizations()
+ return self.get_field_links_as_list(queryset, "suborganization")
+
+ suborganizations.short_description = "Suborganizations" # type: ignore
+
+ def domains(self, obj: models.Portfolio):
+ """Returns a list of links for each related domain"""
+ queryset = obj.get_domains()
+ return self.get_field_links_as_list(
+ queryset, "domaininformation", link_info_attribute="get_state_display_of_domain"
+ )
+
+ domains.short_description = "Domains" # type: ignore
+
+ def domain_requests(self, obj: models.Portfolio):
+ """Returns a list of links for each related domain request"""
+ queryset = obj.get_domain_requests()
+ return self.get_field_links_as_list(queryset, "domainrequest", link_info_attribute="get_status_display")
+
+ domain_requests.short_description = "Domain requests" # type: ignore
+
# Creates select2 fields (with search bars)
autocomplete_fields = [
"creator",
@@ -2881,17 +2977,91 @@ class PortfolioAdmin(ListHeaderAdmin):
"senior_official",
]
+ def get_field_links_as_list(
+ self, queryset, model_name, attribute_name=None, link_info_attribute=None, seperator=None
+ ):
+ """
+ Generate HTML links for items in a queryset, using a specified attribute for link text.
+
+ Args:
+ queryset: The queryset of items to generate links for.
+ model_name: The model name used to construct the admin change URL.
+ attribute_name: The attribute or method name to use for link text. If None, the item itself is used.
+ link_info_attribute: Appends f"({value_of_attribute})" to the end of the link.
+ separator: The separator to use between links in the resulting HTML.
+ If none, an unordered list is returned.
+
+ Returns:
+ A formatted HTML string with links to the admin change pages for each item.
+ """
+ links = []
+ for item in queryset:
+
+ # This allows you to pass in attribute_name="get_full_name" for instance.
+ if attribute_name:
+ item_display_value = self.value_of_attribute(item, attribute_name)
+ else:
+ item_display_value = item
+
+ if item_display_value:
+ change_url = reverse(f"admin:registrar_{model_name}_change", args=[item.pk])
+
+ link = f'{escape(item_display_value)}'
+ if link_info_attribute:
+ link += f" ({self.value_of_attribute(item, link_info_attribute)})"
+
+ if seperator:
+ links.append(link)
+ else:
+ links.append(f"
{link}
")
+
+ # If no seperator is specified, just return an unordered list.
+ if seperator:
+ return format_html(seperator.join(links)) if links else "-"
+ else:
+ links = "".join(links)
+ return format_html(f'
{links}
') if links else "-"
+
+ def value_of_attribute(self, obj, attribute_name: str):
+ """Returns the value of getattr if the attribute isn't callable.
+ If it is, execute the underlying function and return that result instead."""
+ value = getattr(obj, attribute_name)
+ if callable(value):
+ value = value()
+ return value
+
+ def get_fieldsets(self, request, obj=None):
+ """Override of the default get_fieldsets definition to add an add_fieldsets view"""
+ # This is the add view if no obj exists
+ if not obj:
+ return self.add_fieldsets
+ return super().get_fieldsets(request, obj)
+
+ def get_readonly_fields(self, request, obj=None):
+ """Set the read-only state on form elements.
+ We have 2 conditions that determine which fields are read-only:
+ admin user permissions and the creator's status, so
+ we'll use the baseline readonly_fields and extend it as needed.
+ """
+ readonly_fields = list(self.readonly_fields)
+
+ # Check if the creator is restricted
+ if obj and obj.creator.status == models.User.RESTRICTED:
+ # For fields like CharField, IntegerField, etc., the widget used is
+ # straightforward and the readonly_fields list can control their behavior
+ readonly_fields.extend([field.name for field in self.model._meta.fields])
+
+ if request.user.has_perm("registrar.full_access_permission"):
+ return readonly_fields
+
+ # Return restrictive Read-only fields for analysts and
+ # users who might not belong to groups
+ readonly_fields.extend([field for field in self.analyst_readonly_fields])
+ return readonly_fields
+
def change_view(self, request, object_id, form_url="", extra_context=None):
"""Add related suborganizations and domain groups"""
- obj = self.get_object(request, object_id)
-
- # ---- Domain Groups
- domain_groups = DomainGroup.objects.filter(portfolio=obj)
-
- # ---- Suborganizations
- suborganizations = Suborganization.objects.filter(portfolio=obj)
-
- extra_context = {"domain_groups": domain_groups, "suborganizations": suborganizations}
+ extra_context = {"skip_additional_contact_info": True}
return super().change_view(request, object_id, form_url, extra_context)
def save_model(self, request, obj, form, change):
@@ -2973,12 +3143,32 @@ class DomainGroupAdmin(ListHeaderAdmin, ImportExportModelAdmin):
class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
+
list_display = ["name", "portfolio"]
autocomplete_fields = [
"portfolio",
]
search_fields = ["name"]
+ change_form_template = "django/admin/suborg_change_form.html"
+
+ def change_view(self, request, object_id, form_url="", extra_context=None):
+ """Add suborg's related domains and requests to context"""
+ obj = self.get_object(request, object_id)
+
+ # ---- Domain Requests
+ domain_requests = DomainRequest.objects.filter(sub_organization=obj)
+ sort_by = request.GET.get("sort_by", "requested_domain__name")
+ domain_requests = domain_requests.order_by(sort_by)
+
+ # ---- Domains
+ domain_infos = DomainInformation.objects.filter(sub_organization=obj)
+ domain_ids = domain_infos.values_list("domain", flat=True)
+ domains = Domain.objects.filter(id__in=domain_ids).exclude(state=Domain.State.DELETED)
+
+ extra_context = {"domain_requests": domain_requests, "domains": domains}
+ return super().change_view(request, object_id, form_url, extra_context)
+
admin.site.unregister(LogEntry) # Unregister the default registration
diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js
index 93b8359bf..01c93abf6 100644
--- a/src/registrar/assets/js/get-gov-admin.js
+++ b/src/registrar/assets/js/get-gov-admin.js
@@ -765,20 +765,22 @@ function initializeWidgetOnList(list, parentId) {
*/
(function dynamicPortfolioFields(){
+ // the federal agency change listener fires on page load, which we don't want.
+ var isInitialPageLoad = true
+
+ // This is the additional information that exists beneath the SO element.
+ var contactList = document.querySelector(".field-senior_official .dja-address-contact-list");
document.addEventListener('DOMContentLoaded', function() {
-
+
let isPortfolioPage = document.getElementById("portfolio_form");
if (!isPortfolioPage) {
return;
}
-
+
// $ symbolically denotes that this is using jQuery
let $federalAgency = django.jQuery("#id_federal_agency");
let organizationType = document.getElementById("id_organization_type");
if ($federalAgency && organizationType) {
- // Execute this function once on load
- handleFederalAgencyChange($federalAgency, organizationType);
-
// Attach the change event listener
$federalAgency.on("change", function() {
handleFederalAgencyChange($federalAgency, organizationType);
@@ -800,6 +802,12 @@ function initializeWidgetOnList(list, parentId) {
});
function handleFederalAgencyChange(federalAgency, organizationType) {
+ // Don't do anything on page load
+ if (isInitialPageLoad) {
+ isInitialPageLoad = false;
+ return;
+ }
+
// Set the org type to federal if an agency is selected
let selectedText = federalAgency.find("option:selected").text();
@@ -825,6 +833,10 @@ function initializeWidgetOnList(list, parentId) {
return;
}
+ // Hide the contactList initially.
+ // If we can update the contact information, it'll be shown again.
+ hideElement(contactList.parentElement);
+
let seniorOfficialApi = document.getElementById("senior_official_from_agency_json_url").value;
fetch(`${seniorOfficialApi}?agency_name=${selectedText}`)
.then(response => {
@@ -843,6 +855,10 @@ function initializeWidgetOnList(list, parentId) {
return;
}
+ // Update the "contact details" blurb beneath senior official
+ updateContactInfo(data);
+ showElement(contactList.parentElement);
+
let seniorOfficialId = data.id;
let seniorOfficialName = [data.first_name, data.last_name].join(" ");
if (!seniorOfficialId || !seniorOfficialName || !seniorOfficialName.trim()){
@@ -873,4 +889,35 @@ function initializeWidgetOnList(list, parentId) {
hideElement(urbanizationField)
}
}
+
+ function updateContactInfo(data) {
+ if (!contactList) return;
+
+ const titleSpan = contactList.querySelector("#contact_info_title");
+ const emailSpan = contactList.querySelector("#contact_info_email");
+ const phoneSpan = contactList.querySelector("#contact_info_phone");
+
+ if (titleSpan) {
+ titleSpan.textContent = data.title || "None";
+ };
+
+ // Update the email field and the content for the clipboard
+ if (emailSpan) {
+ let copyButton = contactList.querySelector(".admin-icon-group");
+ emailSpan.textContent = data.email || "None";
+ if (data.email) {
+ const clipboardInput = contactList.querySelector(".admin-icon-group input");
+ if (clipboardInput) {
+ clipboardInput.value = data.email;
+ };
+ showElement(copyButton);
+ }else {
+ hideElement(copyButton);
+ }
+ }
+
+ if (phoneSpan) {
+ phoneSpan.textContent = data.phone || "None";
+ };
+ }
})();
diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss
index 711bfe960..8ca6b5465 100644
--- a/src/registrar/assets/sass/_theme/_admin.scss
+++ b/src/registrar/assets/sass/_theme/_admin.scss
@@ -847,3 +847,8 @@ div.dja__model-description{
}
}
}
+
+ul.add-list-reset {
+ padding: 0 !important;
+ margin: 0 !important;
+}
diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py
index 9d707a533..73aecad7a 100644
--- a/src/registrar/config/settings.py
+++ b/src/registrar/config/settings.py
@@ -356,9 +356,18 @@ CSP_FORM_ACTION = allowed_sources
# strict CSP by allowing scripts to run from their domain
# and inline with a nonce, as well as allowing connections back to their domain.
# Note: If needed, we can embed chart.js instead of using the CDN
-CSP_SCRIPT_SRC_ELEM = ["'self'", "https://www.googletagmanager.com/", "https://cdn.jsdelivr.net/npm/chart.js"]
-CSP_CONNECT_SRC = ["'self'", "https://www.google-analytics.com/"]
-CSP_INCLUDE_NONCE_IN = ["script-src-elem"]
+CSP_DEFAULT_SRC = ("'self'",)
+CSP_STYLE_SRC = ["'self'", "https://www.ssa.gov/accessibility/andi/andi.css"]
+CSP_SCRIPT_SRC_ELEM = [
+ "'self'",
+ "https://www.googletagmanager.com/",
+ "https://cdn.jsdelivr.net/npm/chart.js",
+ "https://www.ssa.gov",
+ "https://ajax.googleapis.com",
+]
+CSP_CONNECT_SRC = ["'self'", "https://www.google-analytics.com/", "https://www.ssa.gov/accessibility/andi/andi.js"]
+CSP_INCLUDE_NONCE_IN = ["script-src-elem", "style-src"]
+CSP_IMG_SRC = ["'self'", "https://www.ssa.gov/accessibility/andi/icons/"]
# Cross-Origin Resource Sharing (CORS) configuration
# Sets clients that allow access control to manage.get.gov
diff --git a/src/registrar/fixtures_users.py b/src/registrar/fixtures_users.py
index 7ce63d364..0fc203248 100644
--- a/src/registrar/fixtures_users.py
+++ b/src/registrar/fixtures_users.py
@@ -22,6 +22,12 @@ class UserFixture:
"""
ADMINS = [
+ {
+ "username": "43a7fa8d-0550-4494-a6fe-81500324d590",
+ "first_name": "Jyoti",
+ "last_name": "Bock",
+ "email": "jyotibock@truss.works",
+ },
{
"username": "aad084c3-66cc-4632-80eb-41cdf5c5bcbf",
"first_name": "Aditi",
@@ -125,6 +131,12 @@ class UserFixture:
]
STAFF = [
+ {
+ "username": "a5906815-dd80-4c64-aebe-2da6a4c9d7a4",
+ "first_name": "Jyoti-Analyst",
+ "last_name": "Bock-Analyst",
+ "email": "jyotibock+1@truss.works",
+ },
{
"username": "ffec5987-aa84-411b-a05a-a7ee5cbcde54",
"first_name": "Aditi-Analyst",
diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py
index 431aa30a7..d97dd0de7 100644
--- a/src/registrar/forms/domain_request_wizard.py
+++ b/src/registrar/forms/domain_request_wizard.py
@@ -679,7 +679,7 @@ class CisaRepresentativeYesNoForm(BaseYesNoForm):
field_name = "has_cisa_representative"
-class AdditionalDetailsForm(BaseDeletableRegistrarForm):
+class AnythingElseForm(BaseDeletableRegistrarForm):
anything_else = forms.CharField(
required=True,
label="Anything else?",
@@ -698,7 +698,7 @@ class AdditionalDetailsForm(BaseDeletableRegistrarForm):
)
-class AdditionalDetailsYesNoForm(BaseYesNoForm):
+class AnythingElseYesNoForm(BaseYesNoForm):
"""Yes/no toggle for the anything else question on additional details"""
# Note that these can be set as functions/init if you need more fine-grained control.
diff --git a/src/registrar/migrations/0118_alter_portfolio_options_alter_portfolio_creator_and_more.py b/src/registrar/migrations/0118_alter_portfolio_options_alter_portfolio_creator_and_more.py
new file mode 100644
index 000000000..8f84187a2
--- /dev/null
+++ b/src/registrar/migrations/0118_alter_portfolio_options_alter_portfolio_creator_and_more.py
@@ -0,0 +1,50 @@
+# Generated by Django 4.2.10 on 2024-08-15 15:32
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("registrar", "0117_alter_portfolioinvitation_portfolio_additional_permissions_and_more"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="portfolio",
+ options={"ordering": ["organization_name"]},
+ ),
+ migrations.AlterField(
+ model_name="portfolio",
+ name="creator",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="created_portfolios",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Portfolio creator",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="portfolio",
+ name="organization_name",
+ field=models.CharField(blank=True, null=True, verbose_name="Portfolio organization"),
+ ),
+ migrations.AlterField(
+ model_name="portfolio",
+ name="senior_official",
+ field=models.ForeignKey(
+ blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to="registrar.seniorofficial"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="suborganization",
+ name="portfolio",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="portfolio_suborganizations",
+ to="registrar.portfolio",
+ ),
+ ),
+ ]
diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py
index 894bbe6fe..774dba897 100644
--- a/src/registrar/models/domain_information.py
+++ b/src/registrar/models/domain_information.py
@@ -296,23 +296,29 @@ class DomainInformation(TimeStampedModel):
"""Some yes/no forms use a db field to track whether it was checked or not.
We handle that here for def save().
"""
+ # Check if the firstname or lastname of cisa representative has any data.
+ # Then set the has_cisa_representative flag accordingly (so that it isn't
+ # "none", which indicates an incomplete form).
# This ensures that if we have prefilled data, the form is prepopulated
if self.cisa_representative_first_name is not None or self.cisa_representative_last_name is not None:
self.has_cisa_representative = (
self.cisa_representative_first_name != "" and self.cisa_representative_last_name != ""
)
- # This check is required to ensure that the form doesn't start out checked
+ # Check for blank data and update has_cisa_representative accordingly (if it isn't None)
if self.has_cisa_representative is not None:
self.has_cisa_representative = (
self.cisa_representative_first_name != "" and self.cisa_representative_first_name is not None
) and (self.cisa_representative_last_name != "" and self.cisa_representative_last_name is not None)
+ # Check if anything_else has any data.
+ # Then set the has_anything_else_text flag accordingly (so that it isn't
+ # "none", which indicates an incomplete form).
# This ensures that if we have prefilled data, the form is prepopulated
if self.anything_else is not None:
self.has_anything_else_text = self.anything_else != ""
- # This check is required to ensure that the form doesn't start out checked.
+ # Check for blank data and update has_anything_else_text accordingly (if it isn't None)
if self.has_anything_else_text is not None:
self.has_anything_else_text = self.anything_else != "" and self.anything_else is not None
@@ -424,3 +430,10 @@ class DomainInformation(TimeStampedModel):
def _get_many_to_many_fields():
"""Returns a set of each field.name that has the many to many relation"""
return {field.name for field in DomainInformation._meta.many_to_many} # type: ignore
+
+ def get_state_display_of_domain(self):
+ """Returns the state display of the underlying domain record"""
+ if self.domain:
+ return self.domain.get_state_display()
+ else:
+ return None
diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py
index 363de213b..966c880d7 100644
--- a/src/registrar/models/domain_request.py
+++ b/src/registrar/models/domain_request.py
@@ -645,23 +645,29 @@ class DomainRequest(TimeStampedModel):
"""Some yes/no forms use a db field to track whether it was checked or not.
We handle that here for def save().
"""
+ # Check if the firstname or lastname of cisa representative has any data.
+ # Then set the has_cisa_representative flag accordingly (so that it isn't
+ # "none", which indicates an incomplete form).
# This ensures that if we have prefilled data, the form is prepopulated
if self.cisa_representative_first_name is not None or self.cisa_representative_last_name is not None:
self.has_cisa_representative = (
self.cisa_representative_first_name != "" and self.cisa_representative_last_name != ""
)
- # This check is required to ensure that the form doesn't start out checked
+ # Check for blank data and update has_cisa_representative accordingly (if it isn't None)
if self.has_cisa_representative is not None:
self.has_cisa_representative = (
self.cisa_representative_first_name != "" and self.cisa_representative_first_name is not None
) and (self.cisa_representative_last_name != "" and self.cisa_representative_last_name is not None)
+ # Check if anything_else has any data.
+ # Then set the has_anything_else_text flag accordingly (so that it isn't
+ # "none", which indicates an incomplete form).
# This ensures that if we have prefilled data, the form is prepopulated
if self.anything_else is not None:
self.has_anything_else_text = self.anything_else != ""
- # This check is required to ensure that the form doesn't start out checked.
+ # Check for blank data and update has_anything_else_text accordingly (if it isn't None)
if self.has_anything_else_text is not None:
self.has_anything_else_text = self.anything_else != "" and self.anything_else is not None
diff --git a/src/registrar/models/portfolio.py b/src/registrar/models/portfolio.py
index 484c45e8c..0f9904c31 100644
--- a/src/registrar/models/portfolio.py
+++ b/src/registrar/models/portfolio.py
@@ -2,6 +2,7 @@ from django.db import models
from registrar.models.domain_request import DomainRequest
from registrar.models.federal_agency import FederalAgency
+from registrar.utility.constants import BranchChoices
from .utility.time_stamped_model import TimeStampedModel
@@ -12,6 +13,10 @@ class Portfolio(TimeStampedModel):
manageable groups.
"""
+ # Addresses the UnorderedObjectListWarning
+ class Meta:
+ ordering = ["organization_name"]
+
# use the short names in Django admin
OrganizationChoices = DomainRequest.OrganizationChoices
StateTerritoryChoices = DomainRequest.StateTerritoryChoices
@@ -21,11 +26,25 @@ class Portfolio(TimeStampedModel):
creator = models.ForeignKey(
"registrar.User",
on_delete=models.PROTECT,
- help_text="Associated user",
+ verbose_name="Portfolio creator",
related_name="created_portfolios",
unique=False,
)
+ organization_name = models.CharField(
+ null=True,
+ blank=True,
+ verbose_name="Portfolio organization",
+ )
+
+ organization_type = models.CharField(
+ max_length=255,
+ choices=OrganizationChoices.choices,
+ null=True,
+ blank=True,
+ help_text="Type of organization",
+ )
+
notes = models.TextField(
null=True,
blank=True,
@@ -42,25 +61,11 @@ class Portfolio(TimeStampedModel):
senior_official = models.ForeignKey(
"registrar.SeniorOfficial",
on_delete=models.PROTECT,
- help_text="Associated senior official",
unique=False,
null=True,
blank=True,
)
- organization_type = models.CharField(
- max_length=255,
- choices=OrganizationChoices.choices,
- null=True,
- blank=True,
- help_text="Type of organization",
- )
-
- organization_name = models.CharField(
- null=True,
- blank=True,
- )
-
address_line1 = models.CharField(
null=True,
blank=True,
@@ -109,7 +114,7 @@ class Portfolio(TimeStampedModel):
)
def __str__(self) -> str:
- return f"{self.organization_name}"
+ return str(self.organization_name)
def save(self, *args, **kwargs):
"""Save override for custom properties"""
@@ -119,3 +124,35 @@ class Portfolio(TimeStampedModel):
self.urbanization = None
super().save(*args, **kwargs)
+
+ @property
+ def portfolio_type(self):
+ """
+ Returns a combination of organization_type / federal_type, seperated by ' - '.
+ If no federal_type is found, we just return the org type.
+ """
+ org_type_label = self.OrganizationChoices.get_org_label(self.organization_type)
+ agency_type_label = BranchChoices.get_branch_label(self.federal_type)
+ if self.organization_type == self.OrganizationChoices.FEDERAL and agency_type_label:
+ return " - ".join([org_type_label, agency_type_label])
+ else:
+ return org_type_label
+
+ @property
+ def federal_type(self):
+ """Returns the federal_type value on the underlying federal_agency field"""
+ return self.federal_agency.federal_type if self.federal_agency else None
+
+ # == Getters for domains == #
+ def get_domains(self):
+ """Returns all DomainInformations associated with this portfolio"""
+ return self.information_portfolio.all()
+
+ def get_domain_requests(self):
+ """Returns all DomainRequests associated with this portfolio"""
+ return self.DomainRequest_portfolio.all()
+
+ # == Getters for suborganization == #
+ def get_suborganizations(self):
+ """Returns all suborganizations associated with this portfolio"""
+ return self.portfolio_suborganizations.all()
diff --git a/src/registrar/models/suborganization.py b/src/registrar/models/suborganization.py
index b1e010953..feeee0669 100644
--- a/src/registrar/models/suborganization.py
+++ b/src/registrar/models/suborganization.py
@@ -16,6 +16,7 @@ class Suborganization(TimeStampedModel):
portfolio = models.ForeignKey(
"registrar.Portfolio",
on_delete=models.PROTECT,
+ related_name="portfolio_suborganizations",
)
def __str__(self) -> str:
diff --git a/src/registrar/templates/django/admin/includes/contact_detail_list.html b/src/registrar/templates/django/admin/includes/contact_detail_list.html
index 418d1464b..7cc72e8e1 100644
--- a/src/registrar/templates/django/admin/includes/contact_detail_list.html
+++ b/src/registrar/templates/django/admin/includes/contact_detail_list.html
@@ -1,6 +1,7 @@
{% load i18n static %}
+{% load custom_filters %}
-
+
{% if show_formatted_name %}
@@ -9,10 +10,10 @@
{% else %}
None
{% endif %}
+
{% endif %}
-
- {% if user.has_contact_info %}
+ {% if user|has_contact_info %}
{# Title #}
{% if user.title %}
{{ user.title }}
@@ -42,7 +43,7 @@
No additional contact information found.
{% endif %}
- {% if user_verification_type %}
+ {% if user_verification_type and not skip_additional_contact_info %}
{{ user_verification_type }}
{% endif %}
diff --git a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
index 067b69c07..683f33117 100644
--- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
+++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
@@ -184,7 +184,9 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% include "django/admin/includes/contact_detail_list.html" with user=original_object.creator no_title_top_padding=field.is_readonly user_verification_type=original_object.creator.get_verification_type_display%}
- {% include "django/admin/includes/user_detail_list.html" with user=original_object.creator no_title_top_padding=field.is_readonly %}
+ {% if not skip_additional_contact_info %}
+ {% include "django/admin/includes/user_detail_list.html" with user=original_object.creator no_title_top_padding=field.is_readonly %}
+ {% endif%}
{% elif field.field.name == "submitter" %}
+{% block field_sets %}
+ {% for fieldset in adminform %}
+ {% comment %}
+ This is a placeholder for now.
+
+ Disclaimer:
+ When extending the fieldset view - *make a new one* that extends from detail_table_fieldset.
+ For instance, "portfolio_fieldset.html".
+ detail_table_fieldset is used on multiple admin pages, so a change there can have unintended consequences.
+ {% endcomment %}
+ {% include "django/admin/includes/detail_table_fieldset.html" with original_object=original %}
+ {% endfor %}
{% endblock %}
diff --git a/src/registrar/templates/django/admin/suborg_change_form.html b/src/registrar/templates/django/admin/suborg_change_form.html
new file mode 100644
index 000000000..005d67aec
--- /dev/null
+++ b/src/registrar/templates/django/admin/suborg_change_form.html
@@ -0,0 +1,36 @@
+{% extends 'django/admin/email_clipboard_change_form.html' %}
+{% load i18n static %}
+
+{% block after_related_objects %}
+
', domains)
+
+ @less_console_noise_decorator
+ def test_domain_requests_display(self):
+ """Tests the custom domains requests field which displays all related requests"""
+ completed_domain_request(name="request1.gov", portfolio=self.portfolio)
+ completed_domain_request(name="request2.gov", portfolio=self.portfolio)
+
+ domain_requests = self.admin.domain_requests(self.portfolio)
+ self.assertIn("request1.gov", domain_requests)
+ self.assertIn("request2.gov", domain_requests)
+ self.assertIn('
', domain_requests)
diff --git a/src/registrar/tests/test_forms.py b/src/registrar/tests/test_forms.py
index 05ce46114..a8d85597b 100644
--- a/src/registrar/tests/test_forms.py
+++ b/src/registrar/tests/test_forms.py
@@ -15,7 +15,7 @@ from registrar.forms.domain_request_wizard import (
RequirementsForm,
TribalGovernmentForm,
PurposeForm,
- AdditionalDetailsForm,
+ AnythingElseForm,
AboutYourOrganizationForm,
)
from registrar.forms.domain import ContactForm
@@ -274,7 +274,7 @@ class TestFormValidation(MockEppLib):
def test_anything_else_form_about_your_organization_character_count_invalid(self):
"""Response must be less than 2000 characters."""
- form = AdditionalDetailsForm(
+ form = AnythingElseForm(
data={
"anything_else": "Bacon ipsum dolor amet fatback strip steak pastrami"
"shankle, drumstick doner chicken landjaeger turkey andouille."
diff --git a/src/registrar/tests/test_views_request.py b/src/registrar/tests/test_views_request.py
index 0cee9d563..6642b6471 100644
--- a/src/registrar/tests/test_views_request.py
+++ b/src/registrar/tests/test_views_request.py
@@ -1017,20 +1017,27 @@ class DomainRequestTests(TestWithUser, WebTest):
type_page = intro_result.follow()
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
+ # fill out the organization type section then submit
type_form = type_page.forms[0]
type_form["generic_org_type-generic_org_type"] = "federal"
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
type_result = type_form.submit()
- # follow first redirect
+ # follow first redirect to the next section
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
federal_page = type_result.follow()
- # Now on federal type page, click back to the organization type
+ # we need to fill out the federal section so it stays unlocked
+ fed_branch_form = federal_page.forms[0]
+ fed_branch_form["organization_federal-federal_type"] = "executive"
+ self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+ fed_branch_form.submit()
+
+ # Now click back to the organization type
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
new_page = federal_page.click(str(self.TITLES["generic_org_type"]), index=0)
- # Should be a link to the organization_federal page
+ # Should be a link to the organization_federal page since it is now unlocked
self.assertGreater(
len(new_page.html.find_all("a", href="/request/organization_federal/")),
0,
@@ -2528,9 +2535,22 @@ class DomainRequestTests(TestWithUser, WebTest):
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
election_page = type_result.follow()
- # Go back to SO page and test the dynamic text changed
+ # Navigate to the org page as that is the step right before senior_official
+ org_page = election_page.click(str(self.TITLES["organization_contact"]), index=0)
+ org_contact_form = org_page.forms[0]
+ org_contact_form["organization_contact-organization_name"] = "Testorg"
+ org_contact_form["organization_contact-address_line1"] = "address 1"
+ org_contact_form["organization_contact-address_line2"] = "address 2"
+ org_contact_form["organization_contact-city"] = "NYC"
+ org_contact_form["organization_contact-state_territory"] = "NY"
+ org_contact_form["organization_contact-zipcode"] = "10002"
+ org_contact_form["organization_contact-urbanization"] = "URB Royal Oaks"
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
- so_page = election_page.click(str(self.TITLES["senior_official"]), index=0)
+ org_contact_result = org_contact_form.submit()
+
+ # Navigate back to the so page
+ self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+ so_page = org_contact_result.follow()
self.assertContains(so_page, "Domain requests from cities")
@less_console_noise_decorator
@@ -2628,9 +2648,15 @@ class DomainRequestTests(TestWithUser, WebTest):
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
election_page = type_result.follow()
+ self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+ current_websites = election_page.click(str(self.TITLES["current_sites"]), index=0)
+ current_sites_form = current_websites.forms[0]
+ current_sites_form["current_sites-0-website"] = "www.city.com"
+ current_sites_result = current_sites_form.submit().follow()
+
# Go back to dotgov domain page to test the dynamic text changed
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
- dotgov_page = election_page.click(str(self.TITLES["dotgov_domain"]), index=0)
+ dotgov_page = current_sites_result.click(str(self.TITLES["dotgov_domain"]), index=0)
self.assertContains(dotgov_page, "CityofEudoraKS.gov")
self.assertNotContains(dotgov_page, "medicare.gov")
@@ -2984,6 +3010,9 @@ class TestWizardUnlockingSteps(TestWithUser, WebTest):
"""Test when all fields in the domain request are filled."""
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.STARTED, user=self.user)
+ domain_request.anything_else = False
+ domain_request.has_anything_else_text = False
+ domain_request.save()
response = self.app.get(f"/domain-request/{domain_request.id}/edit/")
# django-webtest does not handle cookie-based sessions well because it keeps
diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py
index 08e23e402..b691549cd 100644
--- a/src/registrar/views/domain_request.py
+++ b/src/registrar/views/domain_request.py
@@ -217,7 +217,6 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
if current_url == self.EDIT_URL_NAME and "id" in kwargs:
del self.storage
self.storage["domain_request_id"] = kwargs["id"]
- self.storage["step_history"] = self.db_check_for_unlocking_steps()
# if accessing this class directly, redirect to either to an acknowledgement
# page or to the first step in the processes (if an edit rather than a new request);
@@ -233,6 +232,9 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
else:
return self.goto(self.steps.first)
+ # refresh step_history to ensure we don't erroneously unlock unfinished
+ # steps just because we visited it
+ self.storage["step_history"] = self.db_check_for_unlocking_steps()
context = self.get_context_data()
self.steps.current = current_url
context["forms"] = self.get_forms()
@@ -341,6 +343,17 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
"""Helper for get_context_data
Queries the DB for a domain request and returns a list of unlocked steps."""
+
+ # The way this works is as follows:
+ # Each step is assigned a true/false value to determine if it is
+ # "unlocked" or not. This dictionary of values is looped through
+ # at the end of this function and any step with a "true" value is
+ # added to a simple array that is returned at the end of this function.
+ # This array is eventually passed to the frontend context (eg. domain_request_sidebar.html),
+ # and is used to determine how steps appear in the side nav.
+ # It is worth noting that any step assigned "false" here will be EXCLUDED
+ # from the list of "unlocked" steps.
+
history_dict = {
"generic_org_type": self.domain_request.generic_org_type is not None,
"tribal_government": self.domain_request.tribe_name is not None,
@@ -368,8 +381,11 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
or self.domain_request.no_other_contacts_rationale is not None
),
"additional_details": (
- (self.domain_request.anything_else is not None and self.domain_request.has_cisa_representative)
- or self.domain_request.is_policy_acknowledged is not None
+ # Additional details is complete as long as "has anything else" and "has cisa rep" are not None
+ (
+ self.domain_request.has_anything_else_text is not None
+ and self.domain_request.has_cisa_representative is not None
+ )
),
"requirements": self.domain_request.is_policy_acknowledged is not None,
"review": self.domain_request.is_policy_acknowledged is not None,
@@ -626,8 +642,8 @@ class AdditionalDetails(DomainRequestWizard):
forms = [
forms.CisaRepresentativeYesNoForm,
forms.CisaRepresentativeForm,
- forms.AdditionalDetailsYesNoForm,
- forms.AdditionalDetailsForm,
+ forms.AnythingElseYesNoForm,
+ forms.AnythingElseForm,
]
def is_valid(self, forms: list) -> bool: