Merge branch 'main' into dk/2789-member-page

This commit is contained in:
David Kennedy 2024-10-08 15:04:45 -04:00
commit 0dbaf1ea97
No known key found for this signature in database
GPG key ID: 6528A5386E66B96B
52 changed files with 1312 additions and 571 deletions

View file

@ -14,6 +14,7 @@ on:
options:
- ab
- backup
- el
- cb
- dk
- es

View file

@ -30,6 +30,7 @@ jobs:
|| startsWith(github.head_ref, 'ag/')
|| startsWith(github.head_ref, 'ms/')
|| startsWith(github.head_ref, 'ad/')
|| startsWith(github.head_ref, 'el/')
outputs:
environment: ${{ steps.var.outputs.environment}}
runs-on: "ubuntu-latest"

View file

@ -16,6 +16,7 @@ on:
- stable
- staging
- development
- el
- ad
- ms
- ag

View file

@ -16,6 +16,7 @@ on:
options:
- staging
- development
- el
- ad
- ms
- ag

View file

@ -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

View file

@ -0,0 +1,32 @@
---
applications:
- name: getgov-el
buildpacks:
- python_buildpack
path: ../../src
instances: 1
memory: 512M
stack: cflinuxfs4
timeout: 180
command: ./run.sh
health-check-type: http
health-check-http-endpoint: /health
health-check-invocation-timeout: 40
env:
# Send stdout and stderr straight to the terminal without buffering
PYTHONUNBUFFERED: yup
# Tell Django where to find its configuration
DJANGO_SETTINGS_MODULE: registrar.config.settings
# Tell Django where it is being hosted
DJANGO_BASE_URL: https://getgov-el.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# default public site location
GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments
IS_PRODUCTION: False
routes:
- route: getgov-el.app.cloud.gov
services:
- getgov-credentials
- getgov-el-database

View file

@ -8,9 +8,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.domain_invitation import DomainInvitation
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models import DomainInformation, Portfolio, UserPortfolioPermission, DomainInvitation
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from waffle.decorators import flag_is_active
from django.contrib import admin, messages
@ -22,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
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
@ -757,9 +759,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 = (
(
@ -782,6 +785,7 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
},
),
("Important dates", {"fields": ("last_login", "date_joined")}),
("Associated portfolios", {"fields": ("portfolios",)}),
)
# TODO: delete after we merge organization feature
@ -861,6 +865,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", msg_for_none="No portfolios.")
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,
@ -1257,9 +1269,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" # type: ignore
class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin):
@ -1543,33 +1564,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 = [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 +1859,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
@ -3006,39 +2973,59 @@ 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}"
(None, {"fields": ["portfolio_type", "organization_name", "creator", "created_on", "notes"]}),
("Portfolio members", {"fields": ["display_admins", "display_members"]}),
("Portfolio domains", {"fields": ["domains", "domain_requests"]}),
# created_on is the created_at field
(None, {"fields": ["creator", "created_on", "notes"]}),
("Type of organization", {"fields": ["organization_type", "federal_type"]}),
(
"Organization name and mailing address",
{
"fields": [
"organization_name",
"federal_agency",
]
},
),
(
"Show details",
{
"classes": ["collapse--dgfieldset"],
"description": "Extends organization name and mailing address",
"fields": [
"state_territory",
"address_line1",
"address_line2",
"city",
"zipcode",
"urbanization",
]
],
},
),
("Portfolio members", {"fields": ["display_admins", "display_members"]}),
("Domains and requests", {"fields": ["domains", "domain_requests"]}),
("Suborganizations", {"fields": ["suborganizations"]}),
("Senior official", {"fields": ["senior_official"]}),
]
# This is the fieldset display when adding a new model
add_fieldsets = [
(None, {"fields": ["organization_name", "creator", "notes"]}),
(None, {"fields": ["creator", "notes"]}),
("Type of organization", {"fields": ["organization_type"]}),
(
"Organization name and mailing address",
{
"fields": [
"organization_name",
"federal_agency",
"state_territory",
"address_line1",
@ -3052,7 +3039,7 @@ 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."
readonly_fields = [
@ -3065,23 +3052,35 @@ class PortfolioAdmin(ListHeaderAdmin):
"domains",
"domain_requests",
"suborganizations",
"portfolio_type",
"display_admins",
"display_members",
"creator",
# As of now this means that only federal agency can update this, but this will change.
"senior_official",
]
analyst_readonly_fields = [
"organization_name",
]
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."""
if obj:
return obj.portfolio_users.filter(
portfolio=obj, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
else:
return []
def get_non_admin_users(self, obj):
# Filter UserPortfolioPermission objects related to the portfolio that do NOT have the "Admin" role
non_admin_permissions = UserPortfolioPermission.objects.filter(portfolio=obj).exclude(
@ -3093,82 +3092,12 @@ class PortfolioAdmin(ListHeaderAdmin):
return non_admin_users
def display_admins(self, obj):
"""Get joined users who are Admin, unpack and return an HTML block.
'DJA readonly can't handle querysets, so we need to unpack and return html here.
Alternatively, we could return querysets in context but that would limit where this
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)
if not admins:
return format_html("<p>No admins found.</p>")
admin_details = ""
for portfolio_admin in admins:
change_url = reverse("admin:registrar_user_change", args=[portfolio_admin.pk])
admin_details += "<address class='margin-bottom-2 dja-address-contact-list'>"
admin_details += f'<a href="{change_url}">{escape(portfolio_admin)}</a><br>'
admin_details += f"{escape(portfolio_admin.title)}<br>"
admin_details += f"{escape(portfolio_admin.email)}"
admin_details += "<div class='admin-icon-group admin-icon-group__clipboard-link'>"
admin_details += f"<input aria-hidden='true' class='display-none' value='{escape(portfolio_admin.email)}'>"
admin_details += (
"<button class='usa-button usa-button--unstyled padding-right-1 usa-button--icon padding-left-05"
+ "button--clipboard copy-to-clipboard text-no-underline' type='button'>"
)
admin_details += "<svg class='usa-icon'>"
admin_details += "<use aria-hidden='true' xlink:href='/public/img/sprite.svg#content_copy'></use>"
admin_details += "</svg>"
admin_details += "Copy"
admin_details += "</button>"
admin_details += "</div><br>"
admin_details += f"{escape(portfolio_admin.phone)}"
admin_details += "</address>"
return format_html(admin_details)
display_admins.short_description = "Administrators" # type: ignore
def display_members(self, obj):
"""Get joined users who have roles/perms that are not Admin, unpack and return an HTML block.
DJA readonly can't handle querysets, so we need to unpack and return html here.
Alternatively, we could return querysets in context but that would limit where this
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)
if not members:
return ""
member_details = (
"<table><thead><tr><th>Name</th><th>Title</th><th>Email</th>"
+ "<th>Phone</th><th>Roles</th></tr></thead><tbody>"
)
for member in members:
full_name = member.get_formatted_name()
member_details += "<tr>"
member_details += f"<td>{escape(full_name)}</td>"
member_details += f"<td>{escape(member.title)}</td>"
member_details += f"<td>{escape(member.email)}</td>"
member_details += f"<td>{escape(member.phone)}</td>"
member_details += "<td>"
for role in member.portfolio_role_summary(obj):
member_details += f"<span class='usa-tag'>{escape(role)}</span> "
member_details += "</td></tr>"
member_details += "</tbody></table>"
return format_html(member_details)
display_members.short_description = "Members" # type: ignore
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)
if not members:
return {}
return self.get_field_links_as_list(members, "user", separator=", ")
def get_user_portfolio_permission_non_admins(self, obj):
"""Returns each admin on UserPortfolioPermission for a given portfolio."""
if obj:
return obj.portfolio_users.exclude(roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN])
else:
return []
def federal_type(self, obj: models.Portfolio):
"""Returns the federal_type field"""
@ -3183,16 +3112,10 @@ class PortfolioAdmin(ListHeaderAdmin):
created_on.short_description = "Created on" # type: ignore
def portfolio_type(self, obj: models.Portfolio):
"""Returns the portfolio type, or "-" if the result is empty"""
return obj.portfolio_type if obj.portfolio_type else "-"
portfolio_type.short_description = "Portfolio type" # type: ignore
def suborganizations(self, obj: models.Portfolio):
"""Returns a list of links for each related suborg"""
queryset = obj.get_suborganizations()
return self.get_field_links_as_list(queryset, "suborganization")
return get_field_links_as_list(queryset, "suborganization")
suborganizations.short_description = "Suborganizations" # type: ignore
@ -3221,6 +3144,28 @@ class PortfolioAdmin(ListHeaderAdmin):
domain_requests.short_description = "Domain requests" # type: ignore
def display_admins(self, obj):
"""Returns the number of administrators for this portfolio"""
admin_count = len(self.get_user_portfolio_permission_admins(obj))
if admin_count > 0:
url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={obj.id}"
# Create a clickable link with the count
return format_html(f'<a href="{url}">{admin_count} administrators</a>')
return "No administrators found."
display_admins.short_description = "Administrators" # type: ignore
def display_members(self, obj):
"""Returns the number of members for this portfolio"""
member_count = len(self.get_user_portfolio_permission_non_admins(obj))
if member_count > 0:
url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={obj.id}"
# Create a clickable link with the count
return format_html(f'<a href="{url}">{member_count} members</a>')
return "No additional members found."
display_members.short_description = "Members" # type: ignore
# Creates select2 fields (with search bars)
autocomplete_fields = [
"creator",
@ -3228,59 +3173,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'<a href="{change_url}">{escape(item_display_value)}</a>'
if link_info_attribute:
link += f" ({self.value_of_attribute(item, link_info_attribute)})"
if separator:
links.append(link)
else:
links.append(f"<li>{link}</li>")
# 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'<ul class="add-list-reset">{links}</ul>') 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
@ -3313,10 +3205,15 @@ 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["display_members_summary"] = self.display_members_summary(obj)
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):
@ -3333,6 +3230,14 @@ class PortfolioAdmin(ListHeaderAdmin):
is_federal = obj.organization_type == DomainRequest.OrganizationChoices.FEDERAL
if is_federal and obj.organization_name is None:
obj.organization_name = obj.federal_agency.agency
# Remove this line when senior_official is no longer readonly in /admin.
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)
@ -3347,7 +3252,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]
@ -3404,6 +3309,7 @@ class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"portfolio",
]
search_fields = ["name"]
search_help_text = "Search by suborganization."
change_form_template = "django/admin/suborg_change_form.html"

View file

@ -802,10 +802,15 @@ document.addEventListener('DOMContentLoaded', function() {
// $ 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 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);
handleFederalAgencyChange($federalAgency, organizationType, readonlyOrganizationType, organizationNameContainer, federalType);
});
}
@ -821,9 +826,33 @@ document.addEventListener('DOMContentLoaded', function() {
handleStateTerritoryChange(stateTerritory, urbanizationField);
});
}
// Handle hiding the organization name field when the organization_type is federal.
// Run this first one page load, then secondly on a change event.
handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType);
organizationType.addEventListener("change", function() {
handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType);
});
});
function handleFederalAgencyChange(federalAgency, organizationType) {
function handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType) {
if (organizationType && organizationNameContainer) {
let selectedValue = organizationType.value;
if (selectedValue === "federal") {
hideElement(organizationNameContainer);
if (federalType) {
showElement(federalType);
}
} else {
showElement(organizationNameContainer);
if (federalType) {
hideElement(federalType);
}
}
}
}
function handleFederalAgencyChange(federalAgency, organizationType, readonlyOrganizationType, organizationNameContainer, federalType) {
// Don't do anything on page load
if (isInitialPageLoad) {
isInitialPageLoad = false;
@ -838,27 +867,31 @@ document.addEventListener('DOMContentLoaded', function() {
return;
}
let organizationTypeValue = organizationType ? organizationType.value : readonlyOrganizationType.innerText.toLowerCase();
if (selectedText !== "Non-Federal Agency") {
if (organizationType.value !== "federal") {
organizationType.value = "federal";
if (organizationTypeValue !== "federal") {
if (organizationType){
organizationType.value = "federal";
}else {
readonlyOrganizationType.innerText = "Federal"
}
}
}else {
if (organizationType.value === "federal") {
organizationType.value = "";
if (organizationTypeValue === "federal") {
if (organizationType){
organizationType.value = "";
}else {
readonlyOrganizationType.innerText = "-"
}
}
}
// Get the associated senior official with this federal agency
let $seniorOfficial = django.jQuery("#id_senior_official");
if (!$seniorOfficial) {
console.log("Could not find the senior official field");
return;
}
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;
fetch(`${federalPortfolioApi}?organization_type=${organizationType.value}&agency_name=${selectedText}`)
fetch(`${federalPortfolioApi}?&agency_name=${selectedText}`)
.then(response => {
const statusCode = response.status;
return response.json().then(data => ({ statusCode, data }));
@ -869,7 +902,6 @@ document.addEventListener('DOMContentLoaded', function() {
return;
}
updateReadOnly(data.federal_type, '.field-federal_type');
updateReadOnly(data.portfolio_type, '.field-portfolio_type');
})
.catch(error => console.error("Error fetching federal and portfolio types: ", error));
@ -877,6 +909,9 @@ document.addEventListener('DOMContentLoaded', function() {
// 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;
fetch(`${seniorOfficialApi}?agency_name=${selectedText}`)
.then(response => {
@ -887,7 +922,12 @@ document.addEventListener('DOMContentLoaded', function() {
if (data.error) {
// Clear the field if the SO doesn't exist.
if (statusCode === 404) {
$seniorOfficial.val("").trigger("change");
if ($seniorOfficial && $seniorOfficial.length > 0) {
$seniorOfficial.val("").trigger("change");
}else {
// Show the "create one now" text if this field is none in readonly mode.
readonlySeniorOfficial.innerHTML = `<a href="${seniorOfficialAddUrl}">No senior official found. Create one now.</a>`;
}
console.warn("Record not found: " + data.error);
}else {
console.error("Error in AJAX call: " + data.error);
@ -898,30 +938,43 @@ document.addEventListener('DOMContentLoaded', function() {
// Update the "contact details" blurb beneath senior official
updateContactInfo(data);
showElement(contactList.parentElement);
// Get the associated senior official with this federal agency
let seniorOfficialId = data.id;
let seniorOfficialName = [data.first_name, data.last_name].join(" ");
if (!seniorOfficialId || !seniorOfficialName || !seniorOfficialName.trim()){
// Clear the field if the SO doesn't exist
$seniorOfficial.val("").trigger("change");
return;
}
// Add the senior official to the dropdown.
// This format supports select2 - if we decide to convert this field in the future.
if ($seniorOfficial.find(`option[value='${seniorOfficialId}']`).length) {
// Select the value that is associated with the current Senior Official.
$seniorOfficial.val(seniorOfficialId).trigger("change");
} else {
// Create a DOM Option that matches the desired Senior Official. Then append it and select it.
let userOption = new Option(seniorOfficialName, seniorOfficialId, true, true);
$seniorOfficial.append(userOption).trigger("change");
if ($seniorOfficial && $seniorOfficial.length > 0) {
// If the senior official is a dropdown field, edit that
updateSeniorOfficialDropdown($seniorOfficial, seniorOfficialId, seniorOfficialName);
}else {
if (readonlySeniorOfficial) {
let seniorOfficialLink = `<a href=/admin/registrar/seniorofficial/${seniorOfficialId}/change/>${seniorOfficialName}</a>`
readonlySeniorOfficial.innerHTML = seniorOfficialName ? seniorOfficialLink : "-";
}
}
})
.catch(error => console.error("Error fetching senior official: ", error));
}
function updateSeniorOfficialDropdown(dropdown, seniorOfficialId, seniorOfficialName) {
if (!seniorOfficialId || !seniorOfficialName || !seniorOfficialName.trim()){
// Clear the field if the SO doesn't exist
dropdown.val("").trigger("change");
return;
}
// Add the senior official to the dropdown.
// This format supports select2 - if we decide to convert this field in the future.
if (dropdown.find(`option[value='${seniorOfficialId}']`).length) {
// Select the value that is associated with the current Senior Official.
dropdown.val(seniorOfficialId).trigger("change");
} else {
// Create a DOM Option that matches the desired Senior Official. Then append it and select it.
let userOption = new Option(seniorOfficialName, seniorOfficialId, true, true);
dropdown.append(userOption).trigger("change");
}
}
function handleStateTerritoryChange(stateTerritory, urbanizationField) {
let selectedValue = stateTerritory.value;
if (selectedValue === "PR") {

View file

@ -1498,12 +1498,23 @@ class DomainsTable extends LoadTableBase {
}
}
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) {
const exportButton = document.getElementById('export-csv');
if (exportButton) {
if (requests.length > 0) {
showElement(exportButton);
} else {
hideElement(exportButton);
}
}
}
/**
* Loads rows in the domains list, as well as updates pagination around the domains list
* based on the supplied attributes.
@ -1517,6 +1528,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 +1560,9 @@ class DomainRequestsTable extends LoadTableBase {
return;
}
// Manage "export as CSV" visibility for domain requests
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);

View file

@ -455,7 +455,8 @@ details.dja-detail-table {
background-color: var(--body-bg);
.dja-details-summary {
cursor: pointer;
color: var(--body-quiet-color);
color: var(--link-fg);
text-decoration: underline;
}
@media (max-width: 1024px){
@ -922,4 +923,9 @@ ul.add-list-reset {
overflow: visible;
word-break: break-all;
max-width: 100%;
}
}
.organization-admin-label {
font-weight: 600;
font-size: .8125rem;
}

View file

@ -68,21 +68,6 @@ legend.float-left-tablet + button.float-right-tablet {
}
}
// Custom style for disabled inputs
@media (prefers-color-scheme: light) {
.usa-input:disabled, .usa-select:disabled, .usa-textarea:disabled {
background-color: #eeeeee;
color: #666666;
}
}
@media (prefers-color-scheme: dark) {
.usa-input:disabled, .usa-select:disabled, .usa-textarea:disabled {
background-color: var(--body-fg);
color: var(--close-button-hover-bg);
}
}
.read-only-label {
font-size: size('body', 'sm');
color: color('primary-dark');

View file

@ -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)
@ -721,6 +723,7 @@ ALLOWED_HOSTS = [
"getgov-stable.app.cloud.gov",
"getgov-staging.app.cloud.gov",
"getgov-development.app.cloud.gov",
"getgov-el.app.cloud.gov",
"getgov-ad.app.cloud.gov",
"getgov-ms.app.cloud.gov",
"getgov-ag.app.cloud.gov",

View file

@ -20,6 +20,7 @@ from registrar.views.report_views import (
AnalyticsView,
ExportDomainRequestDataFull,
ExportDataTypeUser,
ExportDataTypeRequests,
)
# --jsons
@ -197,6 +198,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/<id>/edit/",
views.DomainRequestWizard.as_view(),

View file

@ -36,13 +36,13 @@ 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"""
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:

View file

@ -0,0 +1,146 @@
# Generated by Django 4.2.10 on 2024-09-30 17:59
from django.db import migrations, models
import django.db.models.deletion
import registrar.models.federal_agency
class Migration(migrations.Migration):
dependencies = [
("registrar", "0129_alter_portfolioinvitation_portfolio_roles_and_more"),
]
operations = [
migrations.RenameField(
model_name="federalagency",
old_name="initials",
new_name="acronym",
),
migrations.AlterField(
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="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",
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",
field=models.ForeignKey(
default=registrar.models.federal_agency.FederalAgency.get_non_federal_agency,
on_delete=django.db.models.deletion.PROTECT,
to="registrar.federalagency",
),
),
migrations.AlterField(
model_name="portfolio",
name="organization_name",
field=models.CharField(blank=True, null=True),
),
migrations.AlterField(
model_name="portfolio",
name="organization_type",
field=models.CharField(
blank=True,
choices=[
("federal", "Federal"),
("interstate", "Interstate"),
("state_or_territory", "State or territory"),
("tribal", "Tribal"),
("county", "County"),
("city", "City"),
("special_district", "Special district"),
("school_district", "School district"),
],
max_length=255,
null=True,
),
),
migrations.AlterField(
model_name="portfolio",
name="senior_official",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="portfolios",
to="registrar.seniorofficial",
),
),
migrations.AlterField(
model_name="suborganization",
name="name",
field=models.CharField(max_length=1000, unique=True, verbose_name="Suborganization"),
),
]

View file

@ -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", "0130_remove_federalagency_initials_federalagency_acronym_and_more"),
]
operations = [
migrations.RunPython(
create_groups,
reverse_code=migrations.RunPython.noop,
atomic=True,
),
]

View file

@ -0,0 +1,36 @@
# Generated by Django 4.2.10 on 2024-10-02 14:17
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("registrar", "0131_create_groups_v17"),
]
operations = [
migrations.AlterField(
model_name="domaininformation",
name="portfolio",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="information_portfolio",
to="registrar.portfolio",
),
),
migrations.AlterField(
model_name="domainrequest",
name="portfolio",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="DomainRequest_portfolio",
to="registrar.portfolio",
),
),
]

View file

@ -63,7 +63,6 @@ class DomainInformation(TimeStampedModel):
null=True,
blank=True,
related_name="information_portfolio",
help_text="Portfolio associated with this domain",
)
sub_organization = models.ForeignKey(
@ -72,7 +71,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(

View file

@ -327,7 +327,6 @@ class DomainRequest(TimeStampedModel):
null=True,
blank=True,
related_name="DomainRequest_portfolio",
help_text="Portfolio associated with this domain request",
)
sub_organization = models.ForeignKey(
@ -336,7 +335,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.

View file

@ -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:

View file

@ -2,7 +2,6 @@ from django.db import models
from registrar.models.domain_request import DomainRequest
from registrar.models.federal_agency import FederalAgency
from registrar.utility.constants import BranchChoices
from .utility.time_stamped_model import TimeStampedModel
@ -34,7 +33,6 @@ class Portfolio(TimeStampedModel):
organization_name = models.CharField(
null=True,
blank=True,
verbose_name="Portfolio organization",
)
organization_type = models.CharField(
@ -42,7 +40,6 @@ class Portfolio(TimeStampedModel):
choices=OrganizationChoices.choices,
null=True,
blank=True,
help_text="Type of organization",
)
notes = models.TextField(
@ -53,7 +50,6 @@ class Portfolio(TimeStampedModel):
federal_agency = models.ForeignKey(
"registrar.FederalAgency",
on_delete=models.PROTECT,
help_text="Associated federal agency",
unique=False,
default=FederalAgency.get_non_federal_agency,
)
@ -64,6 +60,7 @@ class Portfolio(TimeStampedModel):
unique=False,
null=True,
blank=True,
related_name="portfolios",
)
address_line1 = models.CharField(
@ -125,23 +122,6 @@ class Portfolio(TimeStampedModel):
super().save(*args, **kwargs)
@property
def portfolio_type(self):
"""
Returns a combination of organization_type / federal_type, seperated by ' - '.
If no federal_type is found, we just return the org type.
"""
return self.get_portfolio_type(self.organization_type, self.federal_type)
@classmethod
def get_portfolio_type(cls, organization_type, federal_type):
org_type_label = cls.OrganizationChoices.get_org_label(organization_type)
agency_type_label = BranchChoices.get_branch_label(federal_type)
if organization_type == cls.OrganizationChoices.FEDERAL and agency_type_label:
return " - ".join([org_type_label, agency_type_label])
else:
return org_type_label
@property
def federal_type(self):
"""Returns the federal_type value on the underlying federal_agency field"""
@ -152,13 +132,19 @@ 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):

View file

@ -10,7 +10,7 @@ class Suborganization(TimeStampedModel):
name = models.CharField(
unique=True,
max_length=1000,
help_text="Suborganization",
verbose_name="Suborganization",
)
portfolio = models.ForeignKey(

View file

@ -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("id", flat=True)

View file

@ -66,6 +66,30 @@ 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": "seniorofficial",
"permissions": ["add_seniorofficial", "change_seniorofficial", "delete_seniorofficial"],
},
{
"app_label": "registrar",
"model": "userportfoliopermission",
"permissions": [
"add_userportfoliopermission",
"change_userportfoliopermission",
"delete_userportfoliopermission",
],
},
]
# Avoid error: You can't execute queries until the end

View file

@ -66,7 +66,19 @@ class UserPortfolioPermission(TimeStampedModel):
)
def __str__(self):
return f"User '{self.user}' on Portfolio '{self.portfolio}' " f"<Roles: {self.roles}>" if self.roles else ""
readable_roles = []
if self.roles:
readable_roles = self.get_readable_roles()
return f"{self.user}" f" <Roles: {', '.join(readable_roles)}>" 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_managed_domains_count(self):
"""Return the count of domains managed by the user for this portfolio."""
@ -102,7 +114,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.

View file

@ -334,3 +334,12 @@ 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

View file

@ -9,6 +9,10 @@ class UserPortfolioRoleChoices(models.TextChoices):
ORGANIZATION_ADMIN = "organization_admin", "Admin"
ORGANIZATION_MEMBER = "organization_member", "Member"
@classmethod
def get_user_portfolio_role_label(cls, user_portfolio_role):
return cls(user_portfolio_role).label if user_portfolio_role else None
class UserPortfolioPermissionChoices(models.TextChoices):
""" """
@ -28,3 +32,7 @@ class UserPortfolioPermissionChoices(models.TextChoices):
# Domain: field specific permissions
VIEW_SUBORGANIZATION = "view_suborganization", "View suborganization"
EDIT_SUBORGANIZATION = "edit_suborganization", "Edit suborganization"
@classmethod
def get_user_portfolio_permission_label(cls, user_portfolio_permission):
return cls(user_portfolio_permission).label if user_portfolio_permission else None

View file

@ -49,11 +49,15 @@ 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 = [
self.profile_page,
self.logout_page,
"/admin",
"/__debug__",
]
self.excluded_pages = {

View file

@ -39,7 +39,7 @@
None<br>
{% endif %}
{% else %}
{% elif not hide_no_contact_info_message %}
No additional contact information found.<br>
{% endif %}

View file

@ -119,7 +119,7 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% endfor %}
{% endwith %}
</div>
{% 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" %}
<div class="readonly">{{ field.contents|safe }}</div>
{% elif field.field.name == "display_members" %}
<div class="readonly">
@ -288,13 +288,6 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
</details>
{% endif %}
{% endwith %}
{% elif field.field.name == "display_members" and field.contents %}
<details class="margin-top-1 dja-detail-table" aria-role="button" open>
<summary class="padding-1 padding-left-0 dja-details-summary">Details</summary>
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
{{ field.contents|safe }}
</div>
</details>
{% elif field.field.name == "state_territory" and original_object|model_name_lowercase != 'portfolio' %}
<div class="flex-container margin-top-2">
<span>

View file

@ -0,0 +1,9 @@
{% comment %} This view provides a detail button that can be used to show/hide content {% endcomment %}
<details class="margin-top-1 dja-detail-table" aria-role="button" {% if start_open %}open{% else %}closed{% endif %}>
<summary class="padding-1 padding-left-0 dja-details-summary">Details</summary>
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
{% block detail_content %}
{% endblock detail_content%}
</div>
</details>

View file

@ -0,0 +1,48 @@
{% extends "django/admin/includes/details_button.html" %}
{% load static url_helpers %}
{% block detail_content %}
<table>
<thead>
<tr>
<th>Name</th>
<th>Title</th>
<th>Email</th>
<th>Phone</th>
</tr>
</thead>
<tbody>
{% for admin in admins %}
{% url 'admin:registrar_userportfoliopermission_change' admin.pk as url %}
<tr>
<td><a href={{url}}>{{ admin.user.get_formatted_name}}</a></td>
<td>{{ admin.user.title }}</td>
<td>
{% if admin.user.email %}
{{ admin.user.email }}
{% else %}
None
{% endif %}
</td>
<td>{{ admin.user.phone }}</td>
<td class="padding-left-1 text-size-small">
{% if admin.user.email %}
<input aria-hidden="true" class="display-none" value="{{ admin.user.email }}" />
<button
class="usa-button usa-button--unstyled padding-right-1 usa-button--icon button--clipboard copy-to-clipboard usa-button__small-text text-no-underline"
type="button"
>
<svg
class="usa-icon"
>
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
</svg>
<span>Copy email</span>
</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock detail_content %}

View file

@ -0,0 +1,26 @@
{% extends "django/admin/includes/details_button.html" %}
{% load static url_helpers %}
{% block detail_content %}
<table>
<thead>
<tr>
<th>Name</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for domain_request in domain_requests %}
{% url 'admin:registrar_domainrequest_change' domain_request.pk as url %}
<tr>
<td><a href={{url}}>{{ domain_request }}</a></td>
{% if domain_request.get_status_display %}
<td>{{ domain_request.get_status_display }}</td>
{% else %}
<td>None</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
{% endblock detail_content %}

View file

@ -0,0 +1,30 @@
{% extends "django/admin/includes/details_button.html" %}
{% load static url_helpers %}
{% block detail_content %}
<table>
<thead>
<tr>
<th>Name</th>
<th>State</th>
</tr>
</thead>
<tbody>
{% for domain_info in domains %}
{% if domain_info.domain %}
{% with domain=domain_info.domain %}
{% url 'admin:registrar_domain_change' domain.pk as url %}
<tr>
<td><a href={{url}}>{{ domain }}</a></td>
{% if domain and domain.get_state_display %}
<td>{{ domain.get_state_display }}</td>
{% else %}
<td>None</td>
{% endif %}
</tr>
{% endwith %}
{% endif %}
{% endfor %}
</tbody>
</table>
{% endblock detail_content%}

View file

@ -0,0 +1,61 @@
{% extends "django/admin/includes/detail_table_fieldset.html" %}
{% load custom_filters %}
{% load static url_helpers %}
{% block field_readonly %}
{% if field.field.name == "display_admins" or field.field.name == "display_members" %}
<div class="readonly">{{ field.contents|safe }}</div>
{% elif field.field.name == "roles" %}
<div class="readonly">
{% if get_readable_roles %}
{{ get_readable_roles }}
{% else %}
<p>No roles found.</p>
{% endif %}
</div>
{% elif field.field.name == "additional_permissions" %}
<div class="readonly">
{% if display_permissions %}
{{ display_permissions }}
{% else %}
<p>No additional permissions found.</p>
{% endif %}
</div>
{% elif field.field.name == "senior_official" %}
{% if original_object.senior_official %}
<div class="readonly">{{ field.contents }}</div>
{% else %}
{% url "admin:registrar_seniorofficial_add" as url %}
<div class="readonly">
<a href={{ url }}>No senior official found. Create one now.</a>
</div>
{% endif %}
{% else %}
<div class="readonly">{{ field.contents }}</div>
{% endif %}
{% endblock field_readonly%}
{% block after_help_text %}
{% if field.field.name == "senior_official" %}
<div class="flex-container">
<label aria-label="Senior official contact details"></label>
{% 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 %}
</div>
{% elif field.field.name == "display_admins" %}
{% if admins|length > 0 %}
{% include "django/admin/includes/portfolio/portfolio_admins_table.html" with admins=admins %}
{% endif %}
{% elif field.field.name == "display_members" %}
{% if members|length > 0 %}
{% include "django/admin/includes/portfolio/portfolio_members_table.html" with members=members %}
{% endif %}
{% elif field.field.name == "domains" %}
{% if domains|length > 0 %}
{% include "django/admin/includes/portfolio/portfolio_domains_table.html" with domains=domains %}
{% endif %}
{% elif field.field.name == "domain_requests" %}
{% if domain_requests|length > 0 %}
{% include "django/admin/includes/portfolio/portfolio_domain_requests_table.html" with domain_requests=domain_requests %}
{% endif %}
{% endif %}
{% endblock after_help_text %}

View file

@ -0,0 +1,55 @@
{% extends "django/admin/includes/details_button.html" %}
{% load custom_filters %}
{% load static url_helpers %}
{% block detail_content %}
<table>
<thead>
<tr>
<th>Name</th>
<th>Title</th>
<th>Email</th>
<th>Phone</th>
<th>Roles</th>
</tr>
</thead>
<tbody>
{% for member in members %}
{% url 'admin:registrar_userportfoliopermission_change' member.pk as url %}
<tr>
<td><a href={{url}}>{{ member.user.get_formatted_name}}</a></td>
<td>{{ member.user.title }}</td>
<td>
{% if member.user.email %}
{{ member.user.email }}
{% else %}
None
{% endif %}
</td>
<td>{{ member.user.phone }}</td>
<td>
{% for role in member.user|portfolio_role_summary:original %}
<span class="usa-tag">{{ role }}</span>
{% endfor %}
</td>
<td class="padding-left-1 text-size-small">
{% if member.user.email %}
<input aria-hidden="true" class="display-none" value="{{ member.user.email }}" />
<button
class="usa-button usa-button--unstyled padding-right-1 usa-button--icon button--clipboard copy-to-clipboard usa-button__small-text text-no-underline"
type="button"
>
<svg
class="usa-icon"
>
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
</svg>
<span>Copy email</span>
</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View file

@ -8,19 +8,14 @@
<input id="senior_official_from_agency_json_url" class="display-none" value="{{url}}" />
{% url 'get-federal-and-portfolio-types-from-federal-agency-json' as url %}
<input id="federal_and_portfolio_types_from_agency_json_url" class="display-none" value="{{url}}" />
{% url "admin:registrar_seniorofficial_add" as url %}
<input id="senior-official-add-url" class="display-none" value="{{url}}" />
{{ block.super }}
{% endblock content %}
{% block field_sets %}
{% for fieldset in adminform %}
{% comment %}
This is a placeholder for now.
Disclaimer:
When extending the fieldset view consider whether you need to make a new one that extends from detail_table_fieldset.
detail_table_fieldset is used on multiple admin pages, so a change there can have unintended consequences.
{% endcomment %}
{% include "django/admin/includes/detail_table_fieldset.html" with original_object=original %}
{% include "django/admin/includes/portfolio/portfolio_fieldset.html" with original_object=original %}
{% endfor %}
{% endblock %}

View file

@ -8,27 +8,35 @@
<div class="mobile:grid-col-12 tablet:grid-col-6 desktop:grid-col-4">
<h3>Domain requests</h3>
<ul class="margin-0 padding-0">
{% for domain_request in domain_requests %}
<li>
<a href="{% url 'admin:registrar_domainrequest_change' domain_request.pk %}">
{{ domain_request.requested_domain }}
</a>
({{ domain_request.status }})
</li>
{% endfor %}
{% if domains|length > 0 %}
{% for domain_request in domain_requests %}
<li>
<a href="{% url 'admin:registrar_domainrequest_change' domain_request.pk %}">
{{ domain_request.requested_domain }}
</a>
({{ domain_request.status }})
</li>
{% endfor %}
{% else %}
<li>No domain requests.</li>
{% endif %}
</ul>
</div>
<div class="mobile:grid-col-12 tablet:grid-col-6 desktop:grid-col-4">
<h3>Domains</h3>
<ul class="margin-0 padding-0">
{% for domain in domains %}
<li>
<a href="{% url 'admin:registrar_domain_change' domain.pk %}">
{{ domain.name }}
</a>
({{ domain.state }})
</li>
{% endfor %}
{% if domains|length > 0 %}
{% for domain in domains %}
<li>
<a href="{% url 'admin:registrar_domain_change' domain.pk %}">
{{ domain.name }}
</a>
({{ domain.state }})
</li>
{% endfor %}
{% else %}
<li>No domains.</li>
{% endif %}
</ul>
</div>
</div>

View file

@ -17,26 +17,6 @@
{% endblock %}
{% block after_related_objects %}
{% if portfolios %}
<div class="module aligned padding-3">
<h2>Portfolio information</h2>
<div class="grid-row grid-gap mobile:padding-x-1 desktop:padding-x-4">
<div class="mobile:grid-col-12 tablet:grid-col-6 desktop:grid-col-4">
<h3>Portfolios</h3>
<ul class="margin-0 padding-0">
{% for portfolio in portfolios %}
<li>
<a href="{% url 'admin:registrar_portfolio_change' portfolio.pk %}">
{{ portfolio }}
</a>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% endif %}
<div class="module aligned padding-3">
<h2>Associated requests and domains</h2>
<div class="grid-row grid-gap mobile:padding-x-1 desktop:padding-x-4">

View file

@ -0,0 +1,16 @@
{% extends 'django/admin/email_clipboard_change_form.html' %}
{% load custom_filters %}
{% load i18n static %}
{% block field_sets %}
{% for fieldset in adminform %}
{% comment %}
This is a placeholder for now.
Disclaimer:
When extending the fieldset view consider whether you need to make a new one that extends from detail_table_fieldset.
detail_table_fieldset is used on multiple admin pages, so a change there can have unintended consequences.
{% endcomment %}
{% include "django/admin/includes/user_portfolio_permission_fieldset.html" with original_object=original %}
{% endfor %}
{% endblock %}

View file

@ -3,204 +3,203 @@
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
{% url 'get_domain_requests_json' as url %}
<span id="get_domain_requests_json_url" class="display-none">{{url}}</span>
<section class="section-outlined domain-requests{% if portfolio %} section-outlined--border-base-light{% endif %}" id="domain-requests">
<div class="grid-row">
{% if not portfolio %}
<div class="mobile:grid-col-12 desktop:grid-col-6">
<h2 id="domain-requests-header" class="flex-6">Domain requests</h2>
</div>
<div class="section-outlined__header margin-bottom-3 {% if not portfolio %} section-outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}">
{% if not portfolio %}
<h2 id="domain-requests-header" class="display-inline-block">Domain requests</h2>
{% else %}
<!-- Embedding the portfolio value in a data attribute -->
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>
{% endif %}
<div class="mobile:grid-col-12 desktop:grid-col-6">
<section aria-label="Domain requests search component" class="flex-6 margin-y-2">
<form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %}
<button class="usa-button usa-button--unstyled margin-right-2 domain-requests__reset-search display-none" type="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg>
Reset
</button>
{% if portfolio %}
<label class="usa-sr-only" for="domain-requests__search-field">Search by domain name or creator</label>
{% else %}
<label class="usa-sr-only" for="domain-requests__search-field">Search by domain name</label>
{% endif %}
<input
class="usa-input"
id="domain-requests__search-field"
type="search"
name="search"
{% if portfolio %}
placeholder="Search by domain name or creator"
{% else %}
placeholder="Search by domain name"
{% endif %}
/>
<button class="usa-button" type="submit" id="domain-requests__search-field-submit">
<img
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
class="usa-search__submit-icon"
alt="Search"
/>
</button>
</form>
</section>
</div>
{% endif %}
<div class="section-outlined__search {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6{% endif %} {% if is_widescreen_mode %} section-outlined__search--widescreen {% endif %}">
<section aria-label="Domain requests search component" class="margin-top-2">
<form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %}
<button class="usa-button usa-button--unstyled margin-right-3 domain-requests__reset-search display-none" type="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg>
Reset
</button>
{% if portfolio %}
<label class="usa-sr-only" for="domain-requests__search-field">Search by domain name or creator</label>
{% else %}
<label class="usa-sr-only" for="domain-requests__search-field">Search by domain name</label>
{% endif %}
<input
class="usa-input"
id="domain-requests__search-field"
type="search"
name="search"
{% if portfolio %}
placeholder="Search by domain name or creator"
{% else %}
placeholder="Search by domain name"
{% endif %}
/>
<button class="usa-button" type="submit" id="domain-requests__search-field-submit">
<img
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
class="usa-search__submit-icon"
alt="Search"
/>
</button>
</form>
</section>
</div>
{% if portfolio %}
<div class="section-outlined__utility-button mobile-lg:padding-right-105 {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}" id="export-csv">
<section aria-label="Domain Requests report component" class="margin-top-205">
<a href="{% url 'export_data_type_requests' %}" class="usa-button usa-button--unstyled usa-button--with-icon usa-button--justify-right" role="button">
<svg class="usa-icon usa-icon--big" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{% static 'img/sprite.svg' %}#file_download"></use>
</svg>Export as CSV
</a>
</section>
</div>
{% endif %}
</div>
{% if portfolio %}
<div class="display-flex flex-align-center">
<span class="margin-right-2 margin-top-neg-1 usa-prose text-base-darker">Filter by</span>
<div class="usa-accordion usa-accordion--select margin-right-2">
<div class="usa-accordion__heading">
<button
<span class="margin-right-2 margin-top-neg-1 usa-prose text-base-darker">Filter by</span>
<div class="usa-accordion usa-accordion--select margin-right-2">
<div class="usa-accordion__heading">
<button
type="button"
class="usa-button usa-button--small padding--8-8-9 usa-button--outline usa-button--filter usa-accordion__button"
aria-expanded="false"
aria-controls="filter-status"
>
<span class="filter-indicator text-bold display-none"></span> Status
<svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#expand_more"></use>
</svg>
</button>
</div>
<div id="filter-status" class="usa-accordion__content usa-prose shadow-1">
<h2>Status</h2>
<fieldset class="usa-fieldset margin-top-0">
<legend class="usa-legend">Select to apply <span class="sr-only">status</span> filter</legend>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-started"
type="checkbox"
name="filter-status"
value="started"
/>
<label class="usa-checkbox__label" for="filter-status-started">Started</label>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-submitted"
type="checkbox"
name="filter-status"
value="submitted"
/>
<label class="usa-checkbox__label" for="filter-status-submitted">Submitted</label>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-in-review"
type="checkbox"
name="filter-status"
value="in review"
/>
<label class="usa-checkbox__label" for="filter-status-in-review">In review</label>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-action-needed"
type="checkbox"
name="filter-status"
value="action needed"
/>
<label class="usa-checkbox__label" for="filter-status-action-needed">Action needed</label>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-rejected"
type="checkbox"
name="filter-status"
value="rejected"
/>
<label class="usa-checkbox__label" for="filter-status-rejected">Rejected</label>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-withdrawn"
type="checkbox"
name="filter-status"
value="withdrawn"
/>
<label class="usa-checkbox__label" for="filter-status-withdrawn">Withdrawn</label>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-ineligible"
type="checkbox"
name="filter-status"
value="ineligible"
/>
<label class="usa-checkbox__label" for="filter-status-ineligible">Ineligible</label>
</div>
</fieldset>
</div>
</div>
<button
type="button"
class="usa-button usa-button--small padding--8-8-9 usa-button--outline usa-button--filter usa-accordion__button"
aria-expanded="false"
aria-controls="filter-status"
>
<span class="filter-indicator text-bold display-none"></span> Status
<svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#expand_more"></use>
class="usa-button usa-button--small padding--8-12-9-12 usa-button--outline usa-button--filter domain-requests__reset-filters display-none"
>
Clear filters
<svg class="usa-icon top-1px" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#close"></use>
</svg>
</button>
</div>
<div id="filter-status" class="usa-accordion__content usa-prose shadow-1">
<h2>Status</h2>
<fieldset class="usa-fieldset margin-top-0">
<legend class="usa-legend">Select to apply <span class="sr-only">status</span> filter</legend>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-started"
type="checkbox"
name="filter-status"
value="started"
/>
<label class="usa-checkbox__label" for="filter-status-started"
>Started</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-submitted"
type="checkbox"
name="filter-status"
value="submitted"
/>
<label class="usa-checkbox__label" for="filter-status-submitted"
>Submitted</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-in-review"
type="checkbox"
name="filter-status"
value="in review"
/>
<label class="usa-checkbox__label" for="filter-status-in-review"
>In review</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-action-needed"
type="checkbox"
name="filter-status"
value="action needed"
/>
<label class="usa-checkbox__label" for="filter-status-action-needed"
>Action needed</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-rejected"
type="checkbox"
name="filter-status"
value="rejected"
/>
<label class="usa-checkbox__label" for="filter-status-rejected"
>Rejected</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-withdrawn"
type="checkbox"
name="filter-status"
value="withdrawn"
/>
<label class="usa-checkbox__label" for="filter-status-withdrawn"
>Withdrawn</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-ineligible"
type="checkbox"
name="filter-status"
value="ineligible"
/>
<label class="usa-checkbox__label" for="filter-status-ineligible"
>Ineligible</label
>
</div>
</fieldset>
</div>
</div>
<button
type="button"
class="usa-button usa-button--small padding--8-12-9-12 usa-button--outline usa-button--filter domain-requests__reset-filters display-none"
>
Clear filters
<svg class="usa-icon top-1px" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#close"></use>
</svg>
</button>
</button>
</div>
{% endif %}
<div class="domain-requests__table-wrapper display-none usa-table-container--scrollable margin-top-0" tabindex="0">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked domain-requests__table">
<caption class="sr-only">Your domain requests</caption>
<thead>
<tr>
<th data-sortable="requested_domain__name" scope="col" role="columnheader">Domain name</th>
<th data-sortable="last_submitted_date" scope="col" role="columnheader">Submitted</th>
{% if portfolio %}
<th data-sortable="creator" scope="col" role="columnheader">Created by</th>
{% endif %}
<th data-sortable="status" scope="col" role="columnheader">Status</th>
<th scope="col" role="columnheader"><span class="usa-sr-only">Action</span></th>
<!-- AJAX will conditionally add a th for delete actions -->
</tr>
</thead>
<tbody id="domain-requests-tbody">
<!-- AJAX will populate this tbody -->
</tbody>
</table>
<div
class="usa-sr-only usa-table__announcement-region"
aria-live="polite"
></div>
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked domain-requests__table">
<caption class="sr-only">Your domain requests</caption>
<thead>
<tr>
<th data-sortable="requested_domain__name" scope="col" role="columnheader">Domain name</th>
<th data-sortable="last_submitted_date" scope="col" role="columnheader">Submitted</th>
{% if portfolio %}
<th data-sortable="creator" scope="col" role="columnheader">Created by</th>
{% endif %}
<th data-sortable="status" scope="col" role="columnheader">Status</th>
<th scope="col" role="columnheader"><span class="usa-sr-only">Action</span></th>
<!-- AJAX will conditionally add a th for delete actions -->
</tr>
</thead>
<tbody id="domain-requests-tbody">
<!-- AJAX will populate this tbody -->
</tbody>
</table>
<div class="usa-sr-only usa-table__announcement-region" aria-live="polite"></div>
</div>
<div class="domain-requests__no-data display-none">
<p>You haven't requested any domains.</p>
<p>You haven't requested any domains.</p>
</div>
<div class="domain-requests__no-search-results display-none">
<p>No results found</p>
</div>
</section>
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domain-requests-pagination">
<p>No results found</p>
</div>
</section>
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domain-requests-pagination">
<span class="usa-pagination__counter text-base-dark padding-left-2 margin-bottom-1">
<!-- Count will be dynamically populated by JS -->
</span>

View file

@ -250,3 +250,12 @@ def is_members_subpage(path):
"members",
]
return get_url_name(path) in url_names
@register.filter(name="portfolio_role_summary")
def portfolio_role_summary(user, portfolio):
"""Returns the value of user.portfolio_role_summary"""
if user and portfolio:
return user.portfolio_role_summary(portfolio)
else:
return []

View file

@ -2097,36 +2097,11 @@ class TestPortfolioAdmin(TestCase):
)
display_admins = self.admin.display_admins(self.portfolio)
self.assertIn(
f'<a href="/admin/registrar/user/{admin_user_1.pk}/change/">Gerald Meoward meaoward@gov.gov</a>',
display_admins,
)
self.assertIn("Captain", display_admins)
self.assertIn(
f'<a href="/admin/registrar/user/{admin_user_2.pk}/change/">Arnold Poopy poopy@gov.gov</a>', display_admins
)
self.assertIn("Major", display_admins)
display_members_summary = self.admin.display_members_summary(self.portfolio)
self.assertIn(
f'<a href="/admin/registrar/user/{admin_user_3.pk}/change/">Mad Max madmax@gov.gov</a>',
display_members_summary,
)
self.assertIn(
f'<a href="/admin/registrar/user/{admin_user_4.pk}/change/">Agent Smith thematrix@gov.gov</a>',
display_members_summary,
)
url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={self.portfolio.id}"
self.assertIn(f'<a href="{url}">2 administrators</a>', display_admins)
display_members = self.admin.display_members(self.portfolio)
self.assertIn("Mad Max", display_members)
self.assertIn("<span class='usa-tag'>Member</span>", display_members)
self.assertIn("Road warrior", display_members)
self.assertIn("Agent Smith", display_members)
self.assertIn("<span class='usa-tag'>Domain requestor</span>", display_members)
self.assertIn("Program", display_members)
self.assertIn(f'<a href="{url}">2 members</a>', display_members)
class TestTransferUser(WebTest):

View file

@ -100,7 +100,6 @@ class GetFederalPortfolioTypeJsonTest(TestCase):
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["federal_type"], "Judicial")
self.assertEqual(data["portfolio_type"], "Federal - Judicial")
@less_console_noise_decorator
def test_get_federal_and_portfolio_types_json_authenticated_regularuser(self):

View file

@ -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)

View file

@ -40,10 +40,22 @@ class TestGroups(TestCase):
"add_federalagency",
"change_federalagency",
"delete_federalagency",
"add_portfolio",
"change_portfolio",
"delete_portfolio",
"add_seniorofficial",
"change_seniorofficial",
"delete_seniorofficial",
"add_suborganization",
"change_suborganization",
"delete_suborganization",
"analyst_access_permission",
"change_user",
"delete_userdomainrole",
"view_userdomainrole",
"add_userportfoliopermission",
"change_userportfoliopermission",
"delete_userportfoliopermission",
"add_verifiedbystaff",
"change_verifiedbystaff",
"delete_verifiedbystaff",
@ -51,6 +63,7 @@ class TestGroups(TestCase):
# Get the codenames of actual permissions associated with the group
actual_permissions = [p.codename for p in cisa_analysts_group.permissions.all()]
self.maxDiff = None
# Assert that the actual permissions match the expected permissions
self.assertListEqual(actual_permissions, expected_permissions)

View file

@ -1381,7 +1381,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."
),
)
@less_console_noise_decorator

View file

@ -6,7 +6,7 @@ from registrar.models import (
Domain,
UserDomainRole,
)
from registrar.models import Portfolio
from registrar.models import Portfolio, DraftDomain
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
from registrar.utility.csv_export import (
@ -14,6 +14,7 @@ from registrar.utility.csv_export import (
DomainDataType,
DomainDataFederal,
DomainDataTypeUser,
DomainRequestsDataType,
DomainGrowth,
DomainManaged,
DomainUnmanaged,
@ -389,6 +390,77 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
return csv_content
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
def test_domain_request_data_type_user_with_portfolio(self):
"""Tests DomainRequestsDataType export with portfolio permissions"""
# Create a portfolio and assign it to the user
portfolio = Portfolio.objects.create(creator=self.user, organization_name="Test Portfolio")
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(portfolio=portfolio, user=self.user)
# Create DraftDomain objects
dd_1 = DraftDomain.objects.create(name="example1.com")
dd_2 = DraftDomain.objects.create(name="example2.com")
dd_3 = DraftDomain.objects.create(name="example3.com")
# Create some domain requests
dr_1 = DomainRequest.objects.create(creator=self.user, requested_domain=dd_1, portfolio=portfolio)
dr_2 = DomainRequest.objects.create(creator=self.user, requested_domain=dd_2)
dr_3 = DomainRequest.objects.create(creator=self.user, requested_domain=dd_3, portfolio=portfolio)
# Set up user permissions
portfolio_permission.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
portfolio_permission.save()
portfolio_permission.refresh_from_db()
# Make a GET request using self.client to get a request object
request = get_wsgi_request_object(client=self.client, user=self.user)
# Get the CSV content
csv_content = self._run_domain_request_data_type_user_export(request)
# We expect only domain requests associated with the user's portfolio
self.assertIn(dd_1.name, csv_content)
self.assertIn(dd_3.name, csv_content)
self.assertNotIn(dd_2.name, csv_content)
# Get the csv content
csv_content = self._run_domain_request_data_type_user_export(request)
self.assertIn(dd_1.name, csv_content)
self.assertIn(dd_3.name, csv_content)
self.assertNotIn(dd_2.name, csv_content)
portfolio_permission.roles = [UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
portfolio_permission.save()
portfolio_permission.refresh_from_db()
# Domain Request NOT in Portfolio
csv_content = self._run_domain_request_data_type_user_export(request)
self.assertNotIn(dd_1.name, csv_content)
self.assertNotIn(dd_3.name, csv_content)
self.assertNotIn(dd_2.name, csv_content)
# Clean up the created objects
dr_1.delete()
dr_2.delete()
dr_3.delete()
portfolio.delete()
def _run_domain_request_data_type_user_export(self, request):
"""Helper function to run the exporting_dr_data_to_csv function on DomainRequestsDataType"""
csv_file = StringIO()
DomainRequestsDataType.exporting_dr_data_to_csv(csv_file, request=request)
csv_file.seek(0)
csv_content = csv_file.read()
return csv_content
@less_console_noise_decorator
def test_domain_data_full(self):
"""Shows security contacts, filtered by state"""

View file

@ -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,56 @@ 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,
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 `-`.
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'<a href="{change_url}">{escape(item_display_value)}</a>'
if link_info_attribute:
link += f" ({value_of_attribute(item, link_info_attribute)})"
if separator:
links.append(link)
else:
links.append(f"<li>{link}</li>")
# 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'<ul class="add-list-reset">{links}</ul>') if links else msg_for_none

View file

@ -583,6 +583,105 @@ class DomainDataTypeUser(DomainDataType):
return Q(domain__id__in=request.user.get_user_domain_ids(request))
class DomainRequestsDataType:
"""
The DomainRequestsDataType report, but filtered based on the current request user
"""
@classmethod
def get_filter_conditions(cls, request=None):
if request is None or not hasattr(request, "user") or not request.user.is_authenticated:
return Q(id__in=[])
request_ids = request.user.get_user_domain_request_ids(request)
return Q(id__in=request_ids)
@classmethod
def get_queryset(cls, request):
return DomainRequest.objects.filter(cls.get_filter_conditions(request))
def safe_get(attribute, default="N/A"):
# Return the attribute value or default if not present
return attribute if attribute is not None else default
@classmethod
def exporting_dr_data_to_csv(cls, response, request=None):
import csv
writer = csv.writer(response)
# CSV headers
writer.writerow(
[
"Domain request",
"Region",
"Status",
"Election office",
"Federal type",
"Domain type",
"Request additional details",
"Creator approved domains count",
"Creator active requests count",
"Alternative domains",
"Other contacts",
"Current websites",
"Federal agency",
"SO first name",
"SO last name",
"SO email",
"SO title/role",
"Creator first name",
"Creator last name",
"Creator email",
"Organization name",
"City",
"State/territory",
"Request purpose",
"CISA regional representative",
"Last submitted date",
"First submitted date",
"Last status update",
]
)
queryset = cls.get_queryset(request)
for request in queryset:
writer.writerow(
[
request.requested_domain,
cls.safe_get(getattr(request, "region_field", None)),
request.status,
cls.safe_get(getattr(request, "election_office", None)),
request.federal_type,
cls.safe_get(getattr(request, "domain_type", None)),
cls.safe_get(getattr(request, "additional_details", None)),
cls.safe_get(getattr(request, "creator_approved_domains_count", None)),
cls.safe_get(getattr(request, "creator_active_requests_count", None)),
cls.safe_get(getattr(request, "all_alternative_domains", None)),
cls.safe_get(getattr(request, "all_other_contacts", None)),
cls.safe_get(getattr(request, "all_current_websites", None)),
cls.safe_get(getattr(request, "federal_agency", None)),
cls.safe_get(getattr(request.senior_official, "first_name", None)),
cls.safe_get(getattr(request.senior_official, "last_name", None)),
cls.safe_get(getattr(request.senior_official, "email", None)),
cls.safe_get(getattr(request.senior_official, "title", None)),
cls.safe_get(getattr(request.creator, "first_name", None)),
cls.safe_get(getattr(request.creator, "last_name", None)),
cls.safe_get(getattr(request.creator, "email", None)),
cls.safe_get(getattr(request, "organization_name", None)),
cls.safe_get(getattr(request, "city", None)),
cls.safe_get(getattr(request, "state_territory", None)),
cls.safe_get(getattr(request, "purpose", None)),
cls.safe_get(getattr(request, "cisa_representative_email", None)),
cls.safe_get(getattr(request, "last_submitted_date", None)),
cls.safe_get(getattr(request, "first_submitted_date", None)),
cls.safe_get(getattr(request, "last_status_update", None)),
]
)
return response
class DomainDataFull(DomainExport):
"""
Shows security contacts, filtered by state

View file

@ -169,6 +169,17 @@ class ExportDataTypeUser(View):
return response
class ExportDataTypeRequests(View):
"""Returns a domain requests report for a given user on the request"""
def get(self, request, *args, **kwargs):
response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = 'attachment; filename="domain-requests.csv"'
csv_export.DomainRequestsDataType.exporting_dr_data_to_csv(response, request=request)
return response
class ExportDataFull(View):
def get(self, request, *args, **kwargs):
# Smaller export based on 1

View file

@ -55,11 +55,9 @@ def get_federal_and_portfolio_types_from_federal_agency_json(request):
portfolio_type = None
agency_name = request.GET.get("agency_name")
organization_type = request.GET.get("organization_type")
agency = FederalAgency.objects.filter(agency=agency_name).first()
if agency:
federal_type = Portfolio.get_federal_type(agency)
portfolio_type = Portfolio.get_portfolio_type(organization_type, federal_type)
federal_type = BranchChoices.get_branch_label(federal_type) if federal_type else "-"
response_data = {