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