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'') 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" %}
    diff --git a/src/registrar/templates/django/admin/portfolio_change_form.html b/src/registrar/templates/django/admin/portfolio_change_form.html index f6382758b..9d59aae42 100644 --- a/src/registrar/templates/django/admin/portfolio_change_form.html +++ b/src/registrar/templates/django/admin/portfolio_change_form.html @@ -8,34 +8,16 @@ {{ block.super }} {% endblock content %} -{% block after_related_objects %} -
    -

    Associated groups and suborganizations

    -
    -
    -

    Domain groups

    - -
    -
    -

    Suborganizations

    - -
    -
    -
    +{% 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 %} +
    +

    Associated requests and domains

    +
    +
    +

    Domain requests

    + +
    +
    +

    Domains

    + +
    +
    +
    +{% endblock %} diff --git a/src/registrar/templatetags/custom_filters.py b/src/registrar/templatetags/custom_filters.py index e90b3b17f..728478a51 100644 --- a/src/registrar/templatetags/custom_filters.py +++ b/src/registrar/templatetags/custom_filters.py @@ -159,3 +159,13 @@ def and_filter(value, arg): Usage: {{ value|and:arg }} """ return bool(value and arg) + + +@register.filter(name="has_contact_info") +def has_contact_info(user): + """Checks if the given object has the attributes: title, email, phone + and checks if at least one of those is not null.""" + if not hasattr(user, "title") or not hasattr(user, "email") or not hasattr(user, "phone"): + return False + else: + return bool(user.title or user.email or user.phone) diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index a4c3e2ef4..ceb3b6e92 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -906,6 +906,7 @@ def completed_domain_request( # noqa federal_agency=None, federal_type=None, action_needed_reason=None, + portfolio=None, ): """A completed domain request.""" if not user: @@ -976,6 +977,9 @@ def completed_domain_request( # noqa if action_needed_reason: domain_request_kwargs["action_needed_reason"] = action_needed_reason + if portfolio: + domain_request_kwargs["portfolio"] = portfolio + domain_request, _ = DomainRequest.objects.get_or_create(**domain_request_kwargs) if has_other_contacts: diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 4ec3336ba..827742ef1 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -23,6 +23,7 @@ from registrar.admin import ( PublicContactAdmin, TransitionDomainAdmin, UserGroupAdmin, + PortfolioAdmin, ) from registrar.models import ( Domain, @@ -38,6 +39,8 @@ from registrar.models import ( FederalAgency, UserGroup, TransitionDomain, + Portfolio, + Suborganization, ) from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.senior_official import SeniorOfficial @@ -2042,3 +2045,79 @@ class TestUserGroup(TestCase): response, "Groups are a way to bundle admin permissions so they can be easily assigned to multiple users." ) self.assertContains(response, "Show more") + + +class TestPortfolioAdmin(TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.site = AdminSite() + cls.superuser = create_superuser() + cls.admin = PortfolioAdmin(model=Portfolio, admin_site=cls.site) + cls.factory = RequestFactory() + + def setUp(self): + self.client = Client(HTTP_HOST="localhost:8080") + self.portfolio = Portfolio.objects.create(organization_name="Test Portfolio", creator=self.superuser) + + def tearDown(self): + Suborganization.objects.all().delete() + DomainInformation.objects.all().delete() + DomainRequest.objects.all().delete() + Domain.objects.all().delete() + Portfolio.objects.all().delete() + + @less_console_noise_decorator + def test_created_on_display(self): + """Tests the custom created on which is a reskin of the created_at field""" + created_on = self.admin.created_on(self.portfolio) + expected_date = self.portfolio.created_at.strftime("%b %d, %Y") + self.assertEqual(created_on, expected_date) + + @less_console_noise_decorator + def test_suborganizations_display(self): + """Tests the custom suborg field which displays all related suborgs""" + Suborganization.objects.create(name="Sub1", portfolio=self.portfolio) + Suborganization.objects.create(name="Sub2", portfolio=self.portfolio) + + suborganizations = self.admin.suborganizations(self.portfolio) + self.assertIn("Sub1", suborganizations) + self.assertIn("Sub2", suborganizations) + self.assertIn('