/delete",
views.DomainDeleteUserView.as_view(http_method_names=["post"]),
name="domain-user-delete",
),
@@ -392,6 +396,7 @@ urlpatterns = [
# This way, we can share a view for djangooidc, and other pages as we see fit.
handler500 = "registrar.views.utility.error_views.custom_500_error_view"
handler403 = "registrar.views.utility.error_views.custom_403_error_view"
+handler404 = "registrar.views.utility.error_views.custom_404_error_view"
# we normally would guard these with `if settings.DEBUG` but tests run with
# DEBUG = False even when these apps have been loaded because settings.DEBUG
diff --git a/src/registrar/decorators.py b/src/registrar/decorators.py
new file mode 100644
index 000000000..7cb2792f4
--- /dev/null
+++ b/src/registrar/decorators.py
@@ -0,0 +1,300 @@
+import functools
+from django.core.exceptions import PermissionDenied
+from django.utils.decorators import method_decorator
+from registrar.models import Domain, DomainInformation, DomainInvitation, DomainRequest, UserDomainRole
+
+# Constants for clarity
+ALL = "all"
+IS_STAFF = "is_staff"
+IS_DOMAIN_MANAGER = "is_domain_manager"
+IS_DOMAIN_REQUEST_CREATOR = "is_domain_request_creator"
+IS_STAFF_MANAGING_DOMAIN = "is_staff_managing_domain"
+IS_PORTFOLIO_MEMBER = "is_portfolio_member"
+IS_PORTFOLIO_MEMBER_AND_DOMAIN_MANAGER = "is_portfolio_member_and_domain_manager"
+IS_DOMAIN_MANAGER_AND_NOT_PORTFOLIO_MEMBER = "is_domain_manager_and_not_portfolio_member"
+HAS_PORTFOLIO_DOMAINS_ANY_PERM = "has_portfolio_domains_any_perm"
+HAS_PORTFOLIO_DOMAINS_VIEW_ALL = "has_portfolio_domains_view_all"
+HAS_PORTFOLIO_DOMAIN_REQUESTS_ANY_PERM = "has_portfolio_domain_requests_any_perm"
+HAS_PORTFOLIO_DOMAIN_REQUESTS_VIEW_ALL = "has_portfolio_domain_requests_view_all"
+HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT = "has_portfolio_domain_requests_edit"
+HAS_PORTFOLIO_MEMBERS_ANY_PERM = "has_portfolio_members_any_perm"
+HAS_PORTFOLIO_MEMBERS_EDIT = "has_portfolio_members_edit"
+HAS_PORTFOLIO_MEMBERS_VIEW = "has_portfolio_members_view"
+
+
+def grant_access(*rules):
+ """
+ A decorator that enforces access control based on specified rules.
+
+ Usage:
+ - Multiple rules in a single decorator:
+ @grant_access(IS_STAFF, IS_SUPERUSER, IS_DOMAIN_MANAGER)
+
+ - Stacked decorators for separate rules:
+ @grant_access(IS_SUPERUSER)
+ @grant_access(IS_DOMAIN_MANAGER)
+
+ The decorator supports both function-based views (FBVs) and class-based views (CBVs).
+ """
+
+ def decorator(view):
+ if isinstance(view, type): # Check if decorating a class-based view (CBV)
+ original_dispatch = view.dispatch # Store the original dispatch method
+
+ @method_decorator(grant_access(*rules)) # Apply the decorator to dispatch
+ def wrapped_dispatch(self, request, *args, **kwargs):
+ if not _user_has_permission(request.user, request, rules, **kwargs):
+ raise PermissionDenied # Deny access if the user lacks permission
+ return original_dispatch(self, request, *args, **kwargs)
+
+ view.dispatch = wrapped_dispatch # Replace the dispatch method
+ return view
+
+ else: # If decorating a function-based view (FBV)
+ view.has_explicit_access = True # Mark the view as having explicit access control
+ existing_rules = getattr(view, "_access_rules", set()) # Retrieve existing rules
+ existing_rules.update(rules) # Merge with new rules
+ view._access_rules = existing_rules # Store updated rules
+
+ @functools.wraps(view)
+ def wrapper(request, *args, **kwargs):
+ if not _user_has_permission(request.user, request, rules, **kwargs):
+ raise PermissionDenied # Deny access if the user lacks permission
+ return view(request, *args, **kwargs) # Proceed with the original view
+
+ return wrapper
+
+ return decorator
+
+
+def _user_has_permission(user, request, rules, **kwargs):
+ """
+ Determines if the user meets the required permission rules.
+
+ This function evaluates a set of predefined permission rules to check whether a user has access
+ to a specific view. It supports various access control conditions, including staff status,
+ domain management roles, and portfolio-related permissions.
+
+ Parameters:
+ - user: The user requesting access.
+ - request: The HTTP request object.
+ - rules: A set of access control rules to evaluate.
+ - **kwargs: Additional keyword arguments used in specific permission checks.
+
+ Returns:
+ - True if the user satisfies any of the specified rules.
+ - False otherwise.
+ """
+
+ # Skip authentication if @login_not_required is applied
+ if getattr(request, "login_not_required", False):
+ return True
+
+ # Allow everyone if `ALL` is in rules
+ if ALL in rules:
+ return True
+
+ # Ensure user is authenticated and not restricted
+ if not user.is_authenticated or user.is_restricted():
+ return False
+
+ # Define permission checks
+ permission_checks = [
+ (IS_STAFF, lambda: user.is_staff),
+ (IS_DOMAIN_MANAGER, lambda: _is_domain_manager(user, **kwargs)),
+ (IS_STAFF_MANAGING_DOMAIN, lambda: _is_staff_managing_domain(request, **kwargs)),
+ (IS_PORTFOLIO_MEMBER, lambda: user.is_org_user(request)),
+ (
+ HAS_PORTFOLIO_DOMAINS_VIEW_ALL,
+ lambda: _has_portfolio_view_all_domains(request, kwargs.get("domain_pk")),
+ ),
+ (
+ HAS_PORTFOLIO_DOMAINS_ANY_PERM,
+ lambda: user.is_org_user(request)
+ and user.has_any_domains_portfolio_permission(request.session.get("portfolio")),
+ ),
+ (
+ IS_PORTFOLIO_MEMBER_AND_DOMAIN_MANAGER,
+ lambda: _is_domain_manager(user, **kwargs) and _is_portfolio_member(request),
+ ),
+ (
+ IS_DOMAIN_MANAGER_AND_NOT_PORTFOLIO_MEMBER,
+ lambda: _is_domain_manager(user, **kwargs) and not _is_portfolio_member(request),
+ ),
+ (
+ IS_DOMAIN_REQUEST_CREATOR,
+ lambda: _is_domain_request_creator(user, kwargs.get("domain_request_pk"))
+ and not _is_portfolio_member(request),
+ ),
+ (
+ HAS_PORTFOLIO_DOMAIN_REQUESTS_ANY_PERM,
+ lambda: user.is_org_user(request)
+ and user.has_any_requests_portfolio_permission(request.session.get("portfolio")),
+ ),
+ (
+ HAS_PORTFOLIO_DOMAIN_REQUESTS_VIEW_ALL,
+ lambda: user.is_org_user(request)
+ and user.has_view_all_domain_requests_portfolio_permission(request.session.get("portfolio")),
+ ),
+ (
+ HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT,
+ lambda: _has_portfolio_domain_requests_edit(user, request, kwargs.get("domain_request_pk")),
+ ),
+ (
+ HAS_PORTFOLIO_MEMBERS_ANY_PERM,
+ lambda: user.is_org_user(request)
+ and (
+ user.has_view_members_portfolio_permission(request.session.get("portfolio"))
+ or user.has_edit_members_portfolio_permission(request.session.get("portfolio"))
+ ),
+ ),
+ (
+ HAS_PORTFOLIO_MEMBERS_EDIT,
+ lambda: user.is_org_user(request)
+ and user.has_edit_members_portfolio_permission(request.session.get("portfolio")),
+ ),
+ (
+ HAS_PORTFOLIO_MEMBERS_VIEW,
+ lambda: user.is_org_user(request)
+ and user.has_view_members_portfolio_permission(request.session.get("portfolio")),
+ ),
+ ]
+
+ # Check conditions iteratively
+ return any(check() for rule, check in permission_checks if rule in rules)
+
+
+def _has_portfolio_domain_requests_edit(user, request, domain_request_id):
+ if domain_request_id and not _is_domain_request_creator(user, domain_request_id):
+ return False
+ return user.is_org_user(request) and user.has_edit_request_portfolio_permission(request.session.get("portfolio"))
+
+
+def _is_domain_manager(user, **kwargs):
+ """
+ Determines if the given user is a domain manager for a specified domain.
+
+ - First, it checks if 'domain_pk' is present in the URL parameters.
+ - If 'domain_pk' exists, it verifies if the user has a domain role for that domain.
+ - If 'domain_pk' is absent, it checks for 'domain_invitation_pk' to determine if the user
+ has domain permissions through an invitation.
+
+ Returns:
+ bool: True if the user is a domain manager, False otherwise.
+ """
+ domain_id = kwargs.get("domain_pk")
+ if domain_id:
+ return UserDomainRole.objects.filter(user=user, domain_id=domain_id).exists()
+ domain_invitation_id = kwargs.get("domain_invitation_pk")
+ if domain_invitation_id:
+ return DomainInvitation.objects.filter(id=domain_invitation_id, domain__permissions__user=user).exists()
+ return False
+
+
+def _is_domain_request_creator(user, domain_request_pk):
+ """Checks to see if the user is the creator of a domain request
+ with domain_request_pk."""
+ if domain_request_pk:
+ return DomainRequest.objects.filter(creator=user, id=domain_request_pk).exists()
+ return True
+
+
+def _is_portfolio_member(request):
+ """Checks to see if the user in the request is a member of the
+ portfolio in the request's session."""
+ return request.user.is_org_user(request)
+
+
+def _is_staff_managing_domain(request, **kwargs):
+ """
+ Determines whether a staff user (analyst or superuser) has permission to manage a domain
+ that they did not create or were not invited to.
+
+ The function enforces:
+ 1. **User Authorization** - The user must have `analyst_access_permission` or `full_access_permission`.
+ 2. **Valid Session Context** - The user must have explicitly selected the domain for management
+ via an 'analyst action' (e.g., by clicking 'Manage Domain' in the admin interface).
+ 3. **Domain Status Check** - Only domains in specific statuses (e.g., APPROVED, IN_REVIEW, etc.)
+ can be managed, except in cases where the domain lacks a status due to errors.
+
+ Process:
+ - First, the function retrieves the `domain_pk` from the URL parameters.
+ - If `domain_pk` is not provided, it attempts to resolve the domain via `domain_invitation_pk`.
+ - It checks if the user has the required permissions.
+ - It verifies that the user has an active 'analyst action' session for the domain.
+ - Finally, it ensures that the domain is in a status that allows management.
+
+ Returns:
+ bool: True if the user is allowed to manage the domain, False otherwise.
+ """
+
+ domain_id = kwargs.get("domain_pk")
+ if not domain_id:
+ domain_invitation_id = kwargs.get("domain_invitation_pk")
+ domain_invitation = DomainInvitation.objects.filter(id=domain_invitation_id).first()
+ if domain_invitation:
+ domain_id = domain_invitation.domain_id
+
+ # Check if the request user is permissioned...
+ user_is_analyst_or_superuser = request.user.has_perm(
+ "registrar.analyst_access_permission"
+ ) or request.user.has_perm("registrar.full_access_permission")
+
+ if not user_is_analyst_or_superuser:
+ return False
+
+ # Check if the user is attempting a valid edit action.
+ # In other words, if the analyst/admin did not click
+ # the 'Manage Domain' button in /admin,
+ # then they cannot access this page.
+ session = request.session
+ can_do_action = (
+ "analyst_action" in session
+ and "analyst_action_location" in session
+ and session["analyst_action_location"] == domain_id
+ )
+
+ if not can_do_action:
+ return False
+
+ # Analysts may manage domains, when they are in these statuses:
+ valid_domain_statuses = [
+ DomainRequest.DomainRequestStatus.APPROVED,
+ DomainRequest.DomainRequestStatus.IN_REVIEW,
+ DomainRequest.DomainRequestStatus.REJECTED,
+ DomainRequest.DomainRequestStatus.ACTION_NEEDED,
+ # Edge case - some domains do not have
+ # a status or DomainInformation... aka a status of 'None'.
+ # It is necessary to access those to correct errors.
+ None,
+ ]
+
+ requested_domain = DomainInformation.objects.filter(domain_id=domain_id).first()
+
+ # if no domain information or domain request exist, the user
+ # should be able to manage the domain; however, if domain information
+ # and domain request exist, and domain request is not in valid status,
+ # user should not be able to manage domain
+ if (
+ requested_domain
+ and requested_domain.domain_request
+ and requested_domain.domain_request.status not in valid_domain_statuses
+ ):
+ return False
+
+ # Valid session keys exist,
+ # the user is permissioned,
+ # and it is in a valid status
+ return True
+
+
+def _has_portfolio_view_all_domains(request, domain_pk):
+ """Returns whether the user in the request can access the domain
+ via portfolio view all domains permission."""
+ portfolio = request.session.get("portfolio")
+ if request.user.has_view_all_domains_portfolio_permission(portfolio):
+ if Domain.objects.filter(id=domain_pk).exists():
+ domain = Domain.objects.get(id=domain_pk)
+ if domain.domain_info.portfolio == portfolio:
+ return True
+ return False
diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py
index 2725224f1..5aef09389 100644
--- a/src/registrar/forms/portfolio.py
+++ b/src/registrar/forms/portfolio.py
@@ -13,7 +13,16 @@ from registrar.models import (
Portfolio,
SeniorOfficial,
)
-from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
+from registrar.models.utility.portfolio_helper import (
+ UserPortfolioPermissionChoices,
+ UserPortfolioRoleChoices,
+ get_domain_requests_description_display,
+ get_domain_requests_display,
+ get_domains_description_display,
+ get_domains_display,
+ get_members_description_display,
+ get_members_display,
+)
logger = logging.getLogger(__name__)
@@ -126,8 +135,16 @@ class BasePortfolioMemberForm(forms.ModelForm):
domain_permissions = forms.ChoiceField(
choices=[
- (UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value, "Viewer, limited"),
- (UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value, "Viewer, all"),
+ (
+ UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value,
+ get_domains_display(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, None),
+ ),
+ (
+ UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value,
+ get_domains_display(
+ UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS]
+ ),
+ ),
],
widget=forms.RadioSelect,
required=False,
@@ -139,9 +156,19 @@ class BasePortfolioMemberForm(forms.ModelForm):
domain_request_permissions = forms.ChoiceField(
choices=[
- ("no_access", "No access"),
- (UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "Viewer"),
- (UserPortfolioPermissionChoices.EDIT_REQUESTS.value, "Creator"),
+ ("no_access", get_domain_requests_display(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, None)),
+ (
+ UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
+ get_domain_requests_display(
+ UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS]
+ ),
+ ),
+ (
+ UserPortfolioPermissionChoices.EDIT_REQUESTS.value,
+ get_domain_requests_display(
+ UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.EDIT_REQUESTS]
+ ),
+ ),
],
widget=forms.RadioSelect,
required=False,
@@ -153,8 +180,13 @@ class BasePortfolioMemberForm(forms.ModelForm):
member_permissions = forms.ChoiceField(
choices=[
- ("no_access", "No access"),
- (UserPortfolioPermissionChoices.VIEW_MEMBERS.value, "Viewer"),
+ ("no_access", get_members_display(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, None)),
+ (
+ UserPortfolioPermissionChoices.VIEW_MEMBERS.value,
+ get_members_display(
+ UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.VIEW_MEMBERS]
+ ),
+ ),
],
widget=forms.RadioSelect,
required=False,
@@ -191,19 +223,31 @@ class BasePortfolioMemberForm(forms.ModelForm):
# Adds a description beneath each option
self.fields["domain_permissions"].descriptions = {
- UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value: "Can view only the domains they manage",
- UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value: "Can view all domains for the organization",
+ UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value: get_domains_description_display(
+ UserPortfolioRoleChoices.ORGANIZATION_MEMBER, None
+ ),
+ UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value: get_domains_description_display(
+ UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS]
+ ),
}
self.fields["domain_request_permissions"].descriptions = {
UserPortfolioPermissionChoices.EDIT_REQUESTS.value: (
- "Can view all domain requests for the organization and create requests"
+ get_domain_requests_description_display(
+ UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.EDIT_REQUESTS]
+ )
),
- UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value: "Can view all domain requests for the organization",
- "no_access": "Cannot view or create domain requests",
+ UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value: (
+ get_domain_requests_description_display(
+ UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS]
+ )
+ ),
+ "no_access": get_domain_requests_description_display(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, None),
}
self.fields["member_permissions"].descriptions = {
- UserPortfolioPermissionChoices.VIEW_MEMBERS.value: "Can view all members permissions",
- "no_access": "Cannot view member permissions",
+ UserPortfolioPermissionChoices.VIEW_MEMBERS.value: get_members_description_display(
+ UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.VIEW_MEMBERS]
+ ),
+ "no_access": get_members_description_display(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, None),
}
# Map model instance values to custom form fields
@@ -218,6 +262,9 @@ class BasePortfolioMemberForm(forms.ModelForm):
cleaned_data = super().clean()
role = cleaned_data.get("role")
+ # handle role
+ cleaned_data["roles"] = [role] if role else []
+
# Get required fields for the selected role. Then validate all required fields for the role.
required_fields = self.ROLE_REQUIRED_FIELDS.get(role, [])
for field_name in required_fields:
@@ -236,9 +283,6 @@ class BasePortfolioMemberForm(forms.ModelForm):
if cleaned_data.get("member_permissions") == "no_access":
cleaned_data["member_permissions"] = None
- # Handle roles
- cleaned_data["roles"] = [role]
-
# Handle additional_permissions
valid_fields = self.ROLE_REQUIRED_FIELDS.get(role, [])
additional_permissions = {cleaned_data.get(field) for field in valid_fields if cleaned_data.get(field)}
@@ -338,6 +382,24 @@ class BasePortfolioMemberForm(forms.ModelForm):
and UserPortfolioRoleChoices.ORGANIZATION_ADMIN not in new_roles
)
+ def is_change(self) -> bool:
+ """
+ Determines if the form has changed by comparing the initial data
+ with the submitted cleaned data.
+
+ Returns:
+ bool: True if the form has changed, False otherwise.
+ """
+ # Compare role values
+ previous_roles = set(self.initial.get("roles", []))
+ new_roles = set(self.cleaned_data.get("roles", []))
+
+ # Compare additional permissions values
+ previous_permissions = set(self.initial.get("additional_permissions") or [])
+ new_permissions = set(self.cleaned_data.get("additional_permissions") or [])
+
+ return previous_roles != new_roles or previous_permissions != new_permissions
+
class PortfolioMemberForm(BasePortfolioMemberForm):
"""
diff --git a/src/registrar/management/commands/disclose_security_emails.py b/src/registrar/management/commands/disclose_security_emails.py
index 62989e4c0..62456747a 100644
--- a/src/registrar/management/commands/disclose_security_emails.py
+++ b/src/registrar/management/commands/disclose_security_emails.py
@@ -1,4 +1,4 @@
-""""
+""" "
Converts all ready and DNS needed domains with a non-default public contact
to disclose their public contact. Created for Issue#1535 to resolve
disclose issue of domains with missing security emails.
diff --git a/src/registrar/management/commands/master_domain_migrations.py b/src/registrar/management/commands/master_domain_migrations.py
index 7f702e047..2907add06 100644
--- a/src/registrar/management/commands/master_domain_migrations.py
+++ b/src/registrar/management/commands/master_domain_migrations.py
@@ -1,8 +1,8 @@
"""Data migration:
- 1 - generates a report of data integrity across all
- transition domain related tables
- 2 - allows users to run all migration scripts for
- transition domain data
+1 - generates a report of data integrity across all
+transition domain related tables
+2 - allows users to run all migration scripts for
+transition domain data
"""
import logging
diff --git a/src/registrar/management/commands/populate_domain_updated_federal_agency.py b/src/registrar/management/commands/populate_domain_updated_federal_agency.py
index dd8ceb3b2..3fbf2792c 100644
--- a/src/registrar/management/commands/populate_domain_updated_federal_agency.py
+++ b/src/registrar/management/commands/populate_domain_updated_federal_agency.py
@@ -1,4 +1,4 @@
-""""
+""" "
Data migration: Renaming deprecated Federal Agencies to
their new updated names ie (U.S. Peace Corps to Peace Corps)
within Domain Information and Domain Requests
diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py
index 42310c3bb..d3c0ed347 100644
--- a/src/registrar/models/domain.py
+++ b/src/registrar/models/domain.py
@@ -2,6 +2,7 @@ from itertools import zip_longest
import logging
import ipaddress
import re
+import time
from datetime import date, timedelta
from typing import Optional
from django.db import transaction
@@ -750,11 +751,7 @@ class Domain(TimeStampedModel, DomainHelper):
successTotalNameservers = len(oldNameservers) - deleteCount + addToDomainCount
- try:
- self._delete_hosts_if_not_used(hostsToDelete=deleted_values)
- except Exception as e:
- # we don't need this part to succeed in order to continue.
- logger.error("Failed to delete nameserver hosts: %s", e)
+ self._delete_hosts_if_not_used(hostsToDelete=deleted_values)
if successTotalNameservers < 2:
try:
@@ -1038,13 +1035,13 @@ class Domain(TimeStampedModel, DomainHelper):
logger.error(f"registry error removing client hold: {err}")
raise (err)
- def _delete_domain(self):
+ def _delete_domain(self): # noqa
"""This domain should be deleted from the registry
may raises RegistryError, should be caught or handled correctly by caller"""
logger.info("Deleting subdomains for %s", self.name)
# check if any subdomains are in use by another domain
- hosts = Host.objects.filter(name__regex=r".+{}".format(self.name))
+ hosts = Host.objects.filter(name__regex=r".+\.{}".format(self.name))
for host in hosts:
if host.domain != self:
logger.error("Unable to delete host: %s is in use by another domain: %s", host.name, host.domain)
@@ -1052,38 +1049,119 @@ class Domain(TimeStampedModel, DomainHelper):
code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION,
note=f"Host {host.name} is in use by {host.domain}",
)
+ try:
+ # set hosts to empty list so nameservers are deleted
+ (
+ deleted_values,
+ updated_values,
+ new_values,
+ oldNameservers,
+ ) = self.getNameserverChanges(hosts=[])
- (
- deleted_values,
- updated_values,
- new_values,
- oldNameservers,
- ) = self.getNameserverChanges(hosts=[])
-
- _ = self._update_host_values(updated_values, oldNameservers) # returns nothing, just need to be run and errors
- addToDomainList, _ = self.createNewHostList(new_values)
- deleteHostList, _ = self.createDeleteHostList(deleted_values)
- responseCode = self.addAndRemoveHostsFromDomain(hostsToAdd=addToDomainList, hostsToDelete=deleteHostList)
-
+ # update the hosts
+ _ = self._update_host_values(
+ updated_values, oldNameservers
+ ) # returns nothing, just need to be run and errors
+ addToDomainList, _ = self.createNewHostList(new_values)
+ deleteHostList, _ = self.createDeleteHostList(deleted_values)
+ responseCode = self.addAndRemoveHostsFromDomain(hostsToAdd=addToDomainList, hostsToDelete=deleteHostList)
+ except RegistryError as e:
+ logger.error(f"Error trying to delete hosts from domain {self}: {e}")
+ raise e
# if unable to update domain raise error and stop
if responseCode != ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY:
raise NameserverError(code=nsErrorCodes.BAD_DATA)
+ logger.info("Finished removing nameservers from domain")
+
# addAndRemoveHostsFromDomain removes the hosts from the domain object,
# but we still need to delete the object themselves
self._delete_hosts_if_not_used(hostsToDelete=deleted_values)
+ logger.info("Finished _delete_hosts_if_not_used inside _delete_domain()")
+ # delete the non-registrant contacts
logger.debug("Deleting non-registrant contacts for %s", self.name)
contacts = PublicContact.objects.filter(domain=self)
- for contact in contacts:
- if contact.contact_type != PublicContact.ContactTypeChoices.REGISTRANT:
- self._update_domain_with_contact(contact, rem=True)
- request = commands.DeleteContact(contact.registry_id)
- registry.send(request, cleaned=True)
+ logger.info(f"retrieved contacts for domain: {contacts}")
- logger.info("Deleting domain %s", self.name)
+ for contact in contacts:
+ try:
+ if contact.contact_type != PublicContact.ContactTypeChoices.REGISTRANT:
+ logger.info(f"Deleting contact: {contact}")
+ try:
+ self._update_domain_with_contact(contact, rem=True)
+ except Exception as e:
+ logger.error(f"Error while updating domain with contact: {contact}, e: {e}", exc_info=True)
+ request = commands.DeleteContact(contact.registry_id)
+ registry.send(request, cleaned=True)
+ logger.info(f"sent DeleteContact for {contact}")
+ except RegistryError as e:
+ logger.error(f"Error deleting contact: {contact}, {e}", exc_info=True)
+
+ logger.info(f"Finished deleting contacts for {self.name}")
+
+ # delete ds data if it exists
+ if self.dnssecdata:
+ logger.debug("Deleting ds data for %s", self.name)
+ try:
+ # set and unset client hold to be able to change ds data
+ logger.info("removing client hold")
+ self._remove_client_hold()
+ self.dnssecdata = None
+ logger.info("placing client hold")
+ self._place_client_hold()
+ except RegistryError as e:
+ logger.error("Error deleting ds data for %s: %s", self.name, e)
+ e.note = "Error deleting ds data for %s" % self.name
+ raise e
+
+ # check if the domain can be deleted
+ if not self._domain_can_be_deleted():
+ note = "Domain has associated objects that prevent deletion."
+ raise RegistryError(code=ErrorCode.COMMAND_FAILED, note=note)
+
+ # delete the domain
request = commands.DeleteDomain(name=self.name)
- registry.send(request, cleaned=True)
+ try:
+ registry.send(request, cleaned=True)
+ logger.info("Domain %s deleted successfully.", self.name)
+ except RegistryError as e:
+ logger.error("Error deleting domain %s: %s", self.name, e)
+ raise e
+
+ def _domain_can_be_deleted(self, max_attempts=5, wait_interval=2) -> bool:
+ """
+ Polls the registry using InfoDomain calls to confirm that the domain can be deleted.
+ Returns True if the domain can be deleted, False otherwise. Includes a retry mechanism
+ using wait_interval and max_attempts, which may be necessary if subdomains and other
+ associated objects were only recently deleted as the registry may not be immediately updated.
+ """
+ logger.info("Polling registry to confirm deletion pre-conditions for %s", self.name)
+ last_info_error = None
+ for attempt in range(max_attempts):
+ try:
+ info_response = registry.send(commands.InfoDomain(name=self.name), cleaned=True)
+ domain_info = info_response.res_data[0]
+ hosts_associated = getattr(domain_info, "hosts", None)
+ if hosts_associated is None or len(hosts_associated) == 0:
+ logger.info("InfoDomain reports no associated hosts for %s. Proceeding with deletion.", self.name)
+ return True
+ else:
+ logger.info("Attempt %d: Domain %s still has hosts: %s", attempt + 1, self.name, hosts_associated)
+ except RegistryError as info_e:
+ # If the domain is already gone, we can assume deletion already occurred.
+ if info_e.code == ErrorCode.OBJECT_DOES_NOT_EXIST:
+ logger.info("InfoDomain check indicates domain %s no longer exists.", self.name)
+ raise info_e
+ logger.warning("Attempt %d: Error during InfoDomain check: %s", attempt + 1, info_e)
+ time.sleep(wait_interval)
+ else:
+ logger.error(
+ "Exceeded max attempts waiting for domain %s to clear associated objects; last error: %s",
+ self.name,
+ last_info_error,
+ )
+ return False
def __str__(self) -> str:
return self.name
@@ -1840,8 +1918,6 @@ class Domain(TimeStampedModel, DomainHelper):
else:
logger.error("Error _delete_hosts_if_not_used, code was %s error was %s" % (e.code, e))
- raise e
-
def _fix_unknown_state(self, cleaned):
"""
_fix_unknown_state: Calls _add_missing_contacts_if_unknown
diff --git a/src/registrar/models/portfolio_invitation.py b/src/registrar/models/portfolio_invitation.py
index 8feeb0794..fafa99856 100644
--- a/src/registrar/models/portfolio_invitation.py
+++ b/src/registrar/models/portfolio_invitation.py
@@ -9,6 +9,13 @@ from .utility.portfolio_helper import (
UserPortfolioPermissionChoices,
UserPortfolioRoleChoices,
cleanup_after_portfolio_member_deletion,
+ get_domain_requests_description_display,
+ get_domain_requests_display,
+ get_domains_description_display,
+ get_domains_display,
+ get_members_description_display,
+ get_members_display,
+ get_role_display,
validate_portfolio_invitation,
) # type: ignore
from .utility.time_stamped_model import TimeStampedModel
@@ -85,6 +92,90 @@ class PortfolioInvitation(TimeStampedModel):
"""
return UserPortfolioPermission.get_portfolio_permissions(self.roles, self.additional_permissions)
+ @property
+ def role_display(self):
+ """
+ Returns a human-readable display name for the user's role.
+
+ Uses the `get_role_display` function to determine if the user is an "Admin",
+ "Basic" member, or has no role assigned.
+
+ Returns:
+ str: The display name of the user's role.
+ """
+ return get_role_display(self.roles)
+
+ @property
+ def domains_display(self):
+ """
+ Returns a string representation of the user's domain access level.
+
+ Uses the `get_domains_display` function to determine whether the user has
+ "Viewer" access (can view all domains) or "Viewer, limited" access.
+
+ Returns:
+ str: The display name of the user's domain permissions.
+ """
+ return get_domains_display(self.roles, self.additional_permissions)
+
+ @property
+ def domains_description_display(self):
+ """
+ Returns a string description of the user's domain access level.
+
+ Returns:
+ str: The display name of the user's domain permissions description.
+ """
+ return get_domains_description_display(self.roles, self.additional_permissions)
+
+ @property
+ def domain_requests_display(self):
+ """
+ Returns a string representation of the user's access to domain requests.
+
+ Uses the `get_domain_requests_display` function to determine if the user
+ is a "Creator" (can create and edit requests), a "Viewer" (can only view requests),
+ or has "No access" to domain requests.
+
+ Returns:
+ str: The display name of the user's domain request permissions.
+ """
+ return get_domain_requests_display(self.roles, self.additional_permissions)
+
+ @property
+ def domain_requests_description_display(self):
+ """
+ Returns a string description of the user's access to domain requests.
+
+ Returns:
+ str: The display name of the user's domain request permissions description.
+ """
+ return get_domain_requests_description_display(self.roles, self.additional_permissions)
+
+ @property
+ def members_display(self):
+ """
+ Returns a string representation of the user's access to managing members.
+
+ Uses the `get_members_display` function to determine if the user is a
+ "Manager" (can edit members), a "Viewer" (can view members), or has "No access"
+ to member management.
+
+ Returns:
+ str: The display name of the user's member management permissions.
+ """
+ return get_members_display(self.roles, self.additional_permissions)
+
+ @property
+ def members_description_display(self):
+ """
+ Returns a string description of the user's access to managing members.
+
+ Returns:
+ str: The display name of the user's member management permissions description.
+ """
+ return get_members_description_display(self.roles, self.additional_permissions)
+
@transition(field="status", source=PortfolioInvitationStatus.INVITED, target=PortfolioInvitationStatus.RETRIEVED)
def retrieve(self):
"""When an invitation is retrieved, create the corresponding permission.
diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py
index 82a0465c5..d5476ab9a 100644
--- a/src/registrar/models/user.py
+++ b/src/registrar/models/user.py
@@ -269,7 +269,7 @@ class User(AbstractUser):
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_REQUESTS)
def is_portfolio_admin(self, portfolio):
- return "Admin" in self.portfolio_role_summary(portfolio)
+ return self.has_edit_portfolio_permission(portfolio)
def get_first_portfolio(self):
permission = self.portfolio_permissions.first()
@@ -277,49 +277,6 @@ class User(AbstractUser):
return permission.portfolio
return None
- def portfolio_role_summary(self, portfolio):
- """Returns a list of roles based on the user's permissions."""
- roles = []
-
- # Define the conditions and their corresponding roles
- conditions_roles = [
- (self.has_edit_portfolio_permission(portfolio), ["Admin"]),
- (
- self.has_view_all_domains_portfolio_permission(portfolio)
- and self.has_any_requests_portfolio_permission(portfolio)
- and self.has_edit_request_portfolio_permission(portfolio),
- ["View-only admin", "Domain requestor"],
- ),
- (
- self.has_view_all_domains_portfolio_permission(portfolio)
- and self.has_any_requests_portfolio_permission(portfolio),
- ["View-only admin"],
- ),
- (
- self.has_view_portfolio_permission(portfolio)
- and self.has_edit_request_portfolio_permission(portfolio)
- and self.has_any_domains_portfolio_permission(portfolio),
- ["Domain requestor", "Domain manager"],
- ),
- (
- self.has_view_portfolio_permission(portfolio) and self.has_edit_request_portfolio_permission(portfolio),
- ["Domain requestor"],
- ),
- (
- self.has_view_portfolio_permission(portfolio) and self.has_any_domains_portfolio_permission(portfolio),
- ["Domain manager"],
- ),
- (self.has_view_portfolio_permission(portfolio), ["Member"]),
- ]
-
- # Evaluate conditions and add roles
- for condition, role_list in conditions_roles:
- if condition:
- roles.extend(role_list)
- break
-
- return roles
-
def get_portfolios(self):
return self.portfolio_permissions.all()
diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py
index 5378dc185..0a758ff6a 100644
--- a/src/registrar/models/user_portfolio_permission.py
+++ b/src/registrar/models/user_portfolio_permission.py
@@ -6,6 +6,13 @@ from registrar.models.utility.portfolio_helper import (
DomainRequestPermissionDisplay,
MemberPermissionDisplay,
cleanup_after_portfolio_member_deletion,
+ get_domain_requests_display,
+ get_domain_requests_description_display,
+ get_domains_display,
+ get_domains_description_display,
+ get_members_display,
+ get_members_description_display,
+ get_role_display,
validate_user_portfolio_permission,
)
from .utility.time_stamped_model import TimeStampedModel
@@ -181,6 +188,90 @@ class UserPortfolioPermission(TimeStampedModel):
# This is the same as portfolio_permissions & common_forbidden_perms.
return portfolio_permissions.intersection(common_forbidden_perms)
+ @property
+ def role_display(self):
+ """
+ Returns a human-readable display name for the user's role.
+
+ Uses the `get_role_display` function to determine if the user is an "Admin",
+ "Basic" member, or has no role assigned.
+
+ Returns:
+ str: The display name of the user's role.
+ """
+ return get_role_display(self.roles)
+
+ @property
+ def domains_display(self):
+ """
+ Returns a string representation of the user's domain access level.
+
+ Uses the `get_domains_display` function to determine whether the user has
+ "Viewer" access (can view all domains) or "Viewer, limited" access.
+
+ Returns:
+ str: The display name of the user's domain permissions.
+ """
+ return get_domains_display(self.roles, self.additional_permissions)
+
+ @property
+ def domains_description_display(self):
+ """
+ Returns a string description of the user's domain access level.
+
+ Returns:
+ str: The display name of the user's domain permissions description.
+ """
+ return get_domains_description_display(self.roles, self.additional_permissions)
+
+ @property
+ def domain_requests_display(self):
+ """
+ Returns a string representation of the user's access to domain requests.
+
+ Uses the `get_domain_requests_display` function to determine if the user
+ is a "Creator" (can create and edit requests), a "Viewer" (can only view requests),
+ or has "No access" to domain requests.
+
+ Returns:
+ str: The display name of the user's domain request permissions.
+ """
+ return get_domain_requests_display(self.roles, self.additional_permissions)
+
+ @property
+ def domain_requests_description_display(self):
+ """
+ Returns a string description of the user's access to domain requests.
+
+ Returns:
+ str: The display name of the user's domain request permissions description.
+ """
+ return get_domain_requests_description_display(self.roles, self.additional_permissions)
+
+ @property
+ def members_display(self):
+ """
+ Returns a string representation of the user's access to managing members.
+
+ Uses the `get_members_display` function to determine if the user is a
+ "Manager" (can edit members), a "Viewer" (can view members), or has "No access"
+ to member management.
+
+ Returns:
+ str: The display name of the user's member management permissions.
+ """
+ return get_members_display(self.roles, self.additional_permissions)
+
+ @property
+ def members_description_display(self):
+ """
+ Returns a string description of the user's access to managing members.
+
+ Returns:
+ str: The display name of the user's member management permissions description.
+ """
+ return get_members_description_display(self.roles, self.additional_permissions)
+
def clean(self):
"""Extends clean method to perform additional validation, which can raise errors in django admin."""
super().clean()
diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py
index 5feae1cc1..e94733fb6 100644
--- a/src/registrar/models/utility/portfolio_helper.py
+++ b/src/registrar/models/utility/portfolio_helper.py
@@ -79,6 +79,161 @@ class MemberPermissionDisplay(StrEnum):
NONE = "None"
+def get_role_display(roles):
+ """
+ Returns a user-friendly display name for a given list of user roles.
+
+ - If the user has the ORGANIZATION_ADMIN role, return "Admin".
+ - If the user has the ORGANIZATION_MEMBER role, return "Basic".
+ - If the user has neither role, return "-".
+
+ Args:
+ roles (list): A list of role strings assigned to the user.
+
+ Returns:
+ str: The display name for the highest applicable role.
+ """
+ if UserPortfolioRoleChoices.ORGANIZATION_ADMIN in roles:
+ return "Admin"
+ elif UserPortfolioRoleChoices.ORGANIZATION_MEMBER in roles:
+ return "Basic"
+ else:
+ return "-"
+
+
+def get_domains_display(roles, permissions):
+ """
+ Determines the display name for a user's domain viewing permissions.
+
+ - If the user has the VIEW_ALL_DOMAINS permission, return "Viewer".
+ - Otherwise, return "Viewer, limited".
+
+ Args:
+ roles (list): A list of role strings assigned to the user.
+ permissions (list): A list of additional permissions assigned to the user.
+
+ Returns:
+ str: A string representing the user's domain viewing access.
+ """
+ UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
+ all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions)
+ if UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS in all_permissions:
+ return "Viewer"
+ else:
+ return "Viewer, limited"
+
+
+def get_domains_description_display(roles, permissions):
+ """
+ Determines the display description for a user's domain viewing permissions.
+
+ Args:
+ roles (list): A list of role strings assigned to the user.
+ permissions (list): A list of additional permissions assigned to the user.
+
+ Returns:
+ str: A string representing the user's domain viewing access description.
+ """
+ UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
+ all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions)
+ if UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS in all_permissions:
+ return "Can view all domains for the organization"
+ else:
+ return "Can view only the domains they manage"
+
+
+def get_domain_requests_display(roles, permissions):
+ """
+ Determines the display name for a user's domain request permissions.
+
+ - If the user has the EDIT_REQUESTS permission, return "Creator".
+ - If the user has the VIEW_ALL_REQUESTS permission, return "Viewer".
+ - Otherwise, return "No access".
+
+ Args:
+ roles (list): A list of role strings assigned to the user.
+ permissions (list): A list of additional permissions assigned to the user.
+
+ Returns:
+ str: A string representing the user's domain request access level.
+ """
+ UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
+ all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions)
+ if UserPortfolioPermissionChoices.EDIT_REQUESTS in all_permissions:
+ return "Creator"
+ elif UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS in all_permissions:
+ return "Viewer"
+ else:
+ return "No access"
+
+
+def get_domain_requests_description_display(roles, permissions):
+ """
+ Determines the display description for a user's domain request permissions.
+
+ Args:
+ roles (list): A list of role strings assigned to the user.
+ permissions (list): A list of additional permissions assigned to the user.
+
+ Returns:
+ str: A string representing the user's domain request access level description.
+ """
+ UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
+ all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions)
+ if UserPortfolioPermissionChoices.EDIT_REQUESTS in all_permissions:
+ return "Can view all domain requests for the organization and create requests"
+ elif UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS in all_permissions:
+ return "Can view all domain requests for the organization"
+ else:
+ return "Cannot view or create domain requests"
+
+
+def get_members_display(roles, permissions):
+ """
+ Determines the display name for a user's member management permissions.
+
+ - If the user has the EDIT_MEMBERS permission, return "Manager".
+ - If the user has the VIEW_MEMBERS permission, return "Viewer".
+ - Otherwise, return "No access".
+
+ Args:
+ roles (list): A list of role strings assigned to the user.
+ permissions (list): A list of additional permissions assigned to the user.
+
+ Returns:
+ str: A string representing the user's member management access level.
+ """
+ UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
+ all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions)
+ if UserPortfolioPermissionChoices.EDIT_MEMBERS in all_permissions:
+ return "Manager"
+ elif UserPortfolioPermissionChoices.VIEW_MEMBERS in all_permissions:
+ return "Viewer"
+ else:
+ return "No access"
+
+
+def get_members_description_display(roles, permissions):
+ """
+ Determines the display description for a user's member management permissions.
+
+ Args:
+ roles (list): A list of role strings assigned to the user.
+ permissions (list): A list of additional permissions assigned to the user.
+
+ Returns:
+ str: A string representing the user's member management access level description.
+ """
+ UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
+ all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions)
+ if UserPortfolioPermissionChoices.EDIT_MEMBERS in all_permissions:
+ return "Can view and manage all member permissions"
+ elif UserPortfolioPermissionChoices.VIEW_MEMBERS in all_permissions:
+ return "Can view all member permissions"
+ else:
+ return "Cannot view member permissions"
+
+
def validate_user_portfolio_permission(user_portfolio_permission):
"""
Validates a UserPortfolioPermission instance. Located in portfolio_helper to avoid circular imports
diff --git a/src/registrar/registrar_middleware.py b/src/registrar/registrar_middleware.py
index ed7a4dffc..8c9c79876 100644
--- a/src/registrar/registrar_middleware.py
+++ b/src/registrar/registrar_middleware.py
@@ -3,9 +3,13 @@ Contains middleware used in settings.py
"""
import logging
+import re
from urllib.parse import parse_qs
+from django.conf import settings
+from django.core.exceptions import PermissionDenied
from django.urls import reverse
from django.http import HttpResponseRedirect
+from django.urls import resolve
from registrar.models import User
from waffle.decorators import flag_is_active
@@ -170,3 +174,51 @@ class CheckPortfolioMiddleware:
request.session["portfolio"] = request.user.get_first_portfolio()
else:
request.session["portfolio"] = request.user.get_first_portfolio()
+
+
+class RestrictAccessMiddleware:
+ """
+ Middleware that blocks access to all views unless explicitly permitted.
+
+ This middleware enforces authentication by default. Views must explicitly allow access
+ using access control mechanisms such as the `@grant_access` decorator. Exceptions are made
+ for Django admin views, explicitly ignored paths, and views that opt out of login requirements.
+ """
+
+ def __init__(self, get_response):
+ self.get_response = get_response
+ # Compile regex patterns from settings to identify paths that bypass login requirements
+ self.ignored_paths = [re.compile(pattern) for pattern in getattr(settings, "LOGIN_REQUIRED_IGNORE_PATHS", [])]
+
+ def __call__(self, request):
+
+ # Allow requests to Django Debug Toolbar
+ if request.path.startswith("/__debug__/"):
+ return self.get_response(request)
+
+ # Allow requests matching configured ignored paths
+ if any(pattern.match(request.path) for pattern in self.ignored_paths):
+ return self.get_response(request)
+
+ # Attempt to resolve the request path to a view function
+ try:
+ resolver_match = resolve(request.path_info)
+ view_func = resolver_match.func
+ app_name = resolver_match.app_name # Get the app name of the resolved view
+ except Exception:
+ # If resolution fails, allow the request to proceed (avoid blocking non-view routes)
+ return self.get_response(request)
+
+ # Automatically allow access to Django's built-in admin views (excluding custom /admin/* views)
+ if app_name == "admin":
+ return self.get_response(request)
+
+ # Allow access if the view explicitly opts out of login requirements
+ if getattr(view_func, "login_required", True) is False:
+ return self.get_response(request)
+
+ # Restrict access to views that do not explicitly declare access rules
+ if not getattr(view_func, "has_explicit_access", False):
+ raise PermissionDenied # Deny access if the view lacks explicit permission handling
+
+ return self.get_response(request)
diff --git a/src/registrar/templates/admin/analytics.html b/src/registrar/templates/admin/analytics.html
index ccfd54d05..fdebff22c 100644
--- a/src/registrar/templates/admin/analytics.html
+++ b/src/registrar/templates/admin/analytics.html
@@ -18,7 +18,7 @@ https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/
{% block content %}
-
diff --git a/src/registrar/templates/admin/analytics_graph_table.html b/src/registrar/templates/admin/analytics_graph_table.html
new file mode 100644
index 000000000..5f10da93a
--- /dev/null
+++ b/src/registrar/templates/admin/analytics_graph_table.html
@@ -0,0 +1,26 @@
+
+
+
+ Type |
+ Start date {{ data.start_date }} |
+ End date {{ data.end_date }} |
+
+
+
+ {% comment %}
+ This ugly notation is equivalent to data.property_name.start_date_count.index.
+ Or represented in the pure python way: data[property_name]["start_date_count"][index]
+ {% endcomment %}
+ {% with start_counts=data|get_item:property_name|get_item:"start_date_count" end_counts=data|get_item:property_name|get_item:"end_date_count" %}
+ {% for org_count_type in data.org_count_types %}
+ {% with index=forloop.counter %}
+
+ {{ org_count_type }} |
+ {{ start_counts|slice:index|last }} |
+ {{ end_counts|slice:index|last }} |
+
+ {% endwith %}
+ {% endfor %}
+ {% endwith %}
+
+
diff --git a/src/registrar/templates/admin/app_list.html b/src/registrar/templates/admin/app_list.html
index aaf3dc423..ecce12a3e 100644
--- a/src/registrar/templates/admin/app_list.html
+++ b/src/registrar/templates/admin/app_list.html
@@ -4,24 +4,22 @@
{% for app in app_list %}
-
- {# .gov override: add headers #}
- {% if show_changelinks %}
-
- {% else %}
-
- {% endif %}
+ {# .gov override: display the app name as a caption rather than a table header #}
+ {{ app.name }}
- {% if show_changelinks %}
-
- {{ app.name }}
- |
- {% else %}
-
- {{ app.name }}
- |
- {% endif %}
+ {# .gov override: hide headers #}
+ {% comment %}
+ {% if show_changelinks %}
+
+ {{ app.name }}
+ |
+ {% else %}
+
+ {{ app.name }}
+ |
+ {% endif %}
+ {% endcomment %}
Model |
@@ -45,16 +43,17 @@
{% endif %}
{% if model.add_url %}
- {% translate 'Add' %} |
+ {% comment %} Remove the 's' from the end of the string to avoid text like "Add domain requests" {% endcomment %}
+ {% translate 'Add' %} |
{% else %}
|
{% endif %}
{% if model.admin_url and show_changelinks %}
{% if model.view_only %}
- {% translate 'View' %} |
+ {% translate 'View' %} |
{% else %}
- {% translate 'Change' %} |
+ {% translate 'Change' %} |
{% endif %}
{% elif show_changelinks %}
|
@@ -64,9 +63,20 @@
{% endfor %}
-
-
Analytics
-
Dashboard
+
+
+ Analytics
+
+
+ Reports |
+
+
+
+
+ Dashboard |
+
+
+
{% else %}
{% translate 'You don’t have permission to view or edit anything.' %}
diff --git a/src/registrar/templates/admin/base_site.html b/src/registrar/templates/admin/base_site.html
index b80917bb2..a8ef438f9 100644
--- a/src/registrar/templates/admin/base_site.html
+++ b/src/registrar/templates/admin/base_site.html
@@ -22,7 +22,6 @@
-
{% endblock %}
@@ -49,6 +48,10 @@
{% endwith %}
{% endif %}
+ {% if opts.model_name %}
+
Skip to filters
+ {% endif %}
+
{# Djando update: this div will change to header #}