From 5d2fec86afed8bb4b11eff1bfac4f618b47e6190 Mon Sep 17 00:00:00 2001
From: zandercymatics <141044360+zandercymatics@users.noreply.github.com>
Date: Fri, 20 Sep 2024 14:32:12 -0600
Subject: [PATCH 01/55] Expose portfolios to analysts
---
src/registrar/admin.py | 54 -------------------
.../migrations/0128_create_groups_v17.py | 37 +++++++++++++
src/registrar/models/user_group.py | 15 ++++++
3 files changed, 52 insertions(+), 54 deletions(-)
create mode 100644 src/registrar/migrations/0128_create_groups_v17.py
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 1c8551d4e..5da46f110 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -1543,33 +1543,6 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
change_form_template = "django/admin/domain_information_change_form.html"
- superuser_only_fields = [
- "portfolio",
- "sub_organization",
- ]
-
- # DEVELOPER's NOTE:
- # Normally, to exclude a field from an Admin form, we could simply utilize
- # Django's "exclude" feature. However, it causes a "missing key" error if we
- # go that route for this particular form. The error gets thrown by our
- # custom fieldset.html code and is due to the fact that "exclude" removes
- # fields from base_fields but not fieldsets. Rather than reworking our
- # custom frontend, it seems more straightforward (and easier to read) to simply
- # modify the fieldsets list so that it excludes any fields we want to remove
- # based on permissions (eg. superuser_only_fields) or other conditions.
- def get_fieldsets(self, request, obj=None):
- fieldsets = self.fieldsets
-
- # Create a modified version of fieldsets to exclude certain fields
- if not request.user.has_perm("registrar.full_access_permission"):
- modified_fieldsets = []
- for name, data in fieldsets:
- fields = data.get("fields", [])
- fields = tuple(field for field in fields if field not in DomainInformationAdmin.superuser_only_fields)
- modified_fieldsets.append((name, {**data, "fields": fields}))
- return modified_fieldsets
- return fieldsets
-
def get_readonly_fields(self, request, obj=None):
"""Set the read-only state on form elements.
We have 1 conditions that determine which fields are read-only:
@@ -1865,33 +1838,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
]
filter_horizontal = ("current_websites", "alternative_domains", "other_contacts")
- superuser_only_fields = [
- "portfolio",
- "sub_organization",
- ]
-
- # DEVELOPER's NOTE:
- # Normally, to exclude a field from an Admin form, we could simply utilize
- # Django's "exclude" feature. However, it causes a "missing key" error if we
- # go that route for this particular form. The error gets thrown by our
- # custom fieldset.html code and is due to the fact that "exclude" removes
- # fields from base_fields but not fieldsets. Rather than reworking our
- # custom frontend, it seems more straightforward (and easier to read) to simply
- # modify the fieldsets list so that it excludes any fields we want to remove
- # based on permissions (eg. superuser_only_fields) or other conditions.
- def get_fieldsets(self, request, obj=None):
- fieldsets = super().get_fieldsets(request, obj)
-
- # Create a modified version of fieldsets to exclude certain fields
- if not request.user.has_perm("registrar.full_access_permission"):
- modified_fieldsets = []
- for name, data in fieldsets:
- fields = data.get("fields", [])
- fields = tuple(field for field in fields if field not in self.superuser_only_fields)
- modified_fieldsets.append((name, {**data, "fields": fields}))
- return modified_fieldsets
- return fieldsets
-
# Table ordering
# NOTE: This impacts the select2 dropdowns (combobox)
# Currentl, there's only one for requests on DomainInfo
diff --git a/src/registrar/migrations/0128_create_groups_v17.py b/src/registrar/migrations/0128_create_groups_v17.py
new file mode 100644
index 000000000..7ac392da7
--- /dev/null
+++ b/src/registrar/migrations/0128_create_groups_v17.py
@@ -0,0 +1,37 @@
+# This migration creates the create_full_access_group and create_cisa_analyst_group groups
+# It is dependent on 0079 (which populates federal agencies)
+# If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS
+# in the user_group model then:
+# [NOT RECOMMENDED]
+# step 1: docker-compose exec app ./manage.py migrate --fake registrar 0035_contenttypes_permissions
+# step 2: docker-compose exec app ./manage.py migrate registrar 0036_create_groups
+# step 3: fake run the latest migration in the migrations list
+# [RECOMMENDED]
+# Alternatively:
+# step 1: duplicate the migration that loads data
+# step 2: docker-compose exec app ./manage.py migrate
+
+from django.db import migrations
+from registrar.models import UserGroup
+from typing import Any
+
+
+# For linting: RunPython expects a function reference,
+# so let's give it one
+def create_groups(apps, schema_editor) -> Any:
+ UserGroup.create_cisa_analyst_group(apps, schema_editor)
+ UserGroup.create_full_access_group(apps, schema_editor)
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("registrar", "0127_remove_domaininformation_submitter_and_more"),
+ ]
+
+ operations = [
+ migrations.RunPython(
+ create_groups,
+ reverse_code=migrations.RunPython.noop,
+ atomic=True,
+ ),
+ ]
diff --git a/src/registrar/models/user_group.py b/src/registrar/models/user_group.py
index 76657fe29..e6bcaa4f4 100644
--- a/src/registrar/models/user_group.py
+++ b/src/registrar/models/user_group.py
@@ -66,6 +66,21 @@ class UserGroup(Group):
"model": "federalagency",
"permissions": ["add_federalagency", "change_federalagency", "delete_federalagency"],
},
+ {
+ "app_label": "registrar",
+ "model": "portfolio",
+ "permissions": ["add_portfolio", "change_portfolio", "delete_portfolio"],
+ },
+ {
+ "app_label": "registrar",
+ "model": "suborganization",
+ "permissions": ["add_suborganization", "change_suborganization", "delete_suborganization"],
+ },
+ {
+ "app_label": "registrar",
+ "model": "userportfoliopermission",
+ "permissions": ["add_userportfoliopermission", "change_userportfoliopermission", "delete_userportfoliopermission"],
+ },
]
# Avoid error: You can't execute queries until the end
From 887af431b82e297f068c72ce45eb00c5d5bab6ad Mon Sep 17 00:00:00 2001
From: zandercymatics <141044360+zandercymatics@users.noreply.github.com>
Date: Mon, 23 Sep 2024 09:56:17 -0600
Subject: [PATCH 02/55] review changes: minor tweaks
---
src/registrar/admin.py | 99 ++++++++++++++-----
src/registrar/assets/js/get-gov-admin.js | 13 ++-
.../migrations/0129_portfolio_federal_type.py | 24 +++++
src/registrar/models/portfolio.py | 22 +++--
.../admin/includes/detail_table_fieldset.html | 17 ----
.../admin/includes/portfolio_fieldset.html | 35 +++++++
.../django/admin/portfolio_change_form.html | 2 +-
src/registrar/views/utility/api_views.py | 2 +-
8 files changed, 156 insertions(+), 58 deletions(-)
create mode 100644 src/registrar/migrations/0129_portfolio_federal_type.py
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 5da46f110..74126ab46 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -2894,6 +2894,15 @@ class VerifiedByStaffAdmin(ListHeaderAdmin):
class PortfolioAdmin(ListHeaderAdmin):
+
+ class Meta:
+ """Contains meta information about this class"""
+
+ model = models.Portfolio
+ fields = "__all__"
+
+ _meta = Meta()
+
change_form_template = "django/admin/portfolio_change_form.html"
fieldsets = [
# created_on is the created_at field, and portfolio_type is f"{organization_type} - {federal_type}"
@@ -2940,16 +2949,12 @@ class PortfolioAdmin(ListHeaderAdmin):
("Senior official", {"fields": ["senior_official"]}),
]
- list_display = ("organization_name", "federal_agency", "creator")
+ list_display = ("organization_name", "organization_type", "federal_type", "creator")
search_fields = ["organization_name"]
- search_help_text = "Search by organization name."
+ search_help_text = "Search by portfolio organization."
readonly_fields = [
# This is the created_at field
"created_on",
- # Django admin doesn't allow methods to be directly listed in fieldsets. We can
- # display the custom methods display_admins amd display_members in the admin form if
- # they are readonly.
- "federal_type",
"domains",
"domain_requests",
"suborganizations",
@@ -2959,16 +2964,47 @@ class PortfolioAdmin(ListHeaderAdmin):
"creator",
]
+ analyst_readonly_fields = [
+ "organization_name",
+ "organization_type",
+ ]
+
+ 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 get_admin_users(self, obj):
# Filter UserPortfolioPermission objects related to the portfolio
- admin_permissions = UserPortfolioPermission.objects.filter(
- portfolio=obj, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
- )
+ admin_permissions = self.get_user_portfolio_permission_admins(obj)
# Get the user objects associated with these permissions
admin_users = User.objects.filter(portfolio_permissions__in=admin_permissions)
return admin_users
+
+ def get_user_portfolio_permission_admins(self, obj):
+ """Returns each admin on UserPortfolioPermission for a given portfolio."""
+ return obj.portfolio_users.filter(
+ portfolio=obj, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
+ )
def get_non_admin_users(self, obj):
# Filter UserPortfolioPermission objects related to the portfolio that do NOT have the "Admin" role
@@ -2980,6 +3016,13 @@ class PortfolioAdmin(ListHeaderAdmin):
non_admin_users = User.objects.filter(portfolio_permissions__in=non_admin_permissions)
return non_admin_users
+
+ def get_user_portfolio_permission_non_admins(self, obj):
+ """Returns each admin on UserPortfolioPermission for a given portfolio."""
+ return obj.portfolio_users.exclude(
+ roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
+ )
+
def display_admins(self, obj):
"""Get joined users who are Admin, unpack and return an HTML block.
@@ -2989,19 +3032,23 @@ class PortfolioAdmin(ListHeaderAdmin):
data would display in a custom change form without extensive template customization.
Will be used in the field_readonly block"""
- admins = self.get_admin_users(obj)
+ admins = self.get_user_portfolio_permission_admins(obj)
if not admins:
return format_html("
"
- admin_details += f"{escape(portfolio_admin.phone)}"
+ admin_details += f"{escape(portfolio_admin.user.phone)}"
admin_details += ""
return format_html(admin_details)
@@ -3026,7 +3073,7 @@ class PortfolioAdmin(ListHeaderAdmin):
data would display in a custom change form without extensive template customization.
Will be used in the after_help_text block."""
- members = self.get_non_admin_users(obj)
+ members = self.get_user_portfolio_permission_non_admins(obj)
if not members:
return ""
@@ -3035,14 +3082,14 @@ class PortfolioAdmin(ListHeaderAdmin):
+ "
Phone
Roles
"
)
for member in members:
- full_name = member.get_formatted_name()
+ full_name = member.user.get_formatted_name()
member_details += "
"
member_details += f"
{escape(full_name)}
"
- member_details += f"
{escape(member.title)}
"
- member_details += f"
{escape(member.email)}
"
- member_details += f"
{escape(member.phone)}
"
+ member_details += f"
{escape(member.user.title)}
"
+ member_details += f"
{escape(member.user.email)}
"
+ member_details += f"
{escape(member.user.phone)}
"
member_details += "
"
- for role in member.portfolio_role_summary(obj):
+ for role in member.user.portfolio_role_summary(obj):
member_details += f"{escape(role)} "
member_details += "
"
member_details += ""
@@ -3052,11 +3099,11 @@ class PortfolioAdmin(ListHeaderAdmin):
def display_members_summary(self, obj):
"""Will be passed as context and used in the field_readonly block."""
- members = self.get_non_admin_users(obj)
+ members = self.get_user_portfolio_permission_non_admins(obj)
if not members:
return {}
- return self.get_field_links_as_list(members, "user", separator=", ")
+ return self.get_field_links_as_list(members, "userportfoliopermission", attribute_name="user", separator=", ")
def federal_type(self, obj: models.Portfolio):
"""Returns the federal_type field"""
diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js
index 7ff02ba1f..f621d5b07 100644
--- a/src/registrar/assets/js/get-gov-admin.js
+++ b/src/registrar/assets/js/get-gov-admin.js
@@ -858,10 +858,11 @@ function initializeWidgetOnList(list, parentId) {
// $ symbolically denotes that this is using jQuery
let $federalAgency = django.jQuery("#id_federal_agency");
let organizationType = document.getElementById("id_organization_type");
- if ($federalAgency && organizationType) {
+ let federalType = document.getElementById("id_federal_type")
+ if ($federalAgency && organizationType && federalType) {
// Attach the change event listener
$federalAgency.on("change", function() {
- handleFederalAgencyChange($federalAgency, organizationType);
+ handleFederalAgencyChange($federalAgency, organizationType, federalType);
});
}
@@ -879,7 +880,7 @@ function initializeWidgetOnList(list, parentId) {
}
});
- function handleFederalAgencyChange(federalAgency, organizationType) {
+ function handleFederalAgencyChange(federalAgency, organizationType, federalType) {
// Don't do anything on page load
if (isInitialPageLoad) {
isInitialPageLoad = false;
@@ -924,7 +925,11 @@ function initializeWidgetOnList(list, parentId) {
console.error("Error in AJAX call: " + data.error);
return;
}
- updateReadOnly(data.federal_type, '.field-federal_type');
+ if (data.federal_type && selectedText !== "Non-Federal Agency") {
+ federalType.value = data.federal_type.toLowerCase();
+ }else {
+ federalType.value = "";
+ }
updateReadOnly(data.portfolio_type, '.field-portfolio_type');
})
.catch(error => console.error("Error fetching federal and portfolio types: ", error));
diff --git a/src/registrar/migrations/0129_portfolio_federal_type.py b/src/registrar/migrations/0129_portfolio_federal_type.py
new file mode 100644
index 000000000..79548f314
--- /dev/null
+++ b/src/registrar/migrations/0129_portfolio_federal_type.py
@@ -0,0 +1,24 @@
+# Generated by Django 4.2.10 on 2024-09-23 15:29
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("registrar", "0128_create_groups_v17"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="portfolio",
+ name="federal_type",
+ field=models.CharField(
+ blank=True,
+ choices=[("executive", "Executive"), ("judicial", "Judicial"), ("legislative", "Legislative")],
+ help_text="Federal agency type (executive, judicial, legislative, etc.)",
+ max_length=20,
+ null=True,
+ ),
+ ),
+ ]
diff --git a/src/registrar/models/portfolio.py b/src/registrar/models/portfolio.py
index fadcf8cac..2cef1446f 100644
--- a/src/registrar/models/portfolio.py
+++ b/src/registrar/models/portfolio.py
@@ -58,6 +58,14 @@ class Portfolio(TimeStampedModel):
default=FederalAgency.get_non_federal_agency,
)
+ federal_type = models.CharField(
+ max_length=20,
+ choices=BranchChoices.choices,
+ null=True,
+ blank=True,
+ help_text="Federal agency type (executive, judicial, legislative, etc.)",
+ )
+
senior_official = models.ForeignKey(
"registrar.SeniorOfficial",
on_delete=models.PROTECT,
@@ -123,8 +131,13 @@ class Portfolio(TimeStampedModel):
if self.state_territory != self.StateTerritoryChoices.PUERTO_RICO and self.urbanization:
self.urbanization = None
+ # Set the federal type field if it doesn't exist already
+ if self.federal_type is None and self.federal_agency and self.federal_agency.federal_type:
+ self.federal_type = self.federal_agency.federal_type if self.federal_agency else None
+
super().save(*args, **kwargs)
+
@property
def portfolio_type(self):
"""
@@ -142,15 +155,6 @@ class Portfolio(TimeStampedModel):
else:
return org_type_label
- @property
- def federal_type(self):
- """Returns the federal_type value on the underlying federal_agency field"""
- return self.get_federal_type(self.federal_agency)
-
- @classmethod
- def get_federal_type(cls, federal_agency):
- return federal_agency.federal_type if federal_agency else None
-
# == Getters for domains == #
def get_domains(self):
"""Returns all DomainInformations associated with this portfolio"""
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 e22bcb571..e03203f4b 100644
--- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
+++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
@@ -137,16 +137,6 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% endfor %}
{% endwith %}
- {% elif field.field.name == "display_admins" %}
-
+
\ No newline at end of file
diff --git a/src/registrar/templates/django/admin/includes/portfolio_fieldset.html b/src/registrar/templates/django/admin/includes/portfolio_fieldset.html
index 2299a579a..c63964d80 100644
--- a/src/registrar/templates/django/admin/includes/portfolio_fieldset.html
+++ b/src/registrar/templates/django/admin/includes/portfolio_fieldset.html
@@ -21,6 +21,15 @@
- {% include "django/admin/includes/contact_detail_list.html" with user=original_object.senior_official no_title_top_padding=field.is_readonly %}
+ {% include "django/admin/includes/contact_detail_list.html" with user=original_object.senior_official no_title_top_padding=field.is_readonly hide_no_contact_info_message=True %}
{% elif field.field.name == "display_admins" %}
{% if admins|length > 0 %}
@@ -40,5 +49,13 @@
{% if members|length > 0 %}
{% include "django/admin/includes/portfolio_members_table.html" with members=members %}
{% endif %}
+ {% elif field.field.name == "domains" %}
+ {% if domains|length > 0 %}
+ {% include "django/admin/includes/portfolio_domains_table.html" with domains=domains %}
+ {% endif %}
+ {% elif field.field.name == "domain_requests" %}
+ {% if domain_requests|length > 0 %}
+ {% include "django/admin/includes/portfolio_domain_requests_table.html" with domain_requests=domain_requests %}
+ {% endif %}
{% endif %}
{% endblock after_help_text %}
\ No newline at end of file
From 75b34dab3b7ee1e333384dcb274772dda44d425d Mon Sep 17 00:00:00 2001
From: zandercymatics <141044360+zandercymatics@users.noreply.github.com>
Date: Thu, 26 Sep 2024 10:55:20 -0600
Subject: [PATCH 19/55] Cleanup
---
src/registrar/admin.py | 9 ---
.../models/user_portfolio_permission.py | 14 -----
src/registrar/registrar_middleware.py | 2 +
.../django/admin/includes/details_button.html | 9 +++
.../portfolio/portfolio_admins_table.html | 48 ++++++++++++++++
.../portfolio_domain_requests_table.html | 26 +++++++++
.../portfolio/portfolio_domains_table.html | 30 ++++++++++
.../{ => portfolio}/portfolio_fieldset.html | 8 +--
.../portfolio/portfolio_members_table.html | 55 ++++++++++++++++++
.../includes/portfolio_admins_table.html | 50 ----------------
.../portfolio_domain_requests_table.html | 28 ---------
.../includes/portfolio_domains_table.html | 32 -----------
.../includes/portfolio_members_table.html | 57 -------------------
.../user_portfolio_permission_fieldset.html | 26 ---------
.../django/admin/portfolio_change_form.html | 2 +-
15 files changed, 175 insertions(+), 221 deletions(-)
create mode 100644 src/registrar/templates/django/admin/includes/details_button.html
create mode 100644 src/registrar/templates/django/admin/includes/portfolio/portfolio_admins_table.html
create mode 100644 src/registrar/templates/django/admin/includes/portfolio/portfolio_domain_requests_table.html
create mode 100644 src/registrar/templates/django/admin/includes/portfolio/portfolio_domains_table.html
rename src/registrar/templates/django/admin/includes/{ => portfolio}/portfolio_fieldset.html (82%)
create mode 100644 src/registrar/templates/django/admin/includes/portfolio/portfolio_members_table.html
delete mode 100644 src/registrar/templates/django/admin/includes/portfolio_admins_table.html
delete mode 100644 src/registrar/templates/django/admin/includes/portfolio_domain_requests_table.html
delete mode 100644 src/registrar/templates/django/admin/includes/portfolio_domains_table.html
delete mode 100644 src/registrar/templates/django/admin/includes/portfolio_members_table.html
delete mode 100644 src/registrar/templates/django/admin/includes/user_portfolio_permission_fieldset.html
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 2c1bd5a5a..f2e9a65e8 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -1245,7 +1245,6 @@ class UserDomainRoleResource(resources.ModelResource):
class UserPortfolioPermissionAdmin(ListHeaderAdmin):
form = UserPortfolioPermissionsForm
- change_form_template = "django/admin/user_portfolio_permission_change_form.html"
class Meta:
"""Contains meta information about this class"""
@@ -1263,14 +1262,6 @@ class UserPortfolioPermissionAdmin(ListHeaderAdmin):
autocomplete_fields = ["user", "portfolio"]
- def change_view(self, request, object_id, form_url="", extra_context=None):
- """Adds a readonly display for roles and permissions"""
- obj = self.get_object(request, object_id)
- extra_context = extra_context or {}
- extra_context["display_roles"] = ", ".join(obj.get_readable_roles())
- extra_context["display_permissions"] = ", ".join(obj.get_readable_additional_permissions())
- return super().change_view(request, object_id, form_url, extra_context)
-
class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"""Custom user domain role admin class."""
diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py
index 112d93009..5479b6f3d 100644
--- a/src/registrar/models/user_portfolio_permission.py
+++ b/src/registrar/models/user_portfolio_permission.py
@@ -82,20 +82,6 @@ class UserPortfolioPermission(TimeStampedModel):
)
return f"{self.user}" f" " if self.roles else ""
- def get_readable_roles(self):
- """Returns a list of labels of each role in self.roles"""
- role_labels = []
- for role in self.roles:
- role_labels.append(UserPortfolioRoleChoices.get_user_portfolio_role_label(role))
- return role_labels
-
- def get_readable_additional_permissions(self):
- """Returns a list of labels of each additional_permission in self.additional_permissions"""
- perm_labels = []
- for perm in self.additional_permissions:
- perm_labels.append(UserPortfolioPermissionChoices.get_user_portfolio_permission_label(perm))
- return perm_labels
-
def _get_portfolio_permissions(self):
"""
Retrieve the permissions for the user's portfolio roles.
diff --git a/src/registrar/registrar_middleware.py b/src/registrar/registrar_middleware.py
index 6346ed4fd..2ccea9321 100644
--- a/src/registrar/registrar_middleware.py
+++ b/src/registrar/registrar_middleware.py
@@ -49,6 +49,8 @@ class CheckUserProfileMiddleware:
self.setup_page,
self.logout_page,
"/admin",
+ # These are here as there is a bug with this middleware that breaks djangos built in debug console.
+ # The debug console uses this directory, but since this overrides that, it throws errors.
"/__debug__",
]
self.other_excluded_pages = [
diff --git a/src/registrar/templates/django/admin/includes/details_button.html b/src/registrar/templates/django/admin/includes/details_button.html
new file mode 100644
index 000000000..9ae039b04
--- /dev/null
+++ b/src/registrar/templates/django/admin/includes/details_button.html
@@ -0,0 +1,9 @@
+
+{% comment %} This view provides a detail button that can be used to show/hide content {% endcomment %}
+
+ Details
+
{% endif %}
{% else %}
From d80287a428cee71b9283da408156144dedc9ff68 Mon Sep 17 00:00:00 2001
From: zandercymatics <141044360+zandercymatics@users.noreply.github.com>
Date: Thu, 26 Sep 2024 14:37:31 -0600
Subject: [PATCH 25/55] User portfolio suggestions
---
src/registrar/admin.py | 18 +++++++++++++-----
src/registrar/models/portfolio.py | 15 +++++++++++----
.../models/user_portfolio_permission.py | 14 ++++++++++----
src/registrar/tests/test_models.py | 5 ++++-
4 files changed, 38 insertions(+), 14 deletions(-)
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index ea84f3191..ffea1fb24 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -10,8 +10,7 @@ from django.http import HttpResponseRedirect
from django.conf import settings
from django.shortcuts import redirect
from django_fsm import get_available_FIELD_transitions, FSMField
-from registrar.models.domain_information import DomainInformation
-from registrar.models.user_portfolio_permission import UserPortfolioPermission
+from registrar.models import DomainInformation, Portfolio, UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from waffle.decorators import flag_is_active
from django.contrib import admin, messages
@@ -1257,9 +1256,18 @@ class UserPortfolioPermissionAdmin(ListHeaderAdmin):
list_display = [
"user",
"portfolio",
+ "get_roles",
]
autocomplete_fields = ["user", "portfolio"]
+ search_fields = ["user__first_name", "user__last_name", "user__email", "portfolio__organization_name"]
+ search_help_text = "Search by first name, last name, email, or portfolio."
+
+ def get_roles(self, obj):
+ readable_roles = obj.get_readable_roles()
+ return ", ".join(readable_roles)
+
+ get_roles.short_description = "Roles"
class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin):
@@ -3174,14 +3182,14 @@ class PortfolioAdmin(ListHeaderAdmin):
def change_view(self, request, object_id, form_url="", extra_context=None):
"""Add related suborganizations and domain groups.
Add the summary for the portfolio members field (list of members that link to change_forms)."""
- obj = self.get_object(request, object_id)
+ obj: Portfolio = self.get_object(request, object_id)
extra_context = extra_context or {}
extra_context["skip_additional_contact_info"] = True
extra_context["members"] = self.get_user_portfolio_permission_non_admins(obj)
extra_context["admins"] = self.get_user_portfolio_permission_admins(obj)
- extra_context["domains"] = obj.get_domains()
- extra_context["domain_requests"] = obj.get_domain_requests()
+ extra_context["domains"] = obj.get_domains(order_by=["domain_information__name"])
+ extra_context["domain_requests"] = obj.get_domain_requests(order_by=["domain_information__name"])
return super().change_view(request, object_id, form_url, extra_context)
def save_model(self, request, obj, form, change):
diff --git a/src/registrar/models/portfolio.py b/src/registrar/models/portfolio.py
index 9acec8c64..e6f7162d6 100644
--- a/src/registrar/models/portfolio.py
+++ b/src/registrar/models/portfolio.py
@@ -132,13 +132,20 @@ class Portfolio(TimeStampedModel):
return federal_agency.federal_type if federal_agency else None
# == Getters for domains == #
- def get_domains(self):
+ def get_domains(self, order_by=None):
"""Returns all DomainInformations associated with this portfolio"""
- return self.information_portfolio.all()
+ if not order_by:
+ return self.information_portfolio.all()
+ else:
+ return self.information_portfolio.all().order_by(*order_by)
- def get_domain_requests(self):
+ def get_domain_requests(self, order_by=None):
"""Returns all DomainRequests associated with this portfolio"""
- return self.DomainRequest_portfolio.all()
+ if not order_by:
+ return self.DomainRequest_portfolio.all()
+ else:
+ return self.DomainRequest_portfolio.all().order_by(*order_by)
+
# == Getters for suborganization == #
def get_suborganizations(self):
diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py
index 5479b6f3d..4cf85d4fc 100644
--- a/src/registrar/models/user_portfolio_permission.py
+++ b/src/registrar/models/user_portfolio_permission.py
@@ -77,11 +77,16 @@ class UserPortfolioPermission(TimeStampedModel):
def __str__(self):
readable_roles = []
if self.roles:
- readable_roles = sorted(
- [UserPortfolioRoleChoices.get_user_portfolio_role_label(role) for role in self.roles]
- )
+ readable_roles = self.get_readable_roles()
return f"{self.user}" f" " if self.roles else ""
+ def get_readable_roles(self):
+ """Returns a readable list of self.roles"""
+ readable_roles = []
+ if self.roles:
+ readable_roles = sorted([UserPortfolioRoleChoices.get_user_portfolio_role_label(role) for role in self.roles])
+ return readable_roles
+
def _get_portfolio_permissions(self):
"""
Retrieve the permissions for the user's portfolio roles.
@@ -108,7 +113,8 @@ class UserPortfolioPermission(TimeStampedModel):
existing_permissions = UserPortfolioPermission.objects.filter(user=self.user)
if not flag_is_active_for_user(self.user, "multiple_portfolios") and existing_permissions.exists():
raise ValidationError(
- "Only one portfolio permission is allowed per user when multiple portfolios are disabled."
+ "This user is already assigned to a portfolio. "
+ "Based on current waffle flag settings, users cannot be assigned to multiple portfolios."
)
# Check if portfolio is set without accessing the related object.
diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py
index a6cac1389..015c67dab 100644
--- a/src/registrar/tests/test_models.py
+++ b/src/registrar/tests/test_models.py
@@ -1332,7 +1332,10 @@ class TestUserPortfolioPermission(TestCase):
self.assertEqual(
cm.exception.message,
- "Only one portfolio permission is allowed per user when multiple portfolios are disabled.",
+ (
+ "This user is already assigned to a portfolio. "
+ "Based on current waffle flag settings, users cannot be assigned to multiple portfolios."
+ ),
)
From 2260269e3d5e444c79b37c1fcf369907cc800541 Mon Sep 17 00:00:00 2001
From: zandercymatics <141044360+zandercymatics@users.noreply.github.com>
Date: Thu, 26 Sep 2024 14:39:21 -0600
Subject: [PATCH 26/55] fix order by
---
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 ffea1fb24..de1b96e6c 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -3188,8 +3188,8 @@ class PortfolioAdmin(ListHeaderAdmin):
extra_context["members"] = self.get_user_portfolio_permission_non_admins(obj)
extra_context["admins"] = self.get_user_portfolio_permission_admins(obj)
- extra_context["domains"] = obj.get_domains(order_by=["domain_information__name"])
- extra_context["domain_requests"] = obj.get_domain_requests(order_by=["domain_information__name"])
+ extra_context["domains"] = obj.get_domains(order_by=["domain__name"])
+ extra_context["domain_requests"] = obj.get_domain_requests(order_by=["requested_domain__name"])
return super().change_view(request, object_id, form_url, extra_context)
def save_model(self, request, obj, form, change):
From 81e76fe04a74800826308b68c2f378edc69240f9 Mon Sep 17 00:00:00 2001
From: zandercymatics <141044360+zandercymatics@users.noreply.github.com>
Date: Thu, 26 Sep 2024 14:49:19 -0600
Subject: [PATCH 27/55] fix fed agency bug
---
src/registrar/assets/js/get-gov-admin.js | 11 +++++++----
1 file changed, 7 insertions(+), 4 deletions(-)
diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js
index d98d11437..c01f5d784 100644
--- a/src/registrar/assets/js/get-gov-admin.js
+++ b/src/registrar/assets/js/get-gov-admin.js
@@ -860,10 +860,13 @@ function initializeWidgetOnList(list, parentId) {
let organizationType = document.getElementById("id_organization_type");
let readonlyOrganizationType = document.querySelector(".field-organization_type .readonly");
+ let organizationNameContainer = document.querySelector(".field-organization_name");
+ let federalType = document.querySelector(".field-federal_type");
+
if ($federalAgency && (organizationType || readonlyOrganizationType)) {
// Attach the change event listener
$federalAgency.on("change", function() {
- handleFederalAgencyChange($federalAgency, organizationType, readonlyOrganizationType);
+ handleFederalAgencyChange($federalAgency, organizationType, readonlyOrganizationType, organizationNameContainer, federalType);
});
}
@@ -882,8 +885,6 @@ function initializeWidgetOnList(list, parentId) {
// Handle hiding the organization name field when the organization_type is federal.
// Run this first one page load, then secondly on a change event.
- let organizationNameContainer = document.querySelector(".field-organization_name");
- let federalType = document.querySelector(".field-federal_type");
handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType);
organizationType.addEventListener("change", function() {
handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType);
@@ -907,7 +908,7 @@ function initializeWidgetOnList(list, parentId) {
}
}
- function handleFederalAgencyChange(federalAgency, organizationType, readonlyOrganizationType) {
+ function handleFederalAgencyChange(federalAgency, organizationType, readonlyOrganizationType, organizationNameContainer, federalType) {
// Don't do anything on page load
if (isInitialPageLoad) {
isInitialPageLoad = false;
@@ -941,6 +942,8 @@ function initializeWidgetOnList(list, parentId) {
}
}
+ handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType);
+
// Determine if any changes are necessary to the display of portfolio type or federal type
// based on changes to the Federal Agency
let federalPortfolioApi = document.getElementById("federal_and_portfolio_types_from_agency_json_url").value;
From 88b38baaa7e96b37a50406c057587d1c9eea5dbb Mon Sep 17 00:00:00 2001
From: zandercymatics <141044360+zandercymatics@users.noreply.github.com>
Date: Thu, 26 Sep 2024 14:50:08 -0600
Subject: [PATCH 28/55] lint
---
src/registrar/models/portfolio.py | 1 -
src/registrar/models/user_portfolio_permission.py | 4 +++-
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/src/registrar/models/portfolio.py b/src/registrar/models/portfolio.py
index e6f7162d6..8d820e105 100644
--- a/src/registrar/models/portfolio.py
+++ b/src/registrar/models/portfolio.py
@@ -145,7 +145,6 @@ class Portfolio(TimeStampedModel):
return self.DomainRequest_portfolio.all()
else:
return self.DomainRequest_portfolio.all().order_by(*order_by)
-
# == Getters for suborganization == #
def get_suborganizations(self):
diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py
index 4cf85d4fc..f021fc6bf 100644
--- a/src/registrar/models/user_portfolio_permission.py
+++ b/src/registrar/models/user_portfolio_permission.py
@@ -84,7 +84,9 @@ class UserPortfolioPermission(TimeStampedModel):
"""Returns a readable list of self.roles"""
readable_roles = []
if self.roles:
- readable_roles = sorted([UserPortfolioRoleChoices.get_user_portfolio_role_label(role) for role in self.roles])
+ readable_roles = sorted(
+ [UserPortfolioRoleChoices.get_user_portfolio_role_label(role) for role in self.roles]
+ )
return readable_roles
def _get_portfolio_permissions(self):
From a7b3fc71a5d251b4f24486f095107ef9aa897c75 Mon Sep 17 00:00:00 2001
From: zandercymatics <141044360+zandercymatics@users.noreply.github.com>
Date: Thu, 26 Sep 2024 14:57:04 -0600
Subject: [PATCH 29/55] Update 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 de1b96e6c..8406ac3a6 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -1267,7 +1267,7 @@ class UserPortfolioPermissionAdmin(ListHeaderAdmin):
readable_roles = obj.get_readable_roles()
return ", ".join(readable_roles)
- get_roles.short_description = "Roles"
+ get_roles.short_description = "Roles" # type: ignore
class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin):
From a128ebcf6937a652143f5f040239d5401ee4b550 Mon Sep 17 00:00:00 2001
From: zandercymatics <141044360+zandercymatics@users.noreply.github.com>
Date: Fri, 27 Sep 2024 12:46:21 -0600
Subject: [PATCH 30/55] Update settings.py
---
src/registrar/config/settings.py | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py
index 9eb649ad8..30882cd5d 100644
--- a/src/registrar/config/settings.py
+++ b/src/registrar/config/settings.py
@@ -476,6 +476,8 @@ class JsonServerFormatter(ServerFormatter):
def format(self, record):
formatted_record = super().format(record)
+ if not hasattr(record, "server_time"):
+ record.server_time = self.formatTime(record, self.datefmt)
log_entry = {"server_time": record.server_time, "level": record.levelname, "message": formatted_record}
return json.dumps(log_entry)
From 14a1bd53ba67b8b65d8879bb0a028b110d4b1bee Mon Sep 17 00:00:00 2001
From: zandercymatics <141044360+zandercymatics@users.noreply.github.com>
Date: Fri, 27 Sep 2024 13:19:30 -0600
Subject: [PATCH 31/55] Fix url bug
---
src/registrar/admin.py | 16 ++++++++++------
src/registrar/assets/js/get-gov-admin.js | 3 ++-
.../django/admin/portfolio_change_form.html | 2 ++
3 files changed, 14 insertions(+), 7 deletions(-)
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 8406ac3a6..56f5310e0 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -3186,10 +3186,11 @@ class PortfolioAdmin(ListHeaderAdmin):
extra_context = extra_context or {}
extra_context["skip_additional_contact_info"] = True
- extra_context["members"] = self.get_user_portfolio_permission_non_admins(obj)
- extra_context["admins"] = self.get_user_portfolio_permission_admins(obj)
- extra_context["domains"] = obj.get_domains(order_by=["domain__name"])
- extra_context["domain_requests"] = obj.get_domain_requests(order_by=["requested_domain__name"])
+ if obj:
+ extra_context["members"] = self.get_user_portfolio_permission_non_admins(obj)
+ extra_context["admins"] = self.get_user_portfolio_permission_admins(obj)
+ extra_context["domains"] = obj.get_domains(order_by=["domain__name"])
+ extra_context["domain_requests"] = obj.get_domain_requests(order_by=["requested_domain__name"])
return super().change_view(request, object_id, form_url, extra_context)
def save_model(self, request, obj, form, change):
@@ -3208,8 +3209,11 @@ class PortfolioAdmin(ListHeaderAdmin):
obj.organization_name = obj.federal_agency.agency
# Remove this line when senior_official is no longer readonly in /admin.
- if obj.federal_agency and obj.federal_agency.so_federal_agency.exists():
- obj.senior_official = obj.federal_agency.so_federal_agency.first()
+ if obj.federal_agency:
+ if obj.federal_agency.so_federal_agency.exists():
+ obj.senior_official = obj.federal_agency.so_federal_agency.first()
+ else:
+ obj.senior_official = None
super().save_model(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 c01f5d784..25e35b73b 100644
--- a/src/registrar/assets/js/get-gov-admin.js
+++ b/src/registrar/assets/js/get-gov-admin.js
@@ -965,6 +965,7 @@ function initializeWidgetOnList(list, parentId) {
// If we can update the contact information, it'll be shown again.
hideElement(contactList.parentElement);
+ let seniorOfficialAddUrl = document.getElementById("senior-official-add-url").value;
let $seniorOfficial = django.jQuery("#id_senior_official");
let readonlySeniorOfficial = document.querySelector(".field-senior_official .readonly");
let seniorOfficialApi = document.getElementById("senior_official_from_agency_json_url").value;
@@ -981,7 +982,7 @@ function initializeWidgetOnList(list, parentId) {
$seniorOfficial.val("").trigger("change");
}else {
// Show the "create one now" text if this field is none in readonly mode.
- readonlySeniorOfficial.innerHTML = 'No senior official found. Create one now.'
+ readonlySeniorOfficial.innerHTML = `No senior official found. Create one now.`;
}
console.warn("Record not found: " + data.error);
}else {
diff --git a/src/registrar/templates/django/admin/portfolio_change_form.html b/src/registrar/templates/django/admin/portfolio_change_form.html
index fec1538d9..8de6cd5eb 100644
--- a/src/registrar/templates/django/admin/portfolio_change_form.html
+++ b/src/registrar/templates/django/admin/portfolio_change_form.html
@@ -8,6 +8,8 @@
{% url 'get-federal-and-portfolio-types-from-federal-agency-json' as url %}
+ {% url "admin:registrar_seniorofficial_add" as url %}
+
{{ block.super }}
{% endblock content %}
From 9a7a65d958af3a3278b06ad1e2e01a7a425d2011 Mon Sep 17 00:00:00 2001
From: zandercymatics <141044360+zandercymatics@users.noreply.github.com>
Date: Fri, 27 Sep 2024 13:23:31 -0600
Subject: [PATCH 32/55] fix merge conflict
---
.../templates/django/admin/includes/detail_table_fieldset.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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 f6ada5166..6b755724e 100644
--- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
+++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
@@ -119,7 +119,7 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% endfor %}
{% endwith %}
- {% elif field.field.name == "display_admins" or field.field.name == "domain_managers" or field.field.namd == "invited_domain_managers" %}
+ {% elif field.field.name == "domain_managers" or field.field.name == "invited_domain_managers" %}
{{ field.contents|safe }}
{% elif field.field.name == "display_members" %}
From 3290137833bc683e3c68607080ddd470fdecc1d9 Mon Sep 17 00:00:00 2001
From: zandercymatics <141044360+zandercymatics@users.noreply.github.com>
Date: Fri, 27 Sep 2024 14:20:03 -0600
Subject: [PATCH 33/55] Federal agency changes
---
src/registrar/admin.py | 2 +-
...pulate_federal_agency_initials_and_fceb.py | 2 +-
...nitials_federalagency_acronym_and_more.py} | 33 ++++++++++++++++++-
src/registrar/models/federal_agency.py | 7 ++--
.../tests/test_management_scripts.py | 10 +++---
5 files changed, 42 insertions(+), 12 deletions(-)
rename src/registrar/migrations/{0130_alter_portfolio_federal_agency_and_more.py => 0130_remove_federalagency_initials_federalagency_acronym_and_more.py} (62%)
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 2e81d1d4b..c93159c6b 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -3291,7 +3291,7 @@ class FederalAgencyResource(resources.ModelResource):
class FederalAgencyAdmin(ListHeaderAdmin, ImportExportModelAdmin):
list_display = ["agency"]
search_fields = ["agency"]
- search_help_text = "Search by agency name."
+ search_help_text = "Search by federal agency."
ordering = ["agency"]
resource_classes = [FederalAgencyResource]
diff --git a/src/registrar/management/commands/populate_federal_agency_initials_and_fceb.py b/src/registrar/management/commands/populate_federal_agency_initials_and_fceb.py
index 506405b78..30ae08b47 100644
--- a/src/registrar/management/commands/populate_federal_agency_initials_and_fceb.py
+++ b/src/registrar/management/commands/populate_federal_agency_initials_and_fceb.py
@@ -42,7 +42,7 @@ class Command(BaseCommand, PopulateScriptTemplate):
"""For each record, update the initials and is_fceb field if data exists for it"""
initials, agency_status = self.federal_agency_dict.get(record.agency)
- record.initials = initials
+ record.acronym = initials
if agency_status and isinstance(agency_status, str) and agency_status.strip().upper() == "FCEB":
record.is_fceb = True
else:
diff --git a/src/registrar/migrations/0130_alter_portfolio_federal_agency_and_more.py b/src/registrar/migrations/0130_remove_federalagency_initials_federalagency_acronym_and_more.py
similarity index 62%
rename from src/registrar/migrations/0130_alter_portfolio_federal_agency_and_more.py
rename to src/registrar/migrations/0130_remove_federalagency_initials_federalagency_acronym_and_more.py
index f3372b405..665b2115f 100644
--- a/src/registrar/migrations/0130_alter_portfolio_federal_agency_and_more.py
+++ b/src/registrar/migrations/0130_remove_federalagency_initials_federalagency_acronym_and_more.py
@@ -1,4 +1,4 @@
-# Generated by Django 4.2.10 on 2024-09-26 15:09
+# Generated by Django 4.2.10 on 2024-09-27 20:12
from django.db import migrations, models
import django.db.models.deletion
@@ -12,6 +12,37 @@ class Migration(migrations.Migration):
]
operations = [
+ migrations.RemoveField(
+ model_name="federalagency",
+ name="initials",
+ ),
+ migrations.AddField(
+ model_name="federalagency",
+ name="acronym",
+ field=models.CharField(
+ blank=True,
+ help_text="Acronym commonly used to reference the federal agency (Optional)",
+ max_length=10,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="federalagency",
+ name="federal_type",
+ field=models.CharField(
+ blank=True,
+ choices=[("executive", "Executive"), ("judicial", "Judicial"), ("legislative", "Legislative")],
+ max_length=20,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="federalagency",
+ name="is_fceb",
+ field=models.BooleanField(
+ blank=True, help_text="Federal Civilian Executive Branch (FCEB)", null=True, verbose_name="FCEB"
+ ),
+ ),
migrations.AlterField(
model_name="portfolio",
name="federal_agency",
diff --git a/src/registrar/models/federal_agency.py b/src/registrar/models/federal_agency.py
index 5cc87b38c..aeeebac8c 100644
--- a/src/registrar/models/federal_agency.py
+++ b/src/registrar/models/federal_agency.py
@@ -22,21 +22,20 @@ class FederalAgency(TimeStampedModel):
choices=BranchChoices.choices,
null=True,
blank=True,
- help_text="Federal agency type (executive, judicial, legislative, etc.)",
)
- initials = models.CharField(
+ acronym = models.CharField(
max_length=10,
null=True,
blank=True,
- help_text="Agency initials",
+ help_text="Acronym commonly used to reference the federal agency (Optional)",
)
is_fceb = models.BooleanField(
null=True,
blank=True,
verbose_name="FCEB",
- help_text="Determines if this agency is FCEB",
+ help_text="Federal Civilian Executive Branch (FCEB)",
)
def __str__(self) -> str:
diff --git a/src/registrar/tests/test_management_scripts.py b/src/registrar/tests/test_management_scripts.py
index cbdc2c034..9fcd261f7 100644
--- a/src/registrar/tests/test_management_scripts.py
+++ b/src/registrar/tests/test_management_scripts.py
@@ -1387,18 +1387,18 @@ class TestPopulateFederalAgencyInitialsAndFceb(TestCase):
self.agency4.refresh_from_db()
# Check if FederalAgency objects were updated correctly
- self.assertEqual(self.agency1.initials, "ABMC")
+ self.assertEqual(self.agency1.acronym, "ABMC")
self.assertTrue(self.agency1.is_fceb)
- self.assertEqual(self.agency2.initials, "ACHP")
+ self.assertEqual(self.agency2.acronym, "ACHP")
self.assertTrue(self.agency2.is_fceb)
# We expect that this field doesn't have any data,
# as none is specified in the CSV
- self.assertIsNone(self.agency3.initials)
+ self.assertIsNone(self.agency3.acronym)
self.assertIsNone(self.agency3.is_fceb)
- self.assertEqual(self.agency4.initials, "KC")
+ self.assertEqual(self.agency4.acronym, "KC")
self.assertFalse(self.agency4.is_fceb)
@less_console_noise_decorator
@@ -1411,7 +1411,7 @@ class TestPopulateFederalAgencyInitialsAndFceb(TestCase):
# Verify that the missing agency was not updated
missing_agency.refresh_from_db()
- self.assertIsNone(missing_agency.initials)
+ self.assertIsNone(missing_agency.acronym)
self.assertIsNone(missing_agency.is_fceb)
From fbd82d5e6f2d6b5c29daa5fb3a0b83bd0f2d9619 Mon Sep 17 00:00:00 2001
From: zandercymatics <141044360+zandercymatics@users.noreply.github.com>
Date: Mon, 30 Sep 2024 08:22:03 -0600
Subject: [PATCH 34/55] suborg changes + portfolio on user
---
src/registrar/admin.py | 70 ++++---------------
...initials_federalagency_acronym_and_more.py | 7 +-
src/registrar/models/suborganization.py | 2 +-
.../models/utility/generic_helper.py | 8 +++
.../django/admin/suborg_change_form.html | 40 ++++++-----
.../django/admin/user_change_form.html | 20 ------
src/registrar/utility/admin_helpers.py | 51 ++++++++++++++
7 files changed, 104 insertions(+), 94 deletions(-)
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index c93159c6b..e3a3b2b2d 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -20,7 +20,7 @@ from epplibwrapper.errors import ErrorCode, RegistryError
from registrar.models.user_domain_role import UserDomainRole
from waffle.admin import FlagAdmin
from waffle.models import Sample, Switch
-from registrar.utility.admin_helpers import get_all_action_needed_reason_emails, get_action_needed_reason_default_email
+from registrar.utility.admin_helpers import get_all_action_needed_reason_emails, get_action_needed_reason_default_email, get_field_links_as_list
from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial
from registrar.utility.constants import BranchChoices
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
@@ -755,9 +755,10 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
},
),
("Important dates", {"fields": ("last_login", "date_joined")}),
+ ("Associated portfolios", {"fields": ("portfolios",)}),
)
- readonly_fields = ("verification_type",)
+ readonly_fields = ("verification_type", "portfolios")
analyst_fieldsets = (
(
@@ -780,6 +781,7 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
},
),
("Important dates", {"fields": ("last_login", "date_joined")}),
+ ("Associated portfolios", {"fields": ("portfolios",)}),
)
# TODO: delete after we merge organization feature
@@ -859,6 +861,14 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
ordering = ["first_name", "last_name", "email"]
search_help_text = "Search by first name, last name, or email."
+ def portfolios(self, obj: models.User):
+ """Returns a list of links for each related suborg"""
+ portfolio_ids = obj.get_portfolios().values_list("portfolio", flat=True)
+ queryset = models.Portfolio.objects.filter(id__in=portfolio_ids)
+ return get_field_links_as_list(queryset, "portfolio")
+
+ portfolios.short_description = "Portfolios" # type: ignore
+
def get_search_results(self, request, queryset, search_term):
"""
Override for get_search_results. This affects any upstream model using autocomplete_fields,
@@ -3101,7 +3111,7 @@ class PortfolioAdmin(ListHeaderAdmin):
def suborganizations(self, obj: models.Portfolio):
"""Returns a list of links for each related suborg"""
queryset = obj.get_suborganizations()
- return self.get_field_links_as_list(queryset, "suborganization")
+ return get_field_links_as_list(queryset, "suborganization")
suborganizations.short_description = "Suborganizations" # type: ignore
@@ -3159,59 +3169,6 @@ class PortfolioAdmin(ListHeaderAdmin):
"senior_official",
]
- def get_field_links_as_list(
- self, queryset, model_name, attribute_name=None, link_info_attribute=None, separator=None
- ):
- """
- Generate HTML links for items in a queryset, using a specified attribute for link text.
-
- Args:
- queryset: The queryset of items to generate links for.
- model_name: The model name used to construct the admin change URL.
- attribute_name: The attribute or method name to use for link text. If None, the item itself is used.
- link_info_attribute: Appends f"({value_of_attribute})" to the end of the link.
- separator: The separator to use between links in the resulting HTML.
- If none, an unordered list is returned.
-
- Returns:
- A formatted HTML string with links to the admin change pages for each item.
- """
- links = []
- for item in queryset:
-
- # This allows you to pass in attribute_name="get_full_name" for instance.
- if attribute_name:
- item_display_value = self.value_of_attribute(item, attribute_name)
- else:
- item_display_value = item
-
- if item_display_value:
- change_url = reverse(f"admin:registrar_{model_name}_change", args=[item.pk])
-
- link = f'{escape(item_display_value)}'
- if link_info_attribute:
- link += f" ({self.value_of_attribute(item, link_info_attribute)})"
-
- if separator:
- links.append(link)
- else:
- links.append(f"
{link}
")
-
- # If no separator is specified, just return an unordered list.
- if separator:
- return format_html(separator.join(links)) if links else "-"
- else:
- links = "".join(links)
- return format_html(f'
{links}
') if links else "-"
-
- def value_of_attribute(self, obj, attribute_name: str):
- """Returns the value of getattr if the attribute isn't callable.
- If it is, execute the underlying function and return that result instead."""
- value = getattr(obj, attribute_name)
- if callable(value):
- value = value()
- return value
-
def get_fieldsets(self, request, obj=None):
"""Override of the default get_fieldsets definition to add an add_fieldsets view"""
# This is the add view if no obj exists
@@ -3348,6 +3305,7 @@ class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"portfolio",
]
search_fields = ["name"]
+ search_help_text = "Search by suborganization."
change_form_template = "django/admin/suborg_change_form.html"
diff --git a/src/registrar/migrations/0130_remove_federalagency_initials_federalagency_acronym_and_more.py b/src/registrar/migrations/0130_remove_federalagency_initials_federalagency_acronym_and_more.py
index 665b2115f..6e5935748 100644
--- a/src/registrar/migrations/0130_remove_federalagency_initials_federalagency_acronym_and_more.py
+++ b/src/registrar/migrations/0130_remove_federalagency_initials_federalagency_acronym_and_more.py
@@ -1,4 +1,4 @@
-# Generated by Django 4.2.10 on 2024-09-27 20:12
+# Generated by Django 4.2.10 on 2024-09-27 20:20
from django.db import migrations, models
import django.db.models.deletion
@@ -87,4 +87,9 @@ class Migration(migrations.Migration):
to="registrar.seniorofficial",
),
),
+ migrations.AlterField(
+ model_name="suborganization",
+ name="name",
+ field=models.CharField(max_length=1000, unique=True, verbose_name="Suborganization"),
+ ),
]
diff --git a/src/registrar/models/suborganization.py b/src/registrar/models/suborganization.py
index feeee0669..6ad80fdc0 100644
--- a/src/registrar/models/suborganization.py
+++ b/src/registrar/models/suborganization.py
@@ -10,7 +10,7 @@ class Suborganization(TimeStampedModel):
name = models.CharField(
unique=True,
max_length=1000,
- help_text="Suborganization",
+ verbose_name="Suborganization",
)
portfolio = models.ForeignKey(
diff --git a/src/registrar/models/utility/generic_helper.py b/src/registrar/models/utility/generic_helper.py
index 3cafe87c4..b02068de0 100644
--- a/src/registrar/models/utility/generic_helper.py
+++ b/src/registrar/models/utility/generic_helper.py
@@ -334,3 +334,11 @@ def get_url_name(path):
except Resolver404:
logger.error(f"No matching URL name found for path: {path}")
return None
+
+def value_of_attribute(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
diff --git a/src/registrar/templates/django/admin/suborg_change_form.html b/src/registrar/templates/django/admin/suborg_change_form.html
index 005d67aec..25fe5700d 100644
--- a/src/registrar/templates/django/admin/suborg_change_form.html
+++ b/src/registrar/templates/django/admin/suborg_change_form.html
@@ -8,27 +8,35 @@
diff --git a/src/registrar/utility/admin_helpers.py b/src/registrar/utility/admin_helpers.py
index 0b99bba13..32d2ad09d 100644
--- a/src/registrar/utility/admin_helpers.py
+++ b/src/registrar/utility/admin_helpers.py
@@ -1,5 +1,9 @@
from registrar.models.domain_request import DomainRequest
from django.template.loader import get_template
+from django.utils.html import format_html
+from django.urls import reverse
+from django.utils.html import escape
+from registrar.models.utility.generic_helper import value_of_attribute
def get_all_action_needed_reason_emails(request, domain_request):
@@ -34,3 +38,50 @@ def get_action_needed_reason_default_email(request, domain_request, action_neede
email_body_text_cleaned = email_body_text.strip().lstrip("\n")
return email_body_text_cleaned
+
+
+def get_field_links_as_list(
+ queryset, model_name, attribute_name=None, link_info_attribute=None, separator=None
+ ):
+ """
+ Generate HTML links for items in a queryset, using a specified attribute for link text.
+
+ Args:
+ queryset: The queryset of items to generate links for.
+ model_name: The model name used to construct the admin change URL.
+ attribute_name: The attribute or method name to use for link text. If None, the item itself is used.
+ link_info_attribute: Appends f"({value_of_attribute})" to the end of the link.
+ separator: The separator to use between links in the resulting HTML.
+ If none, an unordered list is returned.
+
+ Returns:
+ A formatted HTML string with links to the admin change pages for each item.
+ """
+ links = []
+ for item in queryset:
+
+ # This allows you to pass in attribute_name="get_full_name" for instance.
+ if attribute_name:
+ item_display_value = value_of_attribute(item, attribute_name)
+ else:
+ item_display_value = item
+
+ if item_display_value:
+ change_url = reverse(f"admin:registrar_{model_name}_change", args=[item.pk])
+
+ link = f'{escape(item_display_value)}'
+ if link_info_attribute:
+ link += f" ({value_of_attribute(item, link_info_attribute)})"
+
+ if separator:
+ links.append(link)
+ else:
+ links.append(f"
{link}
")
+
+ # If no separator is specified, just return an unordered list.
+ if separator:
+ return format_html(separator.join(links)) if links else "-"
+ else:
+ links = "".join(links)
+ return format_html(f'
{links}
') if links else "-"
+
From 4c756bba69a950763cf6ced2189aeeb0b376ccef Mon Sep 17 00:00:00 2001
From: zandercymatics <141044360+zandercymatics@users.noreply.github.com>
Date: Mon, 30 Sep 2024 08:24:42 -0600
Subject: [PATCH 35/55] User changes
---
src/registrar/admin.py | 2 +-
src/registrar/utility/admin_helpers.py | 8 +++++---
2 files changed, 6 insertions(+), 4 deletions(-)
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index e3a3b2b2d..1ac081eeb 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -865,7 +865,7 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
"""Returns a list of links for each related suborg"""
portfolio_ids = obj.get_portfolios().values_list("portfolio", flat=True)
queryset = models.Portfolio.objects.filter(id__in=portfolio_ids)
- return get_field_links_as_list(queryset, "portfolio")
+ return get_field_links_as_list(queryset, "portfolio", msg_for_none="No portfolios.")
portfolios.short_description = "Portfolios" # type: ignore
diff --git a/src/registrar/utility/admin_helpers.py b/src/registrar/utility/admin_helpers.py
index 32d2ad09d..a6428f826 100644
--- a/src/registrar/utility/admin_helpers.py
+++ b/src/registrar/utility/admin_helpers.py
@@ -41,7 +41,7 @@ def get_action_needed_reason_default_email(request, domain_request, action_neede
def get_field_links_as_list(
- queryset, model_name, attribute_name=None, link_info_attribute=None, separator=None
+ queryset, model_name, attribute_name=None, link_info_attribute=None, separator=None, msg_for_none="-",
):
"""
Generate HTML links for items in a queryset, using a specified attribute for link text.
@@ -53,6 +53,8 @@ def get_field_links_as_list(
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.
+ msg_for_none: What to return when the field would otherwise display None.
+ Defaults to `-`.
Returns:
A formatted HTML string with links to the admin change pages for each item.
@@ -80,8 +82,8 @@ def get_field_links_as_list(
# If no separator is specified, just return an unordered list.
if separator:
- return format_html(separator.join(links)) if links else "-"
+ return format_html(separator.join(links)) if links else msg_for_none
else:
links = "".join(links)
- return format_html(f'
{links}
') if links else "-"
+ return format_html(f'
{links}
') if links else msg_for_none
From e798410ac4cf942a44b6a1456ba42588ba4912d6 Mon Sep 17 00:00:00 2001
From: zandercymatics <141044360+zandercymatics@users.noreply.github.com>
Date: Mon, 30 Sep 2024 08:32:46 -0600
Subject: [PATCH 36/55] update domain info / domain request
---
...er_domaininformation_portfolio_and_more.py | 64 +++++++++++++++++++
src/registrar/models/domain_information.py | 5 +-
src/registrar/models/domain_request.py | 5 +-
3 files changed, 70 insertions(+), 4 deletions(-)
create mode 100644 src/registrar/migrations/0131_alter_domaininformation_portfolio_and_more.py
diff --git a/src/registrar/migrations/0131_alter_domaininformation_portfolio_and_more.py b/src/registrar/migrations/0131_alter_domaininformation_portfolio_and_more.py
new file mode 100644
index 000000000..bf1513f7d
--- /dev/null
+++ b/src/registrar/migrations/0131_alter_domaininformation_portfolio_and_more.py
@@ -0,0 +1,64 @@
+# Generated by Django 4.2.10 on 2024-09-30 14:31
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("registrar", "0130_remove_federalagency_initials_federalagency_acronym_and_more"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="domaininformation",
+ name="portfolio",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="If blank, domain is not associated with a portfolio.",
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="information_portfolio",
+ to="registrar.portfolio",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="domaininformation",
+ name="sub_organization",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="If blank, domain is associated with the overarching organization for this portfolio.",
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="information_sub_organization",
+ to="registrar.suborganization",
+ verbose_name="Suborganization",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="domainrequest",
+ name="portfolio",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="If blank, request is not associated with a portfolio.",
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="DomainRequest_portfolio",
+ to="registrar.portfolio",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="domainrequest",
+ name="sub_organization",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="If blank, request is associated with the overarching organization for this portfolio.",
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="request_sub_organization",
+ to="registrar.suborganization",
+ verbose_name="Suborganization",
+ ),
+ ),
+ ]
diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py
index d04f09c07..6e99dfbee 100644
--- a/src/registrar/models/domain_information.py
+++ b/src/registrar/models/domain_information.py
@@ -63,7 +63,7 @@ class DomainInformation(TimeStampedModel):
null=True,
blank=True,
related_name="information_portfolio",
- help_text="Portfolio associated with this domain",
+ help_text="If blank, domain is not associated with a portfolio.",
)
sub_organization = models.ForeignKey(
@@ -72,7 +72,8 @@ class DomainInformation(TimeStampedModel):
null=True,
blank=True,
related_name="information_sub_organization",
- help_text="The suborganization that this domain is included under",
+ help_text="If blank, domain is associated with the overarching organization for this portfolio.",
+ verbose_name="Suborganization",
)
domain_request = models.OneToOneField(
diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py
index bb8693ac1..35c121f3e 100644
--- a/src/registrar/models/domain_request.py
+++ b/src/registrar/models/domain_request.py
@@ -327,7 +327,7 @@ class DomainRequest(TimeStampedModel):
null=True,
blank=True,
related_name="DomainRequest_portfolio",
- help_text="Portfolio associated with this domain request",
+ help_text="If blank, request is not associated with a portfolio.",
)
sub_organization = models.ForeignKey(
@@ -336,7 +336,8 @@ class DomainRequest(TimeStampedModel):
null=True,
blank=True,
related_name="request_sub_organization",
- help_text="The suborganization that this domain request is included under",
+ help_text="If blank, request is associated with the overarching organization for this portfolio.",
+ verbose_name="Suborganization",
)
# This is the domain request user who created this domain request.
From df3f37bf404372edc1dda26869b644cc1a2dc15c Mon Sep 17 00:00:00 2001
From: zandercymatics <141044360+zandercymatics@users.noreply.github.com>
Date: Mon, 30 Sep 2024 08:36:19 -0600
Subject: [PATCH 37/55] fix script
---
docs/operations/data_migration.md | 2 +-
.../commands/populate_federal_agency_initials_and_fceb.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/operations/data_migration.md b/docs/operations/data_migration.md
index 5e1aa688a..a234d882b 100644
--- a/docs/operations/data_migration.md
+++ b/docs/operations/data_migration.md
@@ -754,7 +754,7 @@ Example: `cf ssh getgov-za`
| 1 | **emailTo** | Specifies where the email will be emailed. Defaults to help@get.gov on production. |
## Populate federal agency initials and FCEB
-This script adds to the "is_fceb" and "initials" fields on the FederalAgency model. This script expects a CSV of federal CIOs to pull from, which can be sourced from [here](https://docs.google.com/spreadsheets/d/14oXHFpKyUXS5_mDWARPusghGdHCrP67jCleOknaSx38/edit?gid=479328070#gid=479328070).
+This script adds to the "is_fceb" and "acronym" fields on the FederalAgency model. This script expects a CSV of federal CIOs to pull from, which can be sourced from [here](https://docs.google.com/spreadsheets/d/14oXHFpKyUXS5_mDWARPusghGdHCrP67jCleOknaSx38/edit?gid=479328070#gid=479328070).
### Running on sandboxes
diff --git a/src/registrar/management/commands/populate_federal_agency_initials_and_fceb.py b/src/registrar/management/commands/populate_federal_agency_initials_and_fceb.py
index 30ae08b47..50b481e7f 100644
--- a/src/registrar/management/commands/populate_federal_agency_initials_and_fceb.py
+++ b/src/registrar/management/commands/populate_federal_agency_initials_and_fceb.py
@@ -36,7 +36,7 @@ class Command(BaseCommand, PopulateScriptTemplate):
self.federal_agency_dict[agency_name.strip()] = (initials, agency_status)
# Update every federal agency record
- self.mass_update_records(FederalAgency, {"agency__isnull": False}, ["initials", "is_fceb"])
+ self.mass_update_records(FederalAgency, {"agency__isnull": False}, ["acronym", "is_fceb"])
def update_record(self, record: FederalAgency):
"""For each record, update the initials and is_fceb field if data exists for it"""
From 7602629dc5fade38c81aecf2444dd1e991e8938c Mon Sep 17 00:00:00 2001
From: zandercymatics <141044360+zandercymatics@users.noreply.github.com>
Date: Mon, 30 Sep 2024 09:08:01 -0600
Subject: [PATCH 38/55] linting
---
src/registrar/admin.py | 6 +-
.../models/utility/generic_helper.py | 1 +
src/registrar/utility/admin_helpers.py | 86 ++++++++++---------
3 files changed, 51 insertions(+), 42 deletions(-)
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 1ac081eeb..ca51e8b72 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -20,7 +20,11 @@ from epplibwrapper.errors import ErrorCode, RegistryError
from registrar.models.user_domain_role import UserDomainRole
from waffle.admin import FlagAdmin
from waffle.models import Sample, Switch
-from registrar.utility.admin_helpers import get_all_action_needed_reason_emails, get_action_needed_reason_default_email, get_field_links_as_list
+from registrar.utility.admin_helpers import (
+ get_all_action_needed_reason_emails,
+ get_action_needed_reason_default_email,
+ get_field_links_as_list,
+)
from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial
from registrar.utility.constants import BranchChoices
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
diff --git a/src/registrar/models/utility/generic_helper.py b/src/registrar/models/utility/generic_helper.py
index b02068de0..5e425f5a3 100644
--- a/src/registrar/models/utility/generic_helper.py
+++ b/src/registrar/models/utility/generic_helper.py
@@ -335,6 +335,7 @@ def get_url_name(path):
logger.error(f"No matching URL name found for path: {path}")
return None
+
def value_of_attribute(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."""
diff --git a/src/registrar/utility/admin_helpers.py b/src/registrar/utility/admin_helpers.py
index a6428f826..2af9d0b3c 100644
--- a/src/registrar/utility/admin_helpers.py
+++ b/src/registrar/utility/admin_helpers.py
@@ -41,49 +41,53 @@ def get_action_needed_reason_default_email(request, domain_request, action_neede
def get_field_links_as_list(
- queryset, model_name, attribute_name=None, link_info_attribute=None, separator=None, msg_for_none="-",
- ):
- """
- Generate HTML links for items in a queryset, using a specified attribute for link text.
+ queryset,
+ model_name,
+ attribute_name=None,
+ link_info_attribute=None,
+ separator=None,
+ msg_for_none="-",
+):
+ """
+ Generate HTML links for items in a queryset, using a specified attribute for link text.
- Args:
- queryset: The queryset of items to generate links for.
- model_name: The model name used to construct the admin change URL.
- attribute_name: The attribute or method name to use for link text. If None, the item itself is used.
- link_info_attribute: Appends f"({value_of_attribute})" to the end of the link.
- separator: The separator to use between links in the resulting HTML.
- If none, an unordered list is returned.
- msg_for_none: What to return when the field would otherwise display None.
- Defaults to `-`.
+ Args:
+ queryset: The queryset of items to generate links for.
+ model_name: The model name used to construct the admin change URL.
+ attribute_name: The attribute or method name to use for link text. If None, the item itself is used.
+ link_info_attribute: Appends f"({value_of_attribute})" to the end of the link.
+ separator: The separator to use between links in the resulting HTML.
+ If none, an unordered list is returned.
+ msg_for_none: What to return when the field would otherwise display None.
+ Defaults to `-`.
- Returns:
- A formatted HTML string with links to the admin change pages for each item.
- """
- links = []
- for item in queryset:
+ Returns:
+ A formatted HTML string with links to the admin change pages for each item.
+ """
+ links = []
+ for item in queryset:
- # This allows you to pass in attribute_name="get_full_name" for instance.
- if attribute_name:
- item_display_value = value_of_attribute(item, attribute_name)
- else:
- item_display_value = item
-
- if item_display_value:
- change_url = reverse(f"admin:registrar_{model_name}_change", args=[item.pk])
-
- link = f'{escape(item_display_value)}'
- if link_info_attribute:
- link += f" ({value_of_attribute(item, link_info_attribute)})"
-
- if separator:
- links.append(link)
- else:
- links.append(f"
{link}
")
-
- # If no separator is specified, just return an unordered list.
- if separator:
- return format_html(separator.join(links)) if links else msg_for_none
+ # This allows you to pass in attribute_name="get_full_name" for instance.
+ if attribute_name:
+ item_display_value = value_of_attribute(item, attribute_name)
else:
- links = "".join(links)
- return format_html(f'
{links}
') if links else msg_for_none
+ item_display_value = item
+ if item_display_value:
+ change_url = reverse(f"admin:registrar_{model_name}_change", args=[item.pk])
+
+ link = f'{escape(item_display_value)}'
+ if link_info_attribute:
+ link += f" ({value_of_attribute(item, link_info_attribute)})"
+
+ if separator:
+ links.append(link)
+ else:
+ links.append(f"
{link}
")
+
+ # If no separator is specified, just return an unordered list.
+ if separator:
+ return format_html(separator.join(links)) if links else msg_for_none
+ else:
+ links = "".join(links)
+ return format_html(f'
{links}
') if links else msg_for_none
From 1122edc5c588d2722c9a29234c89708880c85d12 Mon Sep 17 00:00:00 2001
From: zandercymatics <141044360+zandercymatics@users.noreply.github.com>
Date: Mon, 30 Sep 2024 12:06:19 -0600
Subject: [PATCH 39/55] Fix migrations
---
...nitials_federalagency_acronym_and_more.py} | 61 ++++++++++++++++--
...roups_v17.py => 0130_create_groups_v17.py} | 2 +-
...er_domaininformation_portfolio_and_more.py | 64 -------------------
src/registrar/models/user_group.py | 5 ++
4 files changed, 62 insertions(+), 70 deletions(-)
rename src/registrar/migrations/{0130_remove_federalagency_initials_federalagency_acronym_and_more.py => 0129_remove_federalagency_initials_federalagency_acronym_and_more.py} (56%)
rename src/registrar/migrations/{0129_create_groups_v17.py => 0130_create_groups_v17.py} (93%)
delete mode 100644 src/registrar/migrations/0131_alter_domaininformation_portfolio_and_more.py
diff --git a/src/registrar/migrations/0130_remove_federalagency_initials_federalagency_acronym_and_more.py b/src/registrar/migrations/0129_remove_federalagency_initials_federalagency_acronym_and_more.py
similarity index 56%
rename from src/registrar/migrations/0130_remove_federalagency_initials_federalagency_acronym_and_more.py
rename to src/registrar/migrations/0129_remove_federalagency_initials_federalagency_acronym_and_more.py
index 6e5935748..8371d8136 100644
--- a/src/registrar/migrations/0130_remove_federalagency_initials_federalagency_acronym_and_more.py
+++ b/src/registrar/migrations/0129_remove_federalagency_initials_federalagency_acronym_and_more.py
@@ -1,4 +1,4 @@
-# Generated by Django 4.2.10 on 2024-09-27 20:20
+# Generated by Django 4.2.10 on 2024-09-30 17:59
from django.db import migrations, models
import django.db.models.deletion
@@ -8,15 +8,16 @@ import registrar.models.federal_agency
class Migration(migrations.Migration):
dependencies = [
- ("registrar", "0129_create_groups_v17"),
+ ("registrar", "0128_alter_domaininformation_state_territory_and_more"),
]
operations = [
- migrations.RemoveField(
+ migrations.RenameField(
model_name="federalagency",
- name="initials",
+ old_name="initials",
+ new_name="acronym",
),
- migrations.AddField(
+ migrations.AlterField(
model_name="federalagency",
name="acronym",
field=models.CharField(
@@ -26,6 +27,56 @@ class Migration(migrations.Migration):
null=True,
),
),
+ migrations.AlterField(
+ model_name="domaininformation",
+ name="portfolio",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="If blank, domain is not associated with a portfolio.",
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="information_portfolio",
+ to="registrar.portfolio",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="domaininformation",
+ name="sub_organization",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="If blank, domain is associated with the overarching organization for this portfolio.",
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="information_sub_organization",
+ to="registrar.suborganization",
+ verbose_name="Suborganization",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="domainrequest",
+ name="portfolio",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="If blank, request is not associated with a portfolio.",
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="DomainRequest_portfolio",
+ to="registrar.portfolio",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="domainrequest",
+ name="sub_organization",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="If blank, request is associated with the overarching organization for this portfolio.",
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="request_sub_organization",
+ to="registrar.suborganization",
+ verbose_name="Suborganization",
+ ),
+ ),
migrations.AlterField(
model_name="federalagency",
name="federal_type",
diff --git a/src/registrar/migrations/0129_create_groups_v17.py b/src/registrar/migrations/0130_create_groups_v17.py
similarity index 93%
rename from src/registrar/migrations/0129_create_groups_v17.py
rename to src/registrar/migrations/0130_create_groups_v17.py
index 7e0ae99ad..c7d10693a 100644
--- a/src/registrar/migrations/0129_create_groups_v17.py
+++ b/src/registrar/migrations/0130_create_groups_v17.py
@@ -25,7 +25,7 @@ def create_groups(apps, schema_editor) -> Any:
class Migration(migrations.Migration):
dependencies = [
- ("registrar", "0128_alter_domaininformation_state_territory_and_more"),
+ ("registrar", "0129_remove_federalagency_initials_federalagency_acronym_and_more"),
]
operations = [
diff --git a/src/registrar/migrations/0131_alter_domaininformation_portfolio_and_more.py b/src/registrar/migrations/0131_alter_domaininformation_portfolio_and_more.py
deleted file mode 100644
index bf1513f7d..000000000
--- a/src/registrar/migrations/0131_alter_domaininformation_portfolio_and_more.py
+++ /dev/null
@@ -1,64 +0,0 @@
-# Generated by Django 4.2.10 on 2024-09-30 14:31
-
-from django.db import migrations, models
-import django.db.models.deletion
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ("registrar", "0130_remove_federalagency_initials_federalagency_acronym_and_more"),
- ]
-
- operations = [
- migrations.AlterField(
- model_name="domaininformation",
- name="portfolio",
- field=models.ForeignKey(
- blank=True,
- help_text="If blank, domain is not associated with a portfolio.",
- null=True,
- on_delete=django.db.models.deletion.PROTECT,
- related_name="information_portfolio",
- to="registrar.portfolio",
- ),
- ),
- migrations.AlterField(
- model_name="domaininformation",
- name="sub_organization",
- field=models.ForeignKey(
- blank=True,
- help_text="If blank, domain is associated with the overarching organization for this portfolio.",
- null=True,
- on_delete=django.db.models.deletion.PROTECT,
- related_name="information_sub_organization",
- to="registrar.suborganization",
- verbose_name="Suborganization",
- ),
- ),
- migrations.AlterField(
- model_name="domainrequest",
- name="portfolio",
- field=models.ForeignKey(
- blank=True,
- help_text="If blank, request is not associated with a portfolio.",
- null=True,
- on_delete=django.db.models.deletion.PROTECT,
- related_name="DomainRequest_portfolio",
- to="registrar.portfolio",
- ),
- ),
- migrations.AlterField(
- model_name="domainrequest",
- name="sub_organization",
- field=models.ForeignKey(
- blank=True,
- help_text="If blank, request is associated with the overarching organization for this portfolio.",
- null=True,
- on_delete=django.db.models.deletion.PROTECT,
- related_name="request_sub_organization",
- to="registrar.suborganization",
- verbose_name="Suborganization",
- ),
- ),
- ]
diff --git a/src/registrar/models/user_group.py b/src/registrar/models/user_group.py
index 41ae67c06..84b4da701 100644
--- a/src/registrar/models/user_group.py
+++ b/src/registrar/models/user_group.py
@@ -76,6 +76,11 @@ class UserGroup(Group):
"model": "suborganization",
"permissions": ["add_suborganization", "change_suborganization", "delete_suborganization"],
},
+ {
+ "app_label": "registrar",
+ "model": "seniorofficial",
+ "permissions": ["add_seniorofficial", "change_seniorofficial", "delete_seniorofficial"],
+ },
{
"app_label": "registrar",
"model": "userportfoliopermission",
From 0e7969f7c781e16e85af529f9468575219df900a Mon Sep 17 00:00:00 2001
From: zandercymatics <141044360+zandercymatics@users.noreply.github.com>
Date: Mon, 30 Sep 2024 12:27:41 -0600
Subject: [PATCH 40/55] fix test
---
src/registrar/tests/test_migrations.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/src/registrar/tests/test_migrations.py b/src/registrar/tests/test_migrations.py
index d646a03de..eaaae8727 100644
--- a/src/registrar/tests/test_migrations.py
+++ b/src/registrar/tests/test_migrations.py
@@ -43,6 +43,9 @@ class TestGroups(TestCase):
"add_portfolio",
"change_portfolio",
"delete_portfolio",
+ "add_seniorofficial",
+ "change_seniorofficial",
+ "delete_seniorofficial",
"add_suborganization",
"change_suborganization",
"delete_suborganization",
From 8b8c176b0cb0ef398708226db9e6acc9f2b3bf23 Mon Sep 17 00:00:00 2001
From: Rebecca Hsieh
Date: Tue, 1 Oct 2024 07:27:19 -0700
Subject: [PATCH 41/55] Biz logic
---
src/registrar/assets/js/get-gov.js | 28 ++
src/registrar/config/urls.py | 6 +
src/registrar/models/user.py | 13 +
.../includes/domain_requests_table.html | 364 +++++++++---------
src/registrar/utility/csv_export.py | 99 +++++
src/registrar/views/report_views.py | 11 +
6 files changed, 335 insertions(+), 186 deletions(-)
diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js
index 027ef4344..9d5432259 100644
--- a/src/registrar/assets/js/get-gov.js
+++ b/src/registrar/assets/js/get-gov.js
@@ -1498,12 +1498,36 @@ class DomainsTable extends LoadTableBase {
}
}
+function showExportElement(element) {
+ console.log(`Showing element: ${element.id}`);
+ element.style.display = 'block';
+}
+
+function hideExportElement(element) {
+ console.log(`Hiding element: ${element.id}`);
+ element.style.display = 'none';
+}
class DomainRequestsTable extends LoadTableBase {
constructor() {
super('.domain-requests__table', '.domain-requests__table-wrapper', '#domain-requests__search-field', '#domain-requests__search-field-submit', '.domain-requests__reset-search', '.domain-requests__reset-filters', '.domain-requests__no-data', '.domain-requests__no-search-results');
}
+
+ toggleExportButton(requests) {
+ console.log("Toggling Export Button Visibility");
+ const exportButton = document.getElementById('export-csv-button');
+ if (exportButton) {
+ console.log(`Current requests length: ${requests.length}`);
+ if (requests.length > 0) {
+ showExportElement(exportButton);
+ } else {
+ hideExportElement(exportButton);
+ }
+ console.log(exportButton);
+ }
+}
+
/**
* Loads rows in the domains list, as well as updates pagination around the domains list
* based on the supplied attributes.
@@ -1517,6 +1541,7 @@ class DomainRequestsTable extends LoadTableBase {
*/
loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm = this.currentSearchTerm, portfolio = this.portfolioValue) {
let baseUrl = document.getElementById("get_domain_requests_json_url");
+
if (!baseUrl) {
return;
}
@@ -1548,6 +1573,9 @@ class DomainRequestsTable extends LoadTableBase {
return;
}
+ // Call toggleExportButton to manage button visibility
+ this.toggleExportButton(data.domain_requests);
+
// handle the display of proper messaging in the event that no requests exist in the list or search returns no results
this.updateDisplay(data, this.tableWrapper, this.noTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm);
diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py
index 76c77955f..d4612f9a8 100644
--- a/src/registrar/config/urls.py
+++ b/src/registrar/config/urls.py
@@ -20,6 +20,7 @@ from registrar.views.report_views import (
AnalyticsView,
ExportDomainRequestDataFull,
ExportDataTypeUser,
+ ExportDataTypeRequests,
)
from registrar.views.domain_request import Step
@@ -165,6 +166,11 @@ urlpatterns = [
ExportDataTypeUser.as_view(),
name="export_data_type_user",
),
+ path(
+ "reports/export_data_type_requests/",
+ ExportDataTypeRequests.as_view(),
+ name="export_data_type_requests",
+ ),
path(
"domain-request//edit/",
views.DomainRequestWizard.as_view(),
diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py
index ae76d648b..60b08ddcd 100644
--- a/src/registrar/models/user.py
+++ b/src/registrar/models/user.py
@@ -229,6 +229,10 @@ class User(AbstractUser):
"""Determines if the current user can view all available domains in a given portfolio"""
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)
+ def has_view_all_domain_requests_portfolio_permission(self, portfolio):
+ """Determines if the current user can view all available domains in a given portfolio"""
+ return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)
+
def has_any_requests_portfolio_permission(self, portfolio):
# BEGIN
# Note code below is to add organization_request feature
@@ -458,3 +462,12 @@ class User(AbstractUser):
return DomainInformation.objects.filter(portfolio=portfolio).values_list("domain_id", flat=True)
else:
return UserDomainRole.objects.filter(user=self).values_list("domain_id", flat=True)
+
+ def get_user_domain_request_ids(self, request):
+ """Returns either the domain request ids associated with this user on UserDomainRole or Portfolio"""
+ portfolio = request.session.get("portfolio")
+
+ if self.is_org_user(request) and self.has_view_all_domain_requests_portfolio_permission(portfolio):
+ return DomainRequest.objects.filter(portfolio=portfolio).values_list("id", flat=True)
+ else:
+ return UserDomainRole.objects.filter(user=self).values_list("domain_request_id", flat=True)
diff --git a/src/registrar/templates/includes/domain_requests_table.html b/src/registrar/templates/includes/domain_requests_table.html
index 375e0229c..2e0211811 100644
--- a/src/registrar/templates/includes/domain_requests_table.html
+++ b/src/registrar/templates/includes/domain_requests_table.html
@@ -3,208 +3,200 @@
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
{% url 'get_domain_requests_json' as url %}
{{url}}
+