From 55833f04689ed335aea0c89aa4cc003620e76d21 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Fri, 2 Aug 2024 12:30:04 -0600 Subject: [PATCH 01/47] Updated step_history check to ensure side nav refreshes with correct settings --- src/registrar/forms/domain_request_wizard.py | 4 +-- src/registrar/tests/test_forms.py | 4 +-- src/registrar/views/domain_request.py | 29 ++++++++++++++------ 3 files changed, 25 insertions(+), 12 deletions(-) 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/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/views/domain_request.py b/src/registrar/views/domain_request.py index 08e23e402..7aa82d80e 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -217,8 +217,11 @@ 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() - + + # 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() + # 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); # subclasseswill NOT be redirected. The purpose of this is to allow code to @@ -341,10 +344,21 @@ 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, - "organization_federal": self.domain_request.federal_type is not None, + "organization_federal": True, "organization_election": self.domain_request.is_election_board is not None, "organization_contact": ( self.domain_request.federal_agency is not None @@ -355,7 +369,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView): or self.domain_request.zipcode is not None or self.domain_request.urbanization is not None ), - "about_your_organization": self.domain_request.about_your_organization is not None, + "about_your_organization": True, "senior_official": self.domain_request.senior_official is not None, "current_sites": ( self.domain_request.current_websites.exists() or self.domain_request.requested_domain is not None @@ -368,8 +382,7 @@ 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 + (self.domain_request.has_anything_else_text and self.domain_request.has_cisa_representative) ), "requirements": self.domain_request.is_policy_acknowledged is not None, "review": self.domain_request.is_policy_acknowledged is not None, @@ -626,8 +639,8 @@ class AdditionalDetails(DomainRequestWizard): forms = [ forms.CisaRepresentativeYesNoForm, forms.CisaRepresentativeForm, - forms.AdditionalDetailsYesNoForm, - forms.AdditionalDetailsForm, + forms.AnythingElseYesNoForm, + forms.AnythingElseForm, ] def is_valid(self, forms: list) -> bool: From 2a5b31fa42d67281266650bbdae2e095b5a03d2d Mon Sep 17 00:00:00 2001 From: CocoByte Date: Mon, 5 Aug 2024 12:07:19 -0600 Subject: [PATCH 02/47] Restore step logic for organization_federal --- src/registrar/views/domain_request.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py index 7aa82d80e..7352460ce 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -358,7 +358,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView): history_dict = { "generic_org_type": self.domain_request.generic_org_type is not None, "tribal_government": self.domain_request.tribe_name is not None, - "organization_federal": True, + "organization_federal": self.domain_request.federal_type is not None, "organization_election": self.domain_request.is_election_board is not None, "organization_contact": ( self.domain_request.federal_agency is not None From adb7a6ca19247d89a844a49aeacf05756529a7ff Mon Sep 17 00:00:00 2001 From: CocoByte Date: Mon, 12 Aug 2024 17:11:38 -0600 Subject: [PATCH 03/47] Fixed logic for Additional Details section (bonus bug fix). Also linted --- src/registrar/models/domain_information.py | 10 ++++++++-- src/registrar/models/domain_request.py | 10 ++++++++-- src/registrar/views/domain_request.py | 16 ++++++++++------ 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py index 894bbe6fe..92cd4d0f1 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 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/views/domain_request.py b/src/registrar/views/domain_request.py index 7352460ce..c8f81dcaa 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -217,11 +217,11 @@ 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"] - + # 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() - + # 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); # subclasseswill NOT be redirected. The purpose of this is to allow code to @@ -348,12 +348,12 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView): # 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 + # 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. + # from the list of "unlocked" steps. history_dict = { "generic_org_type": self.domain_request.generic_org_type is not None, @@ -369,7 +369,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView): or self.domain_request.zipcode is not None or self.domain_request.urbanization is not None ), - "about_your_organization": True, + "about_your_organization": self.domain_request.about_your_organization is not None, "senior_official": self.domain_request.senior_official is not None, "current_sites": ( self.domain_request.current_websites.exists() or self.domain_request.requested_domain is not None @@ -382,7 +382,11 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView): or self.domain_request.no_other_contacts_rationale is not None ), "additional_details": ( - (self.domain_request.has_anything_else_text and self.domain_request.has_cisa_representative) + # 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, From dc3509c7d65f73eb4148788fe2dac7c410387ae5 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Mon, 12 Aug 2024 22:19:16 -0600 Subject: [PATCH 04/47] Fixing tests --- src/registrar/tests/test_views_request.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/registrar/tests/test_views_request.py b/src/registrar/tests/test_views_request.py index 0cee9d563..f2c28b772 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, From f1f1ea34efd130176b899c53dacea70dc6d02aba Mon Sep 17 00:00:00 2001 From: Rebecca Hsieh Date: Tue, 13 Aug 2024 11:19:01 -0700 Subject: [PATCH 05/47] Attempt at adding ANDI in via Middleware --- src/registrar/config/settings.py | 1 + src/registrar/registrar_middleware.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index 9d707a533..4b9d5c7fe 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -190,6 +190,7 @@ MIDDLEWARE = [ "waffle.middleware.WaffleMiddleware", "registrar.registrar_middleware.CheckUserProfileMiddleware", "registrar.registrar_middleware.CheckPortfolioMiddleware", + "registrar.registrar_middleware.ANDIMiddleware", ] # application object used by Django’s built-in servers (e.g. `runserver`) diff --git a/src/registrar/registrar_middleware.py b/src/registrar/registrar_middleware.py index 2af331bc9..2fcaa78b4 100644 --- a/src/registrar/registrar_middleware.py +++ b/src/registrar/registrar_middleware.py @@ -8,6 +8,7 @@ from django.urls import reverse from django.http import HttpResponseRedirect from registrar.models.user import User from waffle.decorators import flag_is_active +from django.utils.deprecation import MiddlewareMixin from registrar.models.utility.generic_helper import replace_url_queryparams @@ -157,3 +158,17 @@ class CheckPortfolioMiddleware: return HttpResponseRedirect(portfolio_redirect) return None + + +class ANDIMiddleware(MiddlewareMixin): + def process_response(self, request, response): + # Check if the response content type is HTML + if "text/html" in response.get("Content-Type", ""): + andi_script = """ + + """ + # Inject the ANDI script before the closing tag + content = response.content.decode("utf-8") + content = content.replace("", f"{andi_script}") + response.content = content.encode("utf-8") + return response From 4243d1929373331b6f222eb2bc466c88dbf3f30c Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 13 Aug 2024 12:29:22 -0600 Subject: [PATCH 06/47] stub --- src/registrar/admin.py | 79 ++++++++++++++++++- .../0118_portfolio_federal_type_and_more.py | 44 +++++++++++ src/registrar/models/portfolio.py | 52 ++++++++++++ src/registrar/models/suborganization.py | 1 + src/registrar/models/user.py | 2 +- 5 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 src/registrar/migrations/0118_portfolio_federal_type_and_more.py diff --git a/src/registrar/admin.py b/src/registrar/admin.py index ca4038d51..56196e1b7 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -6,10 +6,12 @@ from django.template.loader import get_template from django import forms from django.db.models import Value, CharField, Q from django.db.models.functions import Concat, Coalesce +from django.contrib.admin.widgets import RelatedFieldWidgetWrapper 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.portfolio import Portfolio from registrar.models.suborganization import Suborganization from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from waffle.decorators import flag_is_active @@ -19,7 +21,7 @@ from django.contrib.auth.models import Group from django.contrib.contenttypes.models import ContentType from django.urls import reverse from epplibwrapper.errors import ErrorCode, RegistryError -from registrar.models.user_domain_role import UserDomainRole +from registrar.models import UserDomainRole, DomainInformation from waffle.admin import FlagAdmin from waffle.models import Sample, Switch from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial @@ -39,7 +41,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__) @@ -2833,23 +2835,94 @@ class VerifiedByStaffAdmin(ListHeaderAdmin): super().save_model(request, obj, form, change) + class PortfolioAdmin(ListHeaderAdmin): + class SuborganizationInline(admin.StackedInline): + """""" + model = models.Suborganization change_form_template = "django/admin/portfolio_change_form.html" + #inlines = [SuborganizationInline, UserInline] + fieldsets = [ + # TODO - this will need to be reworked + #(None, {"fields": ["organization_name", "federal_agency", "creator", "created_at", "notes"]}), + (None, {"fields": ["organization_name", "creator", "created_at", "notes"]}), + ("Portfolio members", {"fields": ["administrators", "members"]}), + ("Portfolio domains", {"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"]}), + ("Other", {"fields": ["security_contact_email"]}), + ] + # NOTE: use add_fieldsets to modify that page list_display = ("organization_name", "federal_agency", "creator") search_fields = ["organization_name"] search_help_text = "Search by organization name." readonly_fields = [ - "creator", + "created_at", + + # Custom fields such as these must be defined as readonly, even if they are not. + "administrators", + "members", + "domains", + "domain_requests", + "suborganizations" ] + def suborganizations(self, obj: models.Portfolio): + queryset = obj.get_suborganizations() + return self.get_links_csv(queryset, "suborganization") + suborganizations.short_description = "Suborganizations" + + def domains(self, obj: models.Portfolio): + queryset = obj.get_domains() + return self.get_links_csv(queryset, "domaininformation") + domains.short_description = "Domains" + + def domain_requests(self, obj: models.Portfolio): + queryset = obj.get_domain_requests() + return self.get_links_csv(queryset, "domainrequest") + domain_requests.short_description = "Domain requests" + + def administrators(self, obj: models.Portfolio): + queryset = obj.get_administrators() + return self.get_links_csv(queryset, "user", "get_full_name") + administrators.short_description = "Administrators" + + def members(self, obj: models.Portfolio): + queryset = obj.get_members() + return self.get_links_csv(queryset, "user", "get_full_name") + members.short_description = "Members" + # Creates select2 fields (with search bars) autocomplete_fields = [ "creator", "federal_agency", ] + # TODO change these names + def get_links_csv(self, queryset, model_name, link_text_attribute=None): + links = [] + for item in queryset: + if link_text_attribute: + item_display_value = getattr(item, link_text_attribute) + if callable(item_display_value): + item_display_value = item_display_value() + else: + item_display_value = item + + if item_display_value: + link = self.get_html_change_link(model_name=model_name, object_id=item.pk, text_content=item_display_value) + links.append(link) + return format_html(", ".join(links)) + + def get_html_change_link(self, model_name, object_id, text_content): + change_url = reverse(f"admin:registrar_{model_name}_change", args=[object_id]) + return f'{escape(text_content)}' + + 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) diff --git a/src/registrar/migrations/0118_portfolio_federal_type_and_more.py b/src/registrar/migrations/0118_portfolio_federal_type_and_more.py new file mode 100644 index 000000000..2cbcc8f99 --- /dev/null +++ b/src/registrar/migrations/0118_portfolio_federal_type_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 4.2.10 on 2024-08-13 16:31 + +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.AddField( + model_name="portfolio", + name="federal_type", + field=models.CharField( + blank=True, + choices=[("executive", "Executive"), ("judicial", "Judicial"), ("legislative", "Legislative")], + max_length=50, + null=True, + ), + ), + migrations.AlterField( + model_name="suborganization", + name="portfolio", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="portfolio_suborganizations", + to="registrar.portfolio", + ), + ), + migrations.AlterField( + model_name="user", + name="portfolio", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="portfolio_users", + to="registrar.portfolio", + ), + ), + ] diff --git a/src/registrar/models/portfolio.py b/src/registrar/models/portfolio.py index 06b01e672..5b4ef7c6b 100644 --- a/src/registrar/models/portfolio.py +++ b/src/registrar/models/portfolio.py @@ -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.utility.portfolio_helper import UserPortfolioRoleChoices +from registrar.utility.constants import BranchChoices from .utility.time_stamped_model import TimeStampedModel @@ -39,6 +41,13 @@ class Portfolio(TimeStampedModel): default=FederalAgency.get_non_federal_agency, ) + federal_type = models.CharField( + max_length=50, + choices=BranchChoices.choices, + null=True, + blank=True, + ) + senior_official = models.ForeignKey( "registrar.SeniorOfficial", on_delete=models.PROTECT, @@ -110,3 +119,46 @@ class Portfolio(TimeStampedModel): def __str__(self) -> str: return f"{self.organization_name}" + + # == 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() + + # == Getters for users == # + def get_users(self): + """Returns all users associated with this portfolio""" + return self.portfolio_users.all() + + def get_administrators(self): + """Returns all administrators associated with this portfolio""" + return self.portfolio_users.filter( + portfolio_roles__overlap=[ + UserPortfolioRoleChoices.ORGANIZATION_ADMIN, + ] + ) + + def get_readonly_administrators(self): + """Returns all readonly_administrators associated with this portfolio""" + return self.portfolio_users.filter( + portfolio_roles__overlap=[ + UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY, + ] + ) + + def get_members(self): + """Returns all members associated with this portfolio""" + return self.portfolio_users.filter( + portfolio_roles__overlap=[ + UserPortfolioRoleChoices.ORGANIZATION_MEMBER, + ] + ) 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/models/user.py b/src/registrar/models/user.py index 0221c2d50..0f75c0499 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -115,7 +115,7 @@ class User(AbstractUser): "registrar.Portfolio", null=True, blank=True, - related_name="user", + related_name="portfolio_users", on_delete=models.SET_NULL, ) From ebbd329c986770b498b0a62ed685e39db8351e8e Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Tue, 13 Aug 2024 11:40:45 -0700 Subject: [PATCH 07/47] Update ANDI middleware class --- src/registrar/registrar_middleware.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/registrar/registrar_middleware.py b/src/registrar/registrar_middleware.py index 2fcaa78b4..fe3c2260d 100644 --- a/src/registrar/registrar_middleware.py +++ b/src/registrar/registrar_middleware.py @@ -161,8 +161,15 @@ class CheckPortfolioMiddleware: class ANDIMiddleware(MiddlewareMixin): - def process_response(self, request, response): - # Check if the response content type is HTML + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + return response + + def process_view(self, request, view_func, view_args, view_kwargs): + response = self.get_response(request) if "text/html" in response.get("Content-Type", ""): andi_script = """ @@ -171,4 +178,4 @@ class ANDIMiddleware(MiddlewareMixin): content = response.content.decode("utf-8") content = content.replace("", f"{andi_script}") response.content = content.encode("utf-8") - return response + return None From 5cd5cd645a962ad66d7c969d79679535f7f78d17 Mon Sep 17 00:00:00 2001 From: Rebecca Hsieh Date: Tue, 13 Aug 2024 12:04:25 -0700 Subject: [PATCH 08/47] Allowing ANDI images --- src/registrar/config/settings.py | 17 ++++++++++++++--- src/registrar/registrar_middleware.py | 2 +- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index 4b9d5c7fe..861ce3a94 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -357,9 +357,20 @@ 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", "'unsafe-inline'"] +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"] +CSP_INCLUDE_NONCE_IN = ["script-src-elem", "style-src"] +CSP_IMG_SRC = ["'self'", "https://www.ssa.gov"] # Cross-Origin Resource Sharing (CORS) configuration # Sets clients that allow access control to manage.get.gov diff --git a/src/registrar/registrar_middleware.py b/src/registrar/registrar_middleware.py index fe3c2260d..a772fbe0a 100644 --- a/src/registrar/registrar_middleware.py +++ b/src/registrar/registrar_middleware.py @@ -168,7 +168,7 @@ class ANDIMiddleware(MiddlewareMixin): response = self.get_response(request) return response - def process_view(self, request, view_func, view_args, view_kwargs): + def process_template_view(self, request, view_func, view_args, view_kwargs): response = self.get_response(request) if "text/html" in response.get("Content-Type", ""): andi_script = """ From ceabc16c7394a6296c8280f4317030a11b41910c Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 13 Aug 2024 13:27:31 -0600 Subject: [PATCH 09/47] Stub pt 2 --- src/registrar/admin.py | 51 ++++++++++--------- .../admin/includes/portfolio_fieldset.html | 13 +++++ .../django/admin/portfolio_change_form.html | 43 +++++----------- 3 files changed, 52 insertions(+), 55 deletions(-) create mode 100644 src/registrar/templates/django/admin/includes/portfolio_fieldset.html diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 56196e1b7..28014e0f1 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -2846,7 +2846,7 @@ class PortfolioAdmin(ListHeaderAdmin): fieldsets = [ # TODO - this will need to be reworked #(None, {"fields": ["organization_name", "federal_agency", "creator", "created_at", "notes"]}), - (None, {"fields": ["organization_name", "creator", "created_at", "notes"]}), + (None, {"fields": ["portfolio_type", "organization_name", "creator", "created_at", "notes"]}), ("Portfolio members", {"fields": ["administrators", "members"]}), ("Portfolio domains", {"fields": ["domains", "domain_requests"]}), ("Type of organization", {"fields": ["organization_type", "federal_type"]}), @@ -2862,28 +2862,41 @@ class PortfolioAdmin(ListHeaderAdmin): search_help_text = "Search by organization name." readonly_fields = [ "created_at", - - # Custom fields such as these must be defined as readonly, even if they are not. + # Custom fields such as these must be defined as readonly. "administrators", "members", "domains", "domain_requests", - "suborganizations" + "suborganizations", + "federal_type", + "portfolio_type", ] + def portfolio_type(self, obj: models.Portfolio): + org_choices = DomainRequest.OrganizationChoices + org_type = org_choices.get_org_label(obj.organization_type) + if obj.organization_type == org_choices.FEDERAL and obj.federal_agency: + return " - ".join([org_type, obj.federal_agency.agency]) + else: + return org_type + portfolio_type.short_description = "Portfolio type" + def suborganizations(self, obj: models.Portfolio): queryset = obj.get_suborganizations() - return self.get_links_csv(queryset, "suborganization") + sep = '
' + return self.get_links_csv(queryset, "suborganization", seperator=sep) suborganizations.short_description = "Suborganizations" def domains(self, obj: models.Portfolio): queryset = obj.get_domains() - return self.get_links_csv(queryset, "domaininformation") + sep = '
' + return self.get_links_csv(queryset, "domaininformation", seperator=sep) domains.short_description = "Domains" def domain_requests(self, obj: models.Portfolio): queryset = obj.get_domain_requests() - return self.get_links_csv(queryset, "domainrequest") + sep = '
' + return self.get_links_csv(queryset, "domainrequest", seperator=sep) domain_requests.short_description = "Domain requests" def administrators(self, obj: models.Portfolio): @@ -2902,8 +2915,8 @@ class PortfolioAdmin(ListHeaderAdmin): "federal_agency", ] - # TODO change these names - def get_links_csv(self, queryset, model_name, link_text_attribute=None): + # Q for reviewers: What should this be called? + def get_links_csv(self, queryset, model_name, link_text_attribute=None, seperator=", "): links = [] for item in queryset: if link_text_attribute: @@ -2914,26 +2927,14 @@ class PortfolioAdmin(ListHeaderAdmin): item_display_value = item if item_display_value: - link = self.get_html_change_link(model_name=model_name, object_id=item.pk, text_content=item_display_value) - links.append(link) - return format_html(", ".join(links)) - - def get_html_change_link(self, model_name, object_id, text_content): - change_url = reverse(f"admin:registrar_{model_name}_change", args=[object_id]) - return f'{escape(text_content)}' - + change_url = reverse(f"admin:registrar_{model_name}_change", args=[item.pk]) + links.append(f'{escape(item_display_value)}') + return format_html(seperator.join(links)) 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 = {"administrators": obj.get_administrators(), "members": obj.get_members()} return super().change_view(request, object_id, form_url, extra_context) def save_model(self, request, obj, form, change): diff --git a/src/registrar/templates/django/admin/includes/portfolio_fieldset.html b/src/registrar/templates/django/admin/includes/portfolio_fieldset.html new file mode 100644 index 000000000..4a0df2c34 --- /dev/null +++ b/src/registrar/templates/django/admin/includes/portfolio_fieldset.html @@ -0,0 +1,13 @@ +{% extends "django/admin/includes/email_clipboard_fieldset.html" %} +{% load custom_filters %} +{% load static url_helpers %} + +{% block field_readonly %} + {% if field.field.name == "members" %} + {% comment %} Do nothing - for now {% endcomment %} +
{{ field.contents }}
+ {% else %} +
{{ field.contents }}
+ {% endif %} +{% endblock field_readonly %} + diff --git a/src/registrar/templates/django/admin/portfolio_change_form.html b/src/registrar/templates/django/admin/portfolio_change_form.html index 3a6f13ccf..3257ee6a4 100644 --- a/src/registrar/templates/django/admin/portfolio_change_form.html +++ b/src/registrar/templates/django/admin/portfolio_change_form.html @@ -1,34 +1,17 @@ {% extends 'django/admin/email_clipboard_change_form.html' %} {% load i18n static %} -{% block after_related_objects %} -
-

Associated groups and suborganizations

-
-
-

Domain groups

- -
-
-

Suborganizations

- -
-
-
+{% block field_sets %} + {% for fieldset in adminform %} + {% comment %} + TODO: this will eventually need to be changed to something like this + if we ever want to customize this file: + {% include "django/admin/includes/domain_information_fieldset.html" %} + + Use detail_table_fieldset as an example, or just extend it. + + original_object is just a variable name for "DomainInformation" or "DomainRequest" + {% endcomment %} + {% include "django/admin/includes/portfolio_fieldset.html" with original_object=original %} + {% endfor %} {% endblock %} From 9733fde689d58a8e299562b70c7a3f33cd5f6935 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 13 Aug 2024 14:01:20 -0600 Subject: [PATCH 10/47] Cleanup --- src/registrar/admin.py | 52 ++++++++++++++----- ..._type_alter_portfolio_creator_and_more.py} | 25 ++++++++- src/registrar/models/portfolio.py | 33 ++++++------ src/registrar/models/senior_official.py | 3 ++ .../admin/includes/contact_detail_list.html | 2 +- 5 files changed, 83 insertions(+), 32 deletions(-) rename src/registrar/migrations/{0118_portfolio_federal_type_and_more.py => 0118_portfolio_federal_type_alter_portfolio_creator_and_more.py} (58%) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 28014e0f1..abaa5ff54 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -2835,25 +2835,35 @@ class VerifiedByStaffAdmin(ListHeaderAdmin): super().save_model(request, obj, form, change) - class PortfolioAdmin(ListHeaderAdmin): - class SuborganizationInline(admin.StackedInline): - """""" - model = models.Suborganization - change_form_template = "django/admin/portfolio_change_form.html" - #inlines = [SuborganizationInline, UserInline] fieldsets = [ - # TODO - this will need to be reworked - #(None, {"fields": ["organization_name", "federal_agency", "creator", "created_at", "notes"]}), (None, {"fields": ["portfolio_type", "organization_name", "creator", "created_at", "notes"]}), - ("Portfolio members", {"fields": ["administrators", "members"]}), - ("Portfolio domains", {"fields": ["domains", "domain_requests"]}), + ("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"]}), + ( + "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"]}), - ("Other", {"fields": ["security_contact_email"]}), ] # NOTE: use add_fieldsets to modify that page @@ -2863,7 +2873,7 @@ class PortfolioAdmin(ListHeaderAdmin): readonly_fields = [ "created_at", # Custom fields such as these must be defined as readonly. - "administrators", + "administrators", "members", "domains", "domain_requests", @@ -2872,41 +2882,55 @@ class PortfolioAdmin(ListHeaderAdmin): "portfolio_type", ] + # TODO - this returns None when empty rather than - for some reason def portfolio_type(self, obj: models.Portfolio): + """Returns a concat of organization type and federal agency, + seperated by a dash (-)""" org_choices = DomainRequest.OrganizationChoices org_type = org_choices.get_org_label(obj.organization_type) if obj.organization_type == org_choices.FEDERAL and obj.federal_agency: return " - ".join([org_type, obj.federal_agency.agency]) else: return org_type + portfolio_type.short_description = "Portfolio type" def suborganizations(self, obj: models.Portfolio): + """Returns a comma seperated list of links for each related suborg""" queryset = obj.get_suborganizations() sep = '
' return self.get_links_csv(queryset, "suborganization", seperator=sep) + suborganizations.short_description = "Suborganizations" def domains(self, obj: models.Portfolio): + """Returns a comma seperated list of links for each related domain""" queryset = obj.get_domains() sep = '
' return self.get_links_csv(queryset, "domaininformation", seperator=sep) + domains.short_description = "Domains" - + def domain_requests(self, obj: models.Portfolio): + """Returns a comma seperated list of links for each related domain request""" queryset = obj.get_domain_requests() sep = '
' return self.get_links_csv(queryset, "domainrequest", seperator=sep) + domain_requests.short_description = "Domain requests" def administrators(self, obj: models.Portfolio): + """Returns a comma seperated list of links for each related administrator""" queryset = obj.get_administrators() return self.get_links_csv(queryset, "user", "get_full_name") + administrators.short_description = "Administrators" def members(self, obj: models.Portfolio): + """Returns a comma seperated list of links for each related member""" queryset = obj.get_members() return self.get_links_csv(queryset, "user", "get_full_name") + members.short_description = "Members" # Creates select2 fields (with search bars) diff --git a/src/registrar/migrations/0118_portfolio_federal_type_and_more.py b/src/registrar/migrations/0118_portfolio_federal_type_alter_portfolio_creator_and_more.py similarity index 58% rename from src/registrar/migrations/0118_portfolio_federal_type_and_more.py rename to src/registrar/migrations/0118_portfolio_federal_type_alter_portfolio_creator_and_more.py index 2cbcc8f99..338107ece 100644 --- a/src/registrar/migrations/0118_portfolio_federal_type_and_more.py +++ b/src/registrar/migrations/0118_portfolio_federal_type_alter_portfolio_creator_and_more.py @@ -1,5 +1,6 @@ -# Generated by Django 4.2.10 on 2024-08-13 16:31 +# Generated by Django 4.2.10 on 2024-08-13 20:01 +from django.conf import settings from django.db import migrations, models import django.db.models.deletion @@ -21,6 +22,28 @@ class Migration(migrations.Migration): null=True, ), ), + 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", diff --git a/src/registrar/models/portfolio.py b/src/registrar/models/portfolio.py index 5b4ef7c6b..7e5ee980d 100644 --- a/src/registrar/models/portfolio.py +++ b/src/registrar/models/portfolio.py @@ -23,11 +23,26 @@ 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, ) + # Q for reviewers: shouldn't this be a required field? + 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, @@ -51,25 +66,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, @@ -118,7 +119,7 @@ class Portfolio(TimeStampedModel): ) def __str__(self) -> str: - return f"{self.organization_name}" + return str(self.organization_name) # == Getters for domains == # def get_domains(self): diff --git a/src/registrar/models/senior_official.py b/src/registrar/models/senior_official.py index 38ce4f35d..62632feee 100644 --- a/src/registrar/models/senior_official.py +++ b/src/registrar/models/senior_official.py @@ -54,6 +54,9 @@ class SeniorOfficial(TimeStampedModel): names = [n for n in [self.first_name, self.last_name] if n] return " ".join(names) if names else "Unknown" + def has_contact_info(self): + return bool(self.title or self.email or self.phone) + def __str__(self): if self.first_name or self.last_name: return self.get_formatted_name() 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..397e2e1a0 100644 --- a/src/registrar/templates/django/admin/includes/contact_detail_list.html +++ b/src/registrar/templates/django/admin/includes/contact_detail_list.html @@ -9,8 +9,8 @@ {% else %} None {% endif %} +
{% endif %} -
{% if user.has_contact_info %} {# Title #} From 5f0b342986ad918c512fd8a863e6283b161d5d0c Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 13 Aug 2024 14:10:50 -0600 Subject: [PATCH 11/47] Further cleanup --- src/registrar/admin.py | 21 +++++++-------------- src/registrar/models/portfolio.py | 8 ++++++++ 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index abaa5ff54..afe23c86d 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -2839,10 +2839,11 @@ class PortfolioAdmin(ListHeaderAdmin): change_form_template = "django/admin/portfolio_change_form.html" fieldsets = [ (None, {"fields": ["portfolio_type", "organization_name", "creator", "created_at", "notes"]}), - ("Portfolio members", { - "classes": ("collapse", "closed"), - "fields": ["administrators", "members"]} - ), + # TODO - uncomment in #2521 + # ("Portfolio members", { + # "classes": ("collapse", "closed"), + # "fields": ["administrators", "members"]} + # ), ("Portfolio domains", { "classes": ("collapse", "closed"), "fields": ["domains", "domain_requests"]} @@ -2882,16 +2883,8 @@ class PortfolioAdmin(ListHeaderAdmin): "portfolio_type", ] - # TODO - this returns None when empty rather than - for some reason def portfolio_type(self, obj: models.Portfolio): - """Returns a concat of organization type and federal agency, - seperated by a dash (-)""" - org_choices = DomainRequest.OrganizationChoices - org_type = org_choices.get_org_label(obj.organization_type) - if obj.organization_type == org_choices.FEDERAL and obj.federal_agency: - return " - ".join([org_type, obj.federal_agency.agency]) - else: - return org_type + return obj.portfolio_type if obj.portfolio_type else "-" portfolio_type.short_description = "Portfolio type" @@ -2953,7 +2946,7 @@ class PortfolioAdmin(ListHeaderAdmin): if item_display_value: change_url = reverse(f"admin:registrar_{model_name}_change", args=[item.pk]) links.append(f'{escape(item_display_value)}') - return format_html(seperator.join(links)) + return format_html(seperator.join(links)) if links else "-" def change_view(self, request, object_id, form_url="", extra_context=None): """Add related suborganizations and domain groups""" diff --git a/src/registrar/models/portfolio.py b/src/registrar/models/portfolio.py index 7e5ee980d..77df3be31 100644 --- a/src/registrar/models/portfolio.py +++ b/src/registrar/models/portfolio.py @@ -121,6 +121,14 @@ class Portfolio(TimeStampedModel): def __str__(self) -> str: return str(self.organization_name) + @property + def portfolio_type(self): + org_type = self.OrganizationChoices.get_org_label(self.organization_type) + if self.organization_type == self.OrganizationChoices.FEDERAL and self.federal_agency: + return " - ".join([org_type, self.federal_agency.agency]) + else: + return org_type + # == Getters for domains == # def get_domains(self): """Returns all DomainInformations associated with this portfolio""" From ba1392e6cc0d2a208af6db21071d418f57bf94ba Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 13 Aug 2024 14:28:28 -0600 Subject: [PATCH 12/47] Remove items not part of ACs Remove the content for members --- src/registrar/admin.py | 36 +++++++++---------- src/registrar/models/portfolio.py | 29 --------------- .../admin/includes/portfolio_fieldset.html | 13 ------- .../django/admin/portfolio_change_form.html | 2 +- 4 files changed, 19 insertions(+), 61 deletions(-) delete mode 100644 src/registrar/templates/django/admin/includes/portfolio_fieldset.html diff --git a/src/registrar/admin.py b/src/registrar/admin.py index afe23c86d..c58cd9702 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -2892,7 +2892,7 @@ class PortfolioAdmin(ListHeaderAdmin): """Returns a comma seperated list of links for each related suborg""" queryset = obj.get_suborganizations() sep = '
' - return self.get_links_csv(queryset, "suborganization", seperator=sep) + return self.get_field_links_as_csv(queryset, "suborganization", seperator=sep) suborganizations.short_description = "Suborganizations" @@ -2900,7 +2900,7 @@ class PortfolioAdmin(ListHeaderAdmin): """Returns a comma seperated list of links for each related domain""" queryset = obj.get_domains() sep = '
' - return self.get_links_csv(queryset, "domaininformation", seperator=sep) + return self.get_field_links_as_csv(queryset, "domaininformation", seperator=sep) domains.short_description = "Domains" @@ -2908,24 +2908,10 @@ class PortfolioAdmin(ListHeaderAdmin): """Returns a comma seperated list of links for each related domain request""" queryset = obj.get_domain_requests() sep = '
' - return self.get_links_csv(queryset, "domainrequest", seperator=sep) + return self.get_field_links_as_csv(queryset, "domainrequest", seperator=sep) domain_requests.short_description = "Domain requests" - def administrators(self, obj: models.Portfolio): - """Returns a comma seperated list of links for each related administrator""" - queryset = obj.get_administrators() - return self.get_links_csv(queryset, "user", "get_full_name") - - administrators.short_description = "Administrators" - - def members(self, obj: models.Portfolio): - """Returns a comma seperated list of links for each related member""" - queryset = obj.get_members() - return self.get_links_csv(queryset, "user", "get_full_name") - - members.short_description = "Members" - # Creates select2 fields (with search bars) autocomplete_fields = [ "creator", @@ -2933,9 +2919,23 @@ class PortfolioAdmin(ListHeaderAdmin): ] # Q for reviewers: What should this be called? - def get_links_csv(self, queryset, model_name, link_text_attribute=None, seperator=", "): + def get_field_links_as_csv(self, queryset, model_name, link_text_attribute=None, seperator=", "): + """ + 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. + link_text_attribute: The attribute or method name to use for link text. If None, the item itself is used. + separator: The separator to use between links in the resulting HTML. + + 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 link_text_attribute="get_full_name" for instance. if link_text_attribute: item_display_value = getattr(item, link_text_attribute) if callable(item_display_value): diff --git a/src/registrar/models/portfolio.py b/src/registrar/models/portfolio.py index 77df3be31..bf7ce9601 100644 --- a/src/registrar/models/portfolio.py +++ b/src/registrar/models/portfolio.py @@ -142,32 +142,3 @@ class Portfolio(TimeStampedModel): def get_suborganizations(self): """Returns all suborganizations associated with this portfolio""" return self.portfolio_suborganizations.all() - - # == Getters for users == # - def get_users(self): - """Returns all users associated with this portfolio""" - return self.portfolio_users.all() - - def get_administrators(self): - """Returns all administrators associated with this portfolio""" - return self.portfolio_users.filter( - portfolio_roles__overlap=[ - UserPortfolioRoleChoices.ORGANIZATION_ADMIN, - ] - ) - - def get_readonly_administrators(self): - """Returns all readonly_administrators associated with this portfolio""" - return self.portfolio_users.filter( - portfolio_roles__overlap=[ - UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY, - ] - ) - - def get_members(self): - """Returns all members associated with this portfolio""" - return self.portfolio_users.filter( - portfolio_roles__overlap=[ - UserPortfolioRoleChoices.ORGANIZATION_MEMBER, - ] - ) diff --git a/src/registrar/templates/django/admin/includes/portfolio_fieldset.html b/src/registrar/templates/django/admin/includes/portfolio_fieldset.html deleted file mode 100644 index 4a0df2c34..000000000 --- a/src/registrar/templates/django/admin/includes/portfolio_fieldset.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "django/admin/includes/email_clipboard_fieldset.html" %} -{% load custom_filters %} -{% load static url_helpers %} - -{% block field_readonly %} - {% if field.field.name == "members" %} - {% comment %} Do nothing - for now {% endcomment %} -
{{ field.contents }}
- {% else %} -
{{ field.contents }}
- {% endif %} -{% endblock field_readonly %} - diff --git a/src/registrar/templates/django/admin/portfolio_change_form.html b/src/registrar/templates/django/admin/portfolio_change_form.html index 3257ee6a4..6b3b557a6 100644 --- a/src/registrar/templates/django/admin/portfolio_change_form.html +++ b/src/registrar/templates/django/admin/portfolio_change_form.html @@ -12,6 +12,6 @@ original_object is just a variable name for "DomainInformation" or "DomainRequest" {% endcomment %} - {% include "django/admin/includes/portfolio_fieldset.html" with original_object=original %} + {% include "django/admin/includes/detail_table_fieldset.html" with original_object=original %} {% endfor %} {% endblock %} From fa28412e31548e336abdd65a69e9e63eda18a472 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 13 Aug 2024 14:35:59 -0600 Subject: [PATCH 13/47] cleanup --- src/registrar/admin.py | 14 +++++++++----- .../django/admin/portfolio_change_form.html | 8 +------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index c58cd9702..5d90a90c5 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -2867,19 +2867,16 @@ class PortfolioAdmin(ListHeaderAdmin): ("Senior official", {"fields": ["senior_official"]}), ] - # NOTE: use add_fieldsets to modify that page list_display = ("organization_name", "federal_agency", "creator") search_fields = ["organization_name"] search_help_text = "Search by organization name." readonly_fields = [ "created_at", + "federal_type", # Custom fields such as these must be defined as readonly. - "administrators", - "members", "domains", "domain_requests", "suborganizations", - "federal_type", "portfolio_type", ] @@ -2951,7 +2948,14 @@ class PortfolioAdmin(ListHeaderAdmin): 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) - extra_context = {"administrators": obj.get_administrators(), "members": obj.get_members()} + + # ---- Domain Groups + domain_groups = DomainGroup.objects.filter(portfolio=obj) + + # ---- Suborganizations + suborganizations = Suborganization.objects.filter(portfolio=obj) + + extra_context = {"domain_groups": domain_groups, "suborganizations": suborganizations} return super().change_view(request, object_id, form_url, extra_context) def save_model(self, request, obj, form, change): diff --git a/src/registrar/templates/django/admin/portfolio_change_form.html b/src/registrar/templates/django/admin/portfolio_change_form.html index 6b3b557a6..30f4fd31f 100644 --- a/src/registrar/templates/django/admin/portfolio_change_form.html +++ b/src/registrar/templates/django/admin/portfolio_change_form.html @@ -4,13 +4,7 @@ {% block field_sets %} {% for fieldset in adminform %} {% comment %} - TODO: this will eventually need to be changed to something like this - if we ever want to customize this file: - {% include "django/admin/includes/domain_information_fieldset.html" %} - - Use detail_table_fieldset as an example, or just extend it. - - original_object is just a variable name for "DomainInformation" or "DomainRequest" + This is a placeholder for now {% endcomment %} {% include "django/admin/includes/detail_table_fieldset.html" with original_object=original %} {% endfor %} From 78354b6d39b8171a1b7b01efd28a6a2d9bf82afa Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 13 Aug 2024 14:46:25 -0600 Subject: [PATCH 14/47] Linting --- src/registrar/admin.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 5d90a90c5..faa5aa362 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -6,12 +6,10 @@ from django.template.loader import get_template from django import forms from django.db.models import Value, CharField, Q from django.db.models.functions import Concat, Coalesce -from django.contrib.admin.widgets import RelatedFieldWidgetWrapper 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.portfolio import Portfolio from registrar.models.suborganization import Suborganization from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from waffle.decorators import flag_is_active @@ -21,7 +19,7 @@ from django.contrib.auth.models import Group from django.contrib.contenttypes.models import ContentType from django.urls import reverse from epplibwrapper.errors import ErrorCode, RegistryError -from registrar.models import UserDomainRole, DomainInformation +from registrar.models import UserDomainRole from waffle.admin import FlagAdmin from waffle.models import Sample, Switch from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial @@ -2844,10 +2842,7 @@ class PortfolioAdmin(ListHeaderAdmin): # "classes": ("collapse", "closed"), # "fields": ["administrators", "members"]} # ), - ("Portfolio domains", { - "classes": ("collapse", "closed"), - "fields": ["domains", "domain_requests"]} - ), + ("Portfolio domains", {"classes": ("collapse", "closed"), "fields": ["domains", "domain_requests"]}), ("Type of organization", {"fields": ["organization_type", "federal_type"]}), ( "Organization name and mailing address", @@ -2919,13 +2914,13 @@ class PortfolioAdmin(ListHeaderAdmin): def get_field_links_as_csv(self, queryset, model_name, link_text_attribute=None, seperator=", "): """ 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. link_text_attribute: The attribute or method name to use for link text. If None, the item itself is used. separator: The separator to use between links in the resulting HTML. - + Returns: A formatted HTML string with links to the admin change pages for each item. """ From 40c52c46913bf0211d2deef98cea4712ee7aaef0 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 13 Aug 2024 15:07:37 -0600 Subject: [PATCH 15/47] Update src/registrar/admin.py --- src/registrar/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index faa5aa362..a94cfbba9 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -19,7 +19,7 @@ from django.contrib.auth.models import Group from django.contrib.contenttypes.models import ContentType from django.urls import reverse from epplibwrapper.errors import ErrorCode, RegistryError -from registrar.models import UserDomainRole +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 ffb7e146c7ac4e7fe599e0606fd8b8af3e47eec5 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 14 Aug 2024 08:30:55 -0600 Subject: [PATCH 16/47] Add comments --- src/registrar/admin.py | 1 + src/registrar/models/portfolio.py | 3 ++- src/registrar/models/senior_official.py | 1 + .../templates/django/admin/portfolio_change_form.html | 7 ++++++- 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index a94cfbba9..418e7e8d0 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -2876,6 +2876,7 @@ class PortfolioAdmin(ListHeaderAdmin): ] 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" diff --git a/src/registrar/models/portfolio.py b/src/registrar/models/portfolio.py index bf7ce9601..23e229040 100644 --- a/src/registrar/models/portfolio.py +++ b/src/registrar/models/portfolio.py @@ -2,7 +2,6 @@ from django.db import models from registrar.models.domain_request import DomainRequest from registrar.models.federal_agency import FederalAgency -from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices from registrar.utility.constants import BranchChoices from .utility.time_stamped_model import TimeStampedModel @@ -123,6 +122,8 @@ class Portfolio(TimeStampedModel): @property def portfolio_type(self): + """Returns a combination of organization_type and federal_agency, + seperated by ' - '. If no federal_agency is found, we just return the org type.""" org_type = self.OrganizationChoices.get_org_label(self.organization_type) if self.organization_type == self.OrganizationChoices.FEDERAL and self.federal_agency: return " - ".join([org_type, self.federal_agency.agency]) diff --git a/src/registrar/models/senior_official.py b/src/registrar/models/senior_official.py index 62632feee..7ae96238b 100644 --- a/src/registrar/models/senior_official.py +++ b/src/registrar/models/senior_official.py @@ -55,6 +55,7 @@ class SeniorOfficial(TimeStampedModel): return " ".join(names) if names else "Unknown" def has_contact_info(self): + """Determines if this user has contact information, such as an email or phone number.""" return bool(self.title or self.email or self.phone) def __str__(self): diff --git a/src/registrar/templates/django/admin/portfolio_change_form.html b/src/registrar/templates/django/admin/portfolio_change_form.html index 30f4fd31f..d9bf5cf01 100644 --- a/src/registrar/templates/django/admin/portfolio_change_form.html +++ b/src/registrar/templates/django/admin/portfolio_change_form.html @@ -4,7 +4,12 @@ {% block field_sets %} {% for fieldset in adminform %} {% comment %} - This is a placeholder for now + 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 %} From fdc5236968af0672f7ca2895082c0e9e10f11f77 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 14 Aug 2024 09:31:25 -0600 Subject: [PATCH 17/47] Finishing touches --- src/registrar/admin.py | 106 ++++++++++++++++++--- src/registrar/models/domain_information.py | 7 ++ src/registrar/models/portfolio.py | 8 +- 3 files changed, 104 insertions(+), 17 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 418e7e8d0..538a96981 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -2836,7 +2836,8 @@ class VerifiedByStaffAdmin(ListHeaderAdmin): class PortfolioAdmin(ListHeaderAdmin): change_form_template = "django/admin/portfolio_change_form.html" fieldsets = [ - (None, {"fields": ["portfolio_type", "organization_name", "creator", "created_at", "notes"]}), + # 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"), @@ -2862,12 +2863,39 @@ class PortfolioAdmin(ListHeaderAdmin): ("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", "federal_type"]}), + ( + "Organization name and mailing address", + { + "fields": [ + "federal_agency", + "state_territory", + "address_line1", + "address_line2", + "city", + "zipcode", + "urbanization", + ] + }, + ), + ("Senior official", {"fields": ["senior_official"]}), + ] + + # NOT all fields are readonly for admin, otherwise we would have + # set this at the permissions level. The exception is 'status' + analyst_readonly_fields = [ + "federal_type", + ] + list_display = ("organization_name", "federal_agency", "creator") search_fields = ["organization_name"] search_help_text = "Search by organization name." readonly_fields = [ - "created_at", - "federal_type", + # This is the created_at field + "created_on", # Custom fields such as these must be defined as readonly. "domains", "domain_requests", @@ -2875,6 +2903,13 @@ class PortfolioAdmin(ListHeaderAdmin): "portfolio_type", ] + 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" + 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 "-" @@ -2893,7 +2928,9 @@ class PortfolioAdmin(ListHeaderAdmin): """Returns a comma seperated list of links for each related domain""" queryset = obj.get_domains() sep = '
' - return self.get_field_links_as_csv(queryset, "domaininformation", seperator=sep) + return self.get_field_links_as_csv( + queryset, "domaininformation", link_info_attribute="get_state_display_of_domain", seperator=sep + ) domains.short_description = "Domains" @@ -2901,7 +2938,7 @@ class PortfolioAdmin(ListHeaderAdmin): """Returns a comma seperated list of links for each related domain request""" queryset = obj.get_domain_requests() sep = '
' - return self.get_field_links_as_csv(queryset, "domainrequest", seperator=sep) + return self.get_field_links_as_csv(queryset, "domainrequest", link_info_attribute="get_status_display", seperator=sep) domain_requests.short_description = "Domain requests" @@ -2912,14 +2949,17 @@ class PortfolioAdmin(ListHeaderAdmin): ] # Q for reviewers: What should this be called? - def get_field_links_as_csv(self, queryset, model_name, link_text_attribute=None, seperator=", "): + def get_field_links_as_csv( + self, queryset, model_name, attribute_name=None, link_info_attribute=None, seperator=", " + ): """ 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. - link_text_attribute: The attribute or method name to use for link text. If None, the item itself is used. + 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. Returns: @@ -2928,19 +2968,59 @@ class PortfolioAdmin(ListHeaderAdmin): links = [] for item in queryset: - # This allows you to pass in link_text_attribute="get_full_name" for instance. - if link_text_attribute: - item_display_value = getattr(item, link_text_attribute) - if callable(item_display_value): - item_display_value = item_display_value() + # 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]) - links.append(f'{escape(item_display_value)}') + + link = f'{escape(item_display_value)}' + if link_info_attribute: + link += f" ({self.value_of_attribute(item, link_info_attribute)})" + + links.append(link) return format_html(seperator.join(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) diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py index 894bbe6fe..beffaede8 100644 --- a/src/registrar/models/domain_information.py +++ b/src/registrar/models/domain_information.py @@ -424,3 +424,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 \ No newline at end of file diff --git a/src/registrar/models/portfolio.py b/src/registrar/models/portfolio.py index 23e229040..93944e10d 100644 --- a/src/registrar/models/portfolio.py +++ b/src/registrar/models/portfolio.py @@ -122,11 +122,11 @@ class Portfolio(TimeStampedModel): @property def portfolio_type(self): - """Returns a combination of organization_type and federal_agency, - seperated by ' - '. If no federal_agency is found, we just return the org type.""" + """Returns a combination of organization_type and federal_type, + seperated by ' - '. If no federal_type is found, we just return the org type.""" org_type = self.OrganizationChoices.get_org_label(self.organization_type) - if self.organization_type == self.OrganizationChoices.FEDERAL and self.federal_agency: - return " - ".join([org_type, self.federal_agency.agency]) + if self.organization_type == self.OrganizationChoices.FEDERAL and self.federal_type: + return " - ".join([org_type, self.federal_type]) else: return org_type From a847b07ef72e615577fae3a7365e0ec22c8da5c0 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 14 Aug 2024 10:16:50 -0600 Subject: [PATCH 18/47] Add simple unit tests --- src/registrar/admin.py | 4 +- src/registrar/models/domain_information.py | 2 +- src/registrar/models/portfolio.py | 5 +- src/registrar/tests/common.py | 4 ++ src/registrar/tests/test_admin.py | 79 ++++++++++++++++++++++ 5 files changed, 90 insertions(+), 4 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 538a96981..b2daebd9f 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -2938,7 +2938,9 @@ class PortfolioAdmin(ListHeaderAdmin): """Returns a comma seperated list of links for each related domain request""" queryset = obj.get_domain_requests() sep = '
' - return self.get_field_links_as_csv(queryset, "domainrequest", link_info_attribute="get_status_display", seperator=sep) + return self.get_field_links_as_csv( + queryset, "domainrequest", link_info_attribute="get_status_display", seperator=sep + ) domain_requests.short_description = "Domain requests" diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py index beffaede8..bdd67e582 100644 --- a/src/registrar/models/domain_information.py +++ b/src/registrar/models/domain_information.py @@ -430,4 +430,4 @@ class DomainInformation(TimeStampedModel): if self.domain: return self.domain.get_state_display() else: - return None \ No newline at end of file + return None diff --git a/src/registrar/models/portfolio.py b/src/registrar/models/portfolio.py index 93944e10d..9bf1581f4 100644 --- a/src/registrar/models/portfolio.py +++ b/src/registrar/models/portfolio.py @@ -122,8 +122,9 @@ class Portfolio(TimeStampedModel): @property def portfolio_type(self): - """Returns a combination of organization_type and federal_type, - seperated by ' - '. If no federal_type is found, we just return the org type.""" + """ + Returns a combination of organization_type / federal_type, seperated by ' - '. + If no federal_type is found, we just return the org type.""" org_type = self.OrganizationChoices.get_org_label(self.organization_type) if self.organization_type == self.OrganizationChoices.FEDERAL and self.federal_type: return " - ".join([org_type, self.federal_type]) 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..120e2ef0b 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('
', suborganizations) + + @less_console_noise_decorator + def test_domains_display(self): + """Tests the custom domains field which displays all related domains""" + request_1 = completed_domain_request( + name="request1.gov", portfolio=self.portfolio, status=DomainRequest.DomainRequestStatus.IN_REVIEW + ) + request_2 = completed_domain_request( + name="request2.gov", portfolio=self.portfolio, status=DomainRequest.DomainRequestStatus.IN_REVIEW + ) + + # Create some domain objects + request_1.approve() + request_2.approve() + + domain_1 = DomainInformation.objects.get(domain_request=request_1).domain + domain_1.name = "domain1.gov" + domain_1.save() + domain_2 = DomainInformation.objects.get(domain_request=request_2).domain + domain_2.name = "domain2.gov" + domain_2.save() + + domains = self.admin.domains(self.portfolio) + self.assertIn("domain1.gov", domains) + self.assertIn("domain2.gov", domains) + self.assertIn('
', 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) From 21ee9ad3d83527e237148c63ce972b1099e16d2f Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 14 Aug 2024 12:08:21 -0600 Subject: [PATCH 19/47] Add type ignore --- src/registrar/admin.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index b2daebd9f..2d750874e 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -2908,13 +2908,13 @@ class PortfolioAdmin(ListHeaderAdmin): # Format: Dec 12, 2024 return obj.created_at.strftime("%b %d, %Y") if obj.created_at else "-" - created_on.short_description = "Created on" + 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" + portfolio_type.short_description = "Portfolio type" # type: ignore def suborganizations(self, obj: models.Portfolio): """Returns a comma seperated list of links for each related suborg""" @@ -2922,7 +2922,7 @@ class PortfolioAdmin(ListHeaderAdmin): sep = '
' return self.get_field_links_as_csv(queryset, "suborganization", seperator=sep) - suborganizations.short_description = "Suborganizations" + suborganizations.short_description = "Suborganizations" # type: ignore def domains(self, obj: models.Portfolio): """Returns a comma seperated list of links for each related domain""" @@ -2932,7 +2932,7 @@ class PortfolioAdmin(ListHeaderAdmin): queryset, "domaininformation", link_info_attribute="get_state_display_of_domain", seperator=sep ) - domains.short_description = "Domains" + domains.short_description = "Domains" # type: ignore def domain_requests(self, obj: models.Portfolio): """Returns a comma seperated list of links for each related domain request""" @@ -2942,7 +2942,7 @@ class PortfolioAdmin(ListHeaderAdmin): queryset, "domainrequest", link_info_attribute="get_status_display", seperator=sep ) - domain_requests.short_description = "Domain requests" + domain_requests.short_description = "Domain requests" # type: ignore # Creates select2 fields (with search bars) autocomplete_fields = [ From 073ff94c3ba4214ba22dc449da0dacf438ac6300 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Wed, 14 Aug 2024 12:13:48 -0600 Subject: [PATCH 20/47] fixing tests - thanks for the help Rachid! --- src/registrar/views/domain_request.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py index c8f81dcaa..b691549cd 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -218,10 +218,6 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView): del self.storage self.storage["domain_request_id"] = kwargs["id"] - # 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() - # 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); # subclasseswill NOT be redirected. The purpose of this is to allow code to @@ -236,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() From 7d365522291434883f6103d6cd0a83fda97b69b5 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 14 Aug 2024 12:52:00 -0600 Subject: [PATCH 21/47] Regen migrations --- ..._options_portfolio_federal_type_and_more.py} | 17 +++++------------ src/registrar/models/portfolio.py | 4 ++++ src/registrar/models/user.py | 2 +- 3 files changed, 10 insertions(+), 13 deletions(-) rename src/registrar/migrations/{0118_portfolio_federal_type_alter_portfolio_creator_and_more.py => 0118_alter_portfolio_options_portfolio_federal_type_and_more.py} (83%) diff --git a/src/registrar/migrations/0118_portfolio_federal_type_alter_portfolio_creator_and_more.py b/src/registrar/migrations/0118_alter_portfolio_options_portfolio_federal_type_and_more.py similarity index 83% rename from src/registrar/migrations/0118_portfolio_federal_type_alter_portfolio_creator_and_more.py rename to src/registrar/migrations/0118_alter_portfolio_options_portfolio_federal_type_and_more.py index 338107ece..693744951 100644 --- a/src/registrar/migrations/0118_portfolio_federal_type_alter_portfolio_creator_and_more.py +++ b/src/registrar/migrations/0118_alter_portfolio_options_portfolio_federal_type_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.10 on 2024-08-13 20:01 +# Generated by Django 4.2.10 on 2024-08-14 18:50 from django.conf import settings from django.db import migrations, models @@ -12,6 +12,10 @@ class Migration(migrations.Migration): ] operations = [ + migrations.AlterModelOptions( + name="portfolio", + options={"ordering": ["organization_name"]}, + ), migrations.AddField( model_name="portfolio", name="federal_type", @@ -53,15 +57,4 @@ class Migration(migrations.Migration): to="registrar.portfolio", ), ), - migrations.AlterField( - model_name="user", - name="portfolio", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="portfolio_users", - to="registrar.portfolio", - ), - ), ] diff --git a/src/registrar/models/portfolio.py b/src/registrar/models/portfolio.py index 9bf1581f4..25429c0fe 100644 --- a/src/registrar/models/portfolio.py +++ b/src/registrar/models/portfolio.py @@ -13,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 diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index a532fe5fb..81d3b9b61 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -116,7 +116,7 @@ class User(AbstractUser): "registrar.Portfolio", null=True, blank=True, - related_name="portfolio_users", + related_name="user", on_delete=models.SET_NULL, ) From 273cf91f76d3a731d53734ac101d3d8b4403706b Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 14 Aug 2024 13:33:02 -0600 Subject: [PATCH 22/47] Update portfolio.py --- src/registrar/models/portfolio.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/registrar/models/portfolio.py b/src/registrar/models/portfolio.py index fd5c3870d..bd1c091f8 100644 --- a/src/registrar/models/portfolio.py +++ b/src/registrar/models/portfolio.py @@ -124,6 +124,15 @@ class Portfolio(TimeStampedModel): def __str__(self) -> str: return str(self.organization_name) + def save(self, *args, **kwargs): + """Save override for custom properties""" + + # The urbanization field is only intended for the state_territory puerto rico + if self.state_territory != self.StateTerritoryChoices.PUERTO_RICO and self.urbanization: + self.urbanization = None + + super().save(*args, **kwargs) + @property def portfolio_type(self): """ @@ -148,12 +157,3 @@ class Portfolio(TimeStampedModel): def get_suborganizations(self): """Returns all suborganizations associated with this portfolio""" return self.portfolio_suborganizations.all() - - def save(self, *args, **kwargs): - """Save override for custom properties""" - - # The urbanization field is only intended for the state_territory puerto rico - if self.state_territory != self.StateTerritoryChoices.PUERTO_RICO and self.urbanization: - self.urbanization = None - - super().save(*args, **kwargs) From 1b2b6e784e58a03ded12a3f9bcc7b820092d2b7f Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 14 Aug 2024 14:39:08 -0600 Subject: [PATCH 23/47] Return ul rather than a list, fix bug --- src/registrar/admin.py | 38 +++++++++++--------- src/registrar/assets/js/get-gov-admin.js | 3 -- src/registrar/assets/sass/_theme/_admin.scss | 6 ++++ src/registrar/models/portfolio.py | 1 - 4 files changed, 28 insertions(+), 20 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 607313f2d..6bc6f2061 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -2947,29 +2947,26 @@ class PortfolioAdmin(ListHeaderAdmin): portfolio_type.short_description = "Portfolio type" # type: ignore def suborganizations(self, obj: models.Portfolio): - """Returns a comma seperated list of links for each related suborg""" + """Returns a list of links for each related suborg""" queryset = obj.get_suborganizations() - sep = '
' - return self.get_field_links_as_csv(queryset, "suborganization", seperator=sep) + return self.get_field_links_as_list(queryset, "suborganization") suborganizations.short_description = "Suborganizations" # type: ignore def domains(self, obj: models.Portfolio): - """Returns a comma seperated list of links for each related domain""" + """Returns a list of links for each related domain""" queryset = obj.get_domains() - sep = '
' - return self.get_field_links_as_csv( - queryset, "domaininformation", link_info_attribute="get_state_display_of_domain", seperator=sep + 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 comma seperated list of links for each related domain request""" + """Returns a list of links for each related domain request""" queryset = obj.get_domain_requests() - sep = '
' - return self.get_field_links_as_csv( - queryset, "domainrequest", link_info_attribute="get_status_display", seperator=sep + return self.get_field_links_as_list( + queryset, "domainrequest", link_info_attribute="get_status_display" ) domain_requests.short_description = "Domain requests" # type: ignore @@ -2981,9 +2978,8 @@ class PortfolioAdmin(ListHeaderAdmin): "senior_official", ] - # Q for reviewers: What should this be called? - def get_field_links_as_csv( - self, queryset, model_name, attribute_name=None, link_info_attribute=None, seperator=", " + 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. @@ -2994,6 +2990,7 @@ class PortfolioAdmin(ListHeaderAdmin): 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. @@ -3014,8 +3011,17 @@ class PortfolioAdmin(ListHeaderAdmin): if link_info_attribute: link += f" ({self.value_of_attribute(item, link_info_attribute)})" - links.append(link) - return format_html(seperator.join(links)) if links else "-" + 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. diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 93b8359bf..f376af40b 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -776,9 +776,6 @@ function initializeWidgetOnList(list, parentId) { 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); diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index 711bfe960..d6f03f427 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -847,3 +847,9 @@ div.dja__model-description{ } } } + +ul.unstyled-list-elements { + padding: 0; + margin: 0; + list-style: none; +} diff --git a/src/registrar/models/portfolio.py b/src/registrar/models/portfolio.py index bd1c091f8..6dcd660e7 100644 --- a/src/registrar/models/portfolio.py +++ b/src/registrar/models/portfolio.py @@ -31,7 +31,6 @@ class Portfolio(TimeStampedModel): unique=False, ) - # Q for reviewers: shouldn't this be a required field? organization_name = models.CharField( null=True, blank=True, From a2e38410458ddb5fe33245002017881583975b49 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 14 Aug 2024 14:44:01 -0600 Subject: [PATCH 24/47] fix tests --- src/registrar/tests/test_admin.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 120e2ef0b..b1c7c70a6 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -2083,7 +2083,7 @@ class TestPortfolioAdmin(TestCase): suborganizations = self.admin.suborganizations(self.portfolio) self.assertIn("Sub1", suborganizations) self.assertIn("Sub2", suborganizations) - self.assertIn('
    ', suborganizations) + self.assertIn('
      ', suborganizations) @less_console_noise_decorator def test_domains_display(self): @@ -2109,7 +2109,7 @@ class TestPortfolioAdmin(TestCase): domains = self.admin.domains(self.portfolio) self.assertIn("domain1.gov", domains) self.assertIn("domain2.gov", domains) - self.assertIn('
      ', domains) + self.assertIn('
        ', domains) @less_console_noise_decorator def test_domain_requests_display(self): @@ -2120,4 +2120,4 @@ class TestPortfolioAdmin(TestCase): domain_requests = self.admin.domain_requests(self.portfolio) self.assertIn("request1.gov", domain_requests) self.assertIn("request2.gov", domain_requests) - self.assertIn('
        ', domain_requests) + self.assertIn('
          ', domain_requests) From 7f37bf5b9b5f5828bb15951fcb289882978c3a7e Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 14 Aug 2024 14:44:49 -0600 Subject: [PATCH 25/47] css fix --- src/registrar/assets/sass/_theme/_admin.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index d6f03f427..cc1214c81 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -849,7 +849,7 @@ div.dja__model-description{ } ul.unstyled-list-elements { - padding: 0; - margin: 0; + padding: 0 !important; + margin: 0 !important; list-style: none; } From 6a343634f4a518e33de639d40893ae670c38f468 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 14 Aug 2024 14:48:34 -0600 Subject: [PATCH 26/47] lint --- src/registrar/admin.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 6bc6f2061..736dd6f16 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -2965,9 +2965,7 @@ class PortfolioAdmin(ListHeaderAdmin): 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" - ) + return self.get_field_links_as_list(queryset, "domainrequest", link_info_attribute="get_status_display") domain_requests.short_description = "Domain requests" # type: ignore @@ -3014,7 +3012,7 @@ class PortfolioAdmin(ListHeaderAdmin): if seperator: links.append(link) else: - links.append(f'
        • {link}
        • ') + links.append(f"
        • {link}
        • ") # If no seperator is specified, just return an unordered list. if seperator: From 9059ce32815ff4e2b92ecd38c34caf661b179b44 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 15 Aug 2024 08:59:05 -0600 Subject: [PATCH 27/47] Suggestions + QOL changes --- src/registrar/admin.py | 10 +---- src/registrar/assets/js/get-gov-admin.js | 39 ++++++++++++++++++- src/registrar/models/senior_official.py | 4 -- .../admin/includes/contact_detail_list.html | 5 ++- .../admin/includes/detail_table_fieldset.html | 4 +- src/registrar/templatetags/custom_filters.py | 9 +++++ 6 files changed, 53 insertions(+), 18 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 736dd6f16..28e3af4d0 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -3060,15 +3060,7 @@ class PortfolioAdmin(ListHeaderAdmin): 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): diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index f376af40b..b6ba82821 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -765,13 +765,18 @@ 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"); @@ -797,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(); @@ -822,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 => { @@ -840,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()){ @@ -862,6 +881,22 @@ function initializeWidgetOnList(list, parentId) { .catch(error => console.error("Error fetching senior official: ", error)); } + 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 || ""; + if (emailSpan) { + emailSpan.textContent = data.email || ""; + const clipboardInput = contactList.querySelector(".admin-icon-group input"); + if (clipboardInput) clipboardInput.value = data.email || ""; + } + if (phoneSpan) phoneSpan.textContent = data.phone || ""; + } + function handleStateTerritoryChange(stateTerritory, urbanizationField) { let selectedValue = stateTerritory.value; if (selectedValue === "PR") { diff --git a/src/registrar/models/senior_official.py b/src/registrar/models/senior_official.py index 7ae96238b..38ce4f35d 100644 --- a/src/registrar/models/senior_official.py +++ b/src/registrar/models/senior_official.py @@ -54,10 +54,6 @@ class SeniorOfficial(TimeStampedModel): names = [n for n in [self.first_name, self.last_name] if n] return " ".join(names) if names else "Unknown" - def has_contact_info(self): - """Determines if this user has contact information, such as an email or phone number.""" - return bool(self.title or self.email or self.phone) - def __str__(self): if self.first_name or self.last_name: return self.get_formatted_name() 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 397e2e1a0..054836fd3 100644 --- a/src/registrar/templates/django/admin/includes/contact_detail_list.html +++ b/src/registrar/templates/django/admin/includes/contact_detail_list.html @@ -1,4 +1,5 @@ {% load i18n static %} +{% load custom_filters %}
          @@ -12,7 +13,7 @@
          {% 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/templatetags/custom_filters.py b/src/registrar/templatetags/custom_filters.py index e90b3b17f..1619f9b15 100644 --- a/src/registrar/templatetags/custom_filters.py +++ b/src/registrar/templatetags/custom_filters.py @@ -159,3 +159,12 @@ 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) \ No newline at end of file From 06b66f440097f9d27ce822f97c3bfe6bb42d3fb6 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 15 Aug 2024 09:33:57 -0600 Subject: [PATCH 28/47] Make federal_type readonly --- src/registrar/admin.py | 16 ++++---- src/registrar/assets/js/get-gov-admin.js | 41 +++++++++++-------- ...tions_alter_portfolio_creator_and_more.py} | 12 +----- src/registrar/models/portfolio.py | 24 +++++------ 4 files changed, 47 insertions(+), 46 deletions(-) rename src/registrar/migrations/{0118_alter_portfolio_options_portfolio_federal_type_and_more.py => 0118_alter_portfolio_options_alter_portfolio_creator_and_more.py} (79%) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 28e3af4d0..b11afd7b2 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -23,6 +23,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 @@ -2896,7 +2897,7 @@ class PortfolioAdmin(ListHeaderAdmin): # This is the fieldset display when adding a new model add_fieldsets = [ (None, {"fields": ["organization_name", "creator", "notes"]}), - ("Type of organization", {"fields": ["organization_type", "federal_type"]}), + ("Type of organization", {"fields": ["organization_type"]}), ( "Organization name and mailing address", { @@ -2914,12 +2915,6 @@ class PortfolioAdmin(ListHeaderAdmin): ("Senior official", {"fields": ["senior_official"]}), ] - # NOT all fields are readonly for admin, otherwise we would have - # set this at the permissions level. The exception is 'status' - analyst_readonly_fields = [ - "federal_type", - ] - list_display = ("organization_name", "federal_agency", "creator") search_fields = ["organization_name"] search_help_text = "Search by organization name." @@ -2927,12 +2922,19 @@ class PortfolioAdmin(ListHeaderAdmin): # 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" + def created_on(self, obj: models.Portfolio): """Returns the created_at field, with a different short description""" # Format: Dec 12, 2024 diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index b6ba82821..3a5af7f6e 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -881,22 +881,6 @@ function initializeWidgetOnList(list, parentId) { .catch(error => console.error("Error fetching senior official: ", error)); } - 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 || ""; - if (emailSpan) { - emailSpan.textContent = data.email || ""; - const clipboardInput = contactList.querySelector(".admin-icon-group input"); - if (clipboardInput) clipboardInput.value = data.email || ""; - } - if (phoneSpan) phoneSpan.textContent = data.phone || ""; - } - function handleStateTerritoryChange(stateTerritory, urbanizationField) { let selectedValue = stateTerritory.value; if (selectedValue === "PR") { @@ -905,4 +889,29 @@ 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 || ""; + }; + + // Update the email field and the content for the clipboard + if (emailSpan) { + emailSpan.textContent = data.email || ""; + const clipboardInput = contactList.querySelector(".admin-icon-group input"); + if (clipboardInput) { + clipboardInput.value = data.email || ""; + }; + } + + if (phoneSpan) { + phoneSpan.textContent = data.phone || ""; + }; + } })(); diff --git a/src/registrar/migrations/0118_alter_portfolio_options_portfolio_federal_type_and_more.py b/src/registrar/migrations/0118_alter_portfolio_options_alter_portfolio_creator_and_more.py similarity index 79% rename from src/registrar/migrations/0118_alter_portfolio_options_portfolio_federal_type_and_more.py rename to src/registrar/migrations/0118_alter_portfolio_options_alter_portfolio_creator_and_more.py index 693744951..8f84187a2 100644 --- a/src/registrar/migrations/0118_alter_portfolio_options_portfolio_federal_type_and_more.py +++ b/src/registrar/migrations/0118_alter_portfolio_options_alter_portfolio_creator_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.10 on 2024-08-14 18:50 +# Generated by Django 4.2.10 on 2024-08-15 15:32 from django.conf import settings from django.db import migrations, models @@ -16,16 +16,6 @@ class Migration(migrations.Migration): name="portfolio", options={"ordering": ["organization_name"]}, ), - migrations.AddField( - model_name="portfolio", - name="federal_type", - field=models.CharField( - blank=True, - choices=[("executive", "Executive"), ("judicial", "Judicial"), ("legislative", "Legislative")], - max_length=50, - null=True, - ), - ), migrations.AlterField( model_name="portfolio", name="creator", diff --git a/src/registrar/models/portfolio.py b/src/registrar/models/portfolio.py index 6dcd660e7..77daf9cb1 100644 --- a/src/registrar/models/portfolio.py +++ b/src/registrar/models/portfolio.py @@ -58,13 +58,6 @@ class Portfolio(TimeStampedModel): default=FederalAgency.get_non_federal_agency, ) - federal_type = models.CharField( - max_length=50, - choices=BranchChoices.choices, - null=True, - blank=True, - ) - senior_official = models.ForeignKey( "registrar.SeniorOfficial", on_delete=models.PROTECT, @@ -136,12 +129,19 @@ class Portfolio(TimeStampedModel): 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 = self.OrganizationChoices.get_org_label(self.organization_type) - if self.organization_type == self.OrganizationChoices.FEDERAL and self.federal_type: - return " - ".join([org_type, self.federal_type]) + 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 + 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): From cc1dd665536b44114ccd25462b1812a437cd1f9e Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 15 Aug 2024 10:12:38 -0600 Subject: [PATCH 29/47] use filter on address --- src/registrar/assets/js/get-gov-admin.js | 20 ++++++++++++------- .../admin/includes/contact_detail_list.html | 2 +- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 3a5af7f6e..01c93abf6 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -898,20 +898,26 @@ function initializeWidgetOnList(list, parentId) { const phoneSpan = contactList.querySelector("#contact_info_phone"); if (titleSpan) { - titleSpan.textContent = data.title || ""; + titleSpan.textContent = data.title || "None"; }; // Update the email field and the content for the clipboard if (emailSpan) { - emailSpan.textContent = data.email || ""; - const clipboardInput = contactList.querySelector(".admin-icon-group input"); - if (clipboardInput) { - clipboardInput.value = data.email || ""; - }; + 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 || ""; + phoneSpan.textContent = data.phone || "None"; }; } })(); 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 054836fd3..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,7 +1,7 @@ {% load i18n static %} {% load custom_filters %} -
          +
          {% if show_formatted_name %} From 52e3fb89b70aa2e6d2b6d61998aa582120ebd558 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 15 Aug 2024 10:16:41 -0600 Subject: [PATCH 30/47] linting --- src/registrar/admin.py | 4 +--- src/registrar/models/portfolio.py | 2 +- src/registrar/templatetags/custom_filters.py | 3 ++- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index b11afd7b2..cf2f072a8 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -9,8 +9,6 @@ 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.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from waffle.decorators import flag_is_active from django.contrib import admin, messages @@ -2932,7 +2930,7 @@ class PortfolioAdmin(ListHeaderAdmin): 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" def created_on(self, obj: models.Portfolio): diff --git a/src/registrar/models/portfolio.py b/src/registrar/models/portfolio.py index 77daf9cb1..0f9904c31 100644 --- a/src/registrar/models/portfolio.py +++ b/src/registrar/models/portfolio.py @@ -137,7 +137,7 @@ class Portfolio(TimeStampedModel): 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""" diff --git a/src/registrar/templatetags/custom_filters.py b/src/registrar/templatetags/custom_filters.py index 1619f9b15..728478a51 100644 --- a/src/registrar/templatetags/custom_filters.py +++ b/src/registrar/templatetags/custom_filters.py @@ -160,6 +160,7 @@ def and_filter(value, 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 @@ -167,4 +168,4 @@ def has_contact_info(user): 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) \ No newline at end of file + return bool(user.title or user.email or user.phone) From e92b0c5b79d12ff41bbc591b17ee3836cf469465 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 15 Aug 2024 10:34:57 -0600 Subject: [PATCH 31/47] Update admin.py --- src/registrar/admin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index cf2f072a8..043aee10d 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -2927,11 +2927,11 @@ class PortfolioAdmin(ListHeaderAdmin): "portfolio_type", ] - def federal_type(self, obj: models.portfolio): + 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" + 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""" From 771e443850c5aeda667b3baffaec6be6bc58f2eb Mon Sep 17 00:00:00 2001 From: Kristina Yin <140533113+kristinacyin@users.noreply.github.com> Date: Thu, 15 Aug 2024 10:01:31 -0700 Subject: [PATCH 32/47] Adding Team OOO spreadsheet to designer onboarding template --- .github/ISSUE_TEMPLATE/designer-onboarding.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/designer-onboarding.md b/.github/ISSUE_TEMPLATE/designer-onboarding.md index 2a4cab3c2..2164ae6bb 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. From fc138b5f4420902eb3361db6000d94853bb56d40 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 15 Aug 2024 11:10:00 -0600 Subject: [PATCH 33/47] Use built in uswds class --- src/registrar/admin.py | 2 +- src/registrar/assets/sass/_theme/_admin.scss | 3 +-- src/registrar/tests/test_admin.py | 6 +++--- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 043aee10d..423c0a01b 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -3019,7 +3019,7 @@ class PortfolioAdmin(ListHeaderAdmin): return format_html(seperator.join(links)) if links else "-" else: links = "".join(links) - return format_html(f'
            {links}
          ') if links else "-" + 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. diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index cc1214c81..8ca6b5465 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -848,8 +848,7 @@ div.dja__model-description{ } } -ul.unstyled-list-elements { +ul.add-list-reset { padding: 0 !important; margin: 0 !important; - list-style: none; } diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index b1c7c70a6..827742ef1 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -2083,7 +2083,7 @@ class TestPortfolioAdmin(TestCase): suborganizations = self.admin.suborganizations(self.portfolio) self.assertIn("Sub1", suborganizations) self.assertIn("Sub2", suborganizations) - self.assertIn('
            ', suborganizations) + self.assertIn('
              ', suborganizations) @less_console_noise_decorator def test_domains_display(self): @@ -2109,7 +2109,7 @@ class TestPortfolioAdmin(TestCase): domains = self.admin.domains(self.portfolio) self.assertIn("domain1.gov", domains) self.assertIn("domain2.gov", domains) - self.assertIn('
                ', domains) + self.assertIn('
                  ', domains) @less_console_noise_decorator def test_domain_requests_display(self): @@ -2120,4 +2120,4 @@ class TestPortfolioAdmin(TestCase): domain_requests = self.admin.domain_requests(self.portfolio) self.assertIn("request1.gov", domain_requests) self.assertIn("request2.gov", domain_requests) - self.assertIn('
                    ', domain_requests) + self.assertIn('
                      ', domain_requests) From 9f9969ca72795953b41978280b696e1ee06ed35d Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 15 Aug 2024 11:39:40 -0600 Subject: [PATCH 34/47] Add jyoti to fixtures --- src/registrar/fixtures_users.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/registrar/fixtures_users.py b/src/registrar/fixtures_users.py index 7ce63d364..6cfd13573 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", From 9d83e50ac69e4f37d75e52cb92cae9f0ddd2c007 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 15 Aug 2024 11:45:46 -0600 Subject: [PATCH 35/47] Update fixtures_users.py --- src/registrar/fixtures_users.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/registrar/fixtures_users.py b/src/registrar/fixtures_users.py index 6cfd13573..0fc203248 100644 --- a/src/registrar/fixtures_users.py +++ b/src/registrar/fixtures_users.py @@ -26,7 +26,7 @@ class UserFixture: "username": "43a7fa8d-0550-4494-a6fe-81500324d590", "first_name": "Jyoti", "last_name": "Bock", - "email": "jyotibock@truss.works" + "email": "jyotibock@truss.works", }, { "username": "aad084c3-66cc-4632-80eb-41cdf5c5bcbf", @@ -135,7 +135,7 @@ class UserFixture: "username": "a5906815-dd80-4c64-aebe-2da6a4c9d7a4", "first_name": "Jyoti-Analyst", "last_name": "Bock-Analyst", - "email": "jyotibock+1@truss.works" + "email": "jyotibock+1@truss.works", }, { "username": "ffec5987-aa84-411b-a05a-a7ee5cbcde54", From 34c9cd27617c6c3df8306c1b152295db24318f51 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 15 Aug 2024 11:25:22 -0700 Subject: [PATCH 36/47] Convert CSP default src to tuple --- src/registrar/config/settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index 861ce3a94..e0533943e 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -357,9 +357,9 @@ 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_DEFAULT_SRC = [ +CSP_DEFAULT_SRC = ( "'self'", -] +) CSP_STYLE_SRC = ["'self'", "https://www.ssa.gov", "'unsafe-inline'"] CSP_SCRIPT_SRC_ELEM = [ "'self'", From 965724ec1d77b554a6d17b7d7fa1b96f3187de1a Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 15 Aug 2024 12:02:57 -0700 Subject: [PATCH 37/47] Remove reference to ANDI hardware --- src/registrar/config/settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index e0533943e..bc9e09103 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -190,7 +190,7 @@ MIDDLEWARE = [ "waffle.middleware.WaffleMiddleware", "registrar.registrar_middleware.CheckUserProfileMiddleware", "registrar.registrar_middleware.CheckPortfolioMiddleware", - "registrar.registrar_middleware.ANDIMiddleware", + # "registrar.registrar_middleware.ANDIMiddleware", ] # application object used by Django’s built-in servers (e.g. `runserver`) @@ -360,7 +360,7 @@ CSP_FORM_ACTION = allowed_sources CSP_DEFAULT_SRC = ( "'self'", ) -CSP_STYLE_SRC = ["'self'", "https://www.ssa.gov", "'unsafe-inline'"] +CSP_STYLE_SRC = ["'self'", "https://www.ssa.gov"] CSP_SCRIPT_SRC_ELEM = [ "'self'", "https://www.googletagmanager.com/", From cb9ce3792016a5836897d2d7f2a899703748c194 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 15 Aug 2024 12:03:33 -0700 Subject: [PATCH 38/47] Fix lint errors --- src/registrar/config/settings.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index bc9e09103..4ae0c9fe5 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -357,9 +357,7 @@ 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_DEFAULT_SRC = ( - "'self'", -) +CSP_DEFAULT_SRC = ("'self'",) CSP_STYLE_SRC = ["'self'", "https://www.ssa.gov"] CSP_SCRIPT_SRC_ELEM = [ "'self'", From f68ef23dad07517c4816ec1f8354ad680271c5b8 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 15 Aug 2024 12:19:54 -0700 Subject: [PATCH 39/47] Remove unused middleware class --- src/registrar/config/settings.py | 1 - src/registrar/registrar_middleware.py | 21 --------------------- 2 files changed, 22 deletions(-) diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index 4ae0c9fe5..1b5b2bee5 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -190,7 +190,6 @@ MIDDLEWARE = [ "waffle.middleware.WaffleMiddleware", "registrar.registrar_middleware.CheckUserProfileMiddleware", "registrar.registrar_middleware.CheckPortfolioMiddleware", - # "registrar.registrar_middleware.ANDIMiddleware", ] # application object used by Django’s built-in servers (e.g. `runserver`) diff --git a/src/registrar/registrar_middleware.py b/src/registrar/registrar_middleware.py index a772fbe0a..98685061b 100644 --- a/src/registrar/registrar_middleware.py +++ b/src/registrar/registrar_middleware.py @@ -158,24 +158,3 @@ class CheckPortfolioMiddleware: return HttpResponseRedirect(portfolio_redirect) return None - - -class ANDIMiddleware(MiddlewareMixin): - def __init__(self, get_response): - self.get_response = get_response - - def __call__(self, request): - response = self.get_response(request) - return response - - def process_template_view(self, request, view_func, view_args, view_kwargs): - response = self.get_response(request) - if "text/html" in response.get("Content-Type", ""): - andi_script = """ - - """ - # Inject the ANDI script before the closing tag - content = response.content.decode("utf-8") - content = content.replace("", f"{andi_script}") - response.content = content.encode("utf-8") - return None From c385f88505018868a28b9843367a948313cd3bbe Mon Sep 17 00:00:00 2001 From: Kristina Yin <140533113+kristinacyin@users.noreply.github.com> Date: Thu, 15 Aug 2024 12:59:27 -0700 Subject: [PATCH 40/47] Update designer-onboarding.md --- .github/ISSUE_TEMPLATE/designer-onboarding.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/designer-onboarding.md b/.github/ISSUE_TEMPLATE/designer-onboarding.md index 2164ae6bb..f6518109f 100644 --- a/.github/ISSUE_TEMPLATE/designer-onboarding.md +++ b/.github/ISSUE_TEMPLATE/designer-onboarding.md @@ -32,7 +32,7 @@ Welcome to the .gov team! We're excited to have you here. Please follow the step - [ ] 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 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 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. From f0219639644f6e9aa7f118a83ee318cf4d12b257 Mon Sep 17 00:00:00 2001 From: Rebecca Hsieh Date: Thu, 15 Aug 2024 13:29:02 -0700 Subject: [PATCH 41/47] Remove unused import --- src/registrar/registrar_middleware.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/registrar/registrar_middleware.py b/src/registrar/registrar_middleware.py index 98685061b..2af331bc9 100644 --- a/src/registrar/registrar_middleware.py +++ b/src/registrar/registrar_middleware.py @@ -8,7 +8,6 @@ from django.urls import reverse from django.http import HttpResponseRedirect from registrar.models.user import User from waffle.decorators import flag_is_active -from django.utils.deprecation import MiddlewareMixin from registrar.models.utility.generic_helper import replace_url_queryparams From 047b41e25e488e1f74c3966d9880dd943c13cb71 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 15 Aug 2024 14:22:07 -0700 Subject: [PATCH 42/47] Further define ANDI source --- src/registrar/config/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index 1b5b2bee5..72bffdbb4 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -365,7 +365,7 @@ CSP_SCRIPT_SRC_ELEM = [ "https://www.ssa.gov", "https://ajax.googleapis.com", ] -CSP_CONNECT_SRC = ["'self'", "https://www.google-analytics.com/", "https://www.ssa.gov"] +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"] From f9c2a647d4cd6af3d1a1eabb2d3775ad5618d8b4 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 15 Aug 2024 15:33:44 -0600 Subject: [PATCH 43/47] Fix tests --- src/registrar/tests/test_views_request.py | 28 ++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/registrar/tests/test_views_request.py b/src/registrar/tests/test_views_request.py index f2c28b772..6642b6471 100644 --- a/src/registrar/tests/test_views_request.py +++ b/src/registrar/tests/test_views_request.py @@ -2535,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 @@ -2635,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") @@ -2991,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 From 47765927cdbd9037514b470e2fb6121351c098be Mon Sep 17 00:00:00 2001 From: Rebecca Hsieh Date: Fri, 16 Aug 2024 10:29:36 -0700 Subject: [PATCH 44/47] Update to more specific templates from ssagov --- src/registrar/config/settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index 72bffdbb4..73aecad7a 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -357,7 +357,7 @@ CSP_FORM_ACTION = allowed_sources # 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_DEFAULT_SRC = ("'self'",) -CSP_STYLE_SRC = ["'self'", "https://www.ssa.gov"] +CSP_STYLE_SRC = ["'self'", "https://www.ssa.gov/accessibility/andi/andi.css"] CSP_SCRIPT_SRC_ELEM = [ "'self'", "https://www.googletagmanager.com/", @@ -367,7 +367,7 @@ CSP_SCRIPT_SRC_ELEM = [ ] 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"] +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 From 469a4bda879c5440ec11f587e42f5b02dcdc80b7 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Fri, 16 Aug 2024 15:44:37 -0600 Subject: [PATCH 45/47] Added domains and domain request section to suborgs --- src/registrar/admin.py | 23 ++++++++++++ .../django/admin/suborg_change_form.html | 36 +++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 src/registrar/templates/django/admin/suborg_change_form.html diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 423c0a01b..b3ce63601 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -9,6 +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_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 @@ -3142,12 +3143,34 @@ 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).exclude( + # Q(status=DomainRequest.DomainRequestStatus.STARTED) | Q(status=DomainRequest.DomainRequestStatus.WITHDRAWN) + # ) + 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/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 %} From b83cc5a980bea05559a52fc20447567053bdf820 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Fri, 16 Aug 2024 15:45:01 -0600 Subject: [PATCH 46/47] removed commented out code --- src/registrar/admin.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index b3ce63601..bb3c09ae9 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -3156,9 +3156,6 @@ class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin): obj = self.get_object(request, object_id) # ---- Domain Requests - # domain_requests = DomainRequest.objects.filter(sub_organization=obj).exclude( - # Q(status=DomainRequest.DomainRequestStatus.STARTED) | Q(status=DomainRequest.DomainRequestStatus.WITHDRAWN) - # ) 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) From efc78361975ef22d90144f0ab3c7f007470641a5 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Fri, 16 Aug 2024 16:06:37 -0600 Subject: [PATCH 47/47] linted --- src/registrar/admin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index bb3c09ae9..3ad5e3ea0 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -3151,6 +3151,7 @@ class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin): 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)