This commit is contained in:
CocoByte 2024-09-05 21:30:56 -06:00
commit 15fc5bf0e9
No known key found for this signature in database
GPG key ID: BBFAA2526384C97F
33 changed files with 477 additions and 135 deletions

View file

@ -11,6 +11,7 @@ from django.conf import settings
from django.shortcuts import redirect
from django_fsm import get_available_FIELD_transitions, FSMField
from registrar.models.domain_information import DomainInformation
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from waffle.decorators import flag_is_active
from django.contrib import admin, messages
@ -2968,11 +2969,7 @@ class PortfolioAdmin(ListHeaderAdmin):
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"]}),
# TODO - uncomment in #2521
# ("Portfolio members", {
# "classes": ("collapse", "closed"),
# "fields": ["administrators", "members"]}
# ),
("Portfolio members", {"fields": ["display_admins", "display_members"]}),
("Portfolio domains", {"fields": ["domains", "domain_requests"]}),
("Type of organization", {"fields": ["organization_type", "federal_type"]}),
(
@ -3020,15 +3017,118 @@ class PortfolioAdmin(ListHeaderAdmin):
readonly_fields = [
# This is the created_at field
"created_on",
# Custom fields such as these must be defined as readonly.
# Django admin doesn't allow methods to be directly listed in fieldsets. We can
# display the custom methods display_admins amd display_members in the admin form if
# they are readonly.
"federal_type",
"domains",
"domain_requests",
"suborganizations",
"portfolio_type",
"display_admins",
"display_members",
"creator",
]
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]
)
# Get the user objects associated with these permissions
admin_users = User.objects.filter(portfolio_permissions__in=admin_permissions)
return admin_users
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(
roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
# Get the user objects associated with these permissions
non_admin_users = User.objects.filter(portfolio_permissions__in=non_admin_permissions)
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 federal_type(self, obj: models.Portfolio):
"""Returns the federal_type field"""
return BranchChoices.get_branch_label(obj.federal_type) if obj.federal_type else "-"
@ -3088,7 +3188,7 @@ class PortfolioAdmin(ListHeaderAdmin):
]
def get_field_links_as_list(
self, queryset, model_name, attribute_name=None, link_info_attribute=None, seperator=None
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.
@ -3120,14 +3220,14 @@ class PortfolioAdmin(ListHeaderAdmin):
if link_info_attribute:
link += f" ({self.value_of_attribute(item, link_info_attribute)})"
if seperator:
if separator:
links.append(link)
else:
links.append(f"<li>{link}</li>")
# If no seperator is specified, just return an unordered list.
if seperator:
return format_html(seperator.join(links)) if links else "-"
# 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 "-"
@ -3170,8 +3270,12 @@ class PortfolioAdmin(ListHeaderAdmin):
return readonly_fields
def change_view(self, request, object_id, form_url="", extra_context=None):
"""Add related suborganizations and domain groups"""
extra_context = {"skip_additional_contact_info": True}
"""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)
extra_context = extra_context or {}
extra_context["skip_additional_contact_info"] = True
extra_context["display_members_summary"] = self.display_members_summary(obj)
return super().change_view(request, object_id, form_url, extra_context)
def save_model(self, request, obj, form, change):

View file

@ -1220,7 +1220,7 @@ document.addEventListener('DOMContentLoaded', function() {
const expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : '';
const expirationDateSortValue = expirationDate ? expirationDate.getTime() : '';
const actionUrl = domain.action_url;
const suborganization = domain.suborganization ? domain.suborganization : '';
const suborganization = domain.suborganization ? domain.suborganization : '';
const row = document.createElement('tr');
@ -1229,7 +1229,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (!noPortfolioFlag) {
markupForSuborganizationRow = `
<td>
<span class="${suborganization ? 'ellipsis ellipsis--30 vertical-align-middle' : ''}" aria-label="${suborganization}" title="${suborganization}">${suborganization}</span>
<span class="text-wrap" aria-label="${domain.suborganization ? suborganization : 'No suborganization'}">${suborganization}</span>
</td>
`
}
@ -1910,7 +1910,7 @@ document.addEventListener('DOMContentLoaded', function() {
let editableFormGroup = button.parentElement.parentElement.parentElement;
if (editableFormGroup){
let readonlyField = editableFormGroup.querySelector(".input-with-edit-button__readonly-field")
let readonlyField = editableFormGroup.querySelector(".toggleable_input__readonly-field")
let inputField = document.getElementById(`id_${fieldName}`);
if (!inputField || !readonlyField) {
return;
@ -1936,8 +1936,8 @@ document.addEventListener('DOMContentLoaded', function() {
// Keep the path before '#' and replace the part after '#' with 'invalid'
const newHref = parts[0] + '#error';
svg.setAttribute('xlink:href', newHref);
fullNameField.classList.add("input-with-edit-button__error")
label = fullNameField.querySelector(".input-with-edit-button__readonly-field")
fullNameField.classList.add("toggleable_input__error")
label = fullNameField.querySelector(".toggleable_input__readonly-field")
label.innerHTML = "Unknown";
}
}
@ -2043,11 +2043,11 @@ document.addEventListener('DOMContentLoaded', function() {
// Due to the nature of how uswds works, this is slightly hacky.
// Use a MutationObserver to watch for changes in the dropdown list
const dropdownList = document.querySelector(`#${input.id}--list`);
const dropdownList = comboBox.querySelector(`#${input.id}--list`);
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === "childList") {
addBlankOption(clearInputButton, dropdownList, initialValue);
addBlankOption(clearInputButton, dropdownList, initialValue);
}
});
});
@ -2111,7 +2111,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (!initialValue){
blankOption.classList.add("usa-combo-box__list-option--selected")
}
blankOption.textContent = "---------";
blankOption.textContent = "";
dropdownList.insertBefore(blankOption, dropdownList.firstChild);
blankOption.addEventListener("click", (e) => {

View file

@ -33,16 +33,19 @@ body {
}
#wrapper.dashboard--portfolio {
background-color: color('gray-1');
padding-top: units(4)!important;
}
#wrapper.dashboard--grey-1 {
background-color: color('gray-1');
}
.section--outlined {
.section-outlined {
background-color: color('white');
border: 1px solid color('base-lighter');
border-radius: 4px;
padding: 0 units(2) units(3);
padding: 0 units(4) units(3) units(2);
margin-top: units(3);
&.margin-top-0 {
@ -72,9 +75,13 @@ body {
}
}
.section--outlined__header--no-portfolio {
.section--outlined__search,
.section--outlined__utility-button {
.section-outlined--border-base-light {
border: 1px solid color('base-light');
}
.section-outlined__header--no-portfolio {
.section-outlined__search,
.section-outlined__utility-button {
margin-top: units(2);
}
@ -82,11 +89,11 @@ body {
display: flex;
column-gap: units(3);
.section--outlined__search,
.section--outlined__utility-button {
.section-outlined__search,
.section-outlined__utility-button {
margin-top: 0;
}
.section--outlined__search {
.section-outlined__search {
flex-grow: 4;
// Align right
max-width: 383px;
@ -195,4 +202,7 @@ abbr[title] {
.usa-banner__inner--widescreen {
max-width: 1920px;
}
}
.margin-right-neg-4px {
margin-right: -4px;
}

View file

@ -124,10 +124,6 @@ a.withdraw:active {
background-color: color('error-darker');
}
.usa-button--unstyled .usa-icon {
vertical-align: bottom;
}
a.usa-button--unstyled:visited {
color: color('primary');
}
@ -162,14 +158,14 @@ a.usa-button--unstyled:visited {
}
}
.input-with-edit-button {
.toggleable_input {
svg.usa-icon {
width: 1.5em !important;
height: 1.5em !important;
color: #{$dhs-green};
position: absolute;
}
&.input-with-edit-button__error {
&.toggleable_input__error {
svg.usa-icon {
color: #{$dhs-red};
}
@ -205,12 +201,32 @@ a.usa-button--unstyled:visited {
}
}
.dotgov-table a,
.usa-link--icon,
.usa-button--with-icon {
display: flex;
align-items: flex-start;
color: color('primary');
column-gap: units(.5);
align-items: center;
}
.dotgov-table a,
.usa-link--icon {
&:visited {
color: color('primary');
}
}
a .usa-icon,
.usa-button--with-icon .usa-icon {
height: 1.3em;
width: 1.3em;
}
.usa-icon.usa-icon--big {
margin: 0;
height: 1.5em;
width: 1.5em;
}
.margin-right-neg-4px {
margin-right: -4px;
}

View file

@ -1,18 +0,0 @@
@use "uswds-core" as *;
.dotgov-table a,
.usa-link--icon {
display: flex;
align-items: flex-start;
color: color('primary');
&:visited {
color: color('primary');
}
.usa-icon {
// align icon with x height
margin-top: units(0.5);
margin-right: units(0.5);
}
}

View file

@ -1,5 +1,10 @@
@use "uswds-core" as *;
td,
th {
vertical-align: top;
}
.dotgov-table--stacked {
td, th {
padding: units(1) units(2) units(2px) 0;
@ -12,7 +17,7 @@
tr {
border-bottom: none;
border-top: 2px solid color('base-light');
border-top: 2px solid color('base-lighter');
margin-top: units(2);
&:first-child {
@ -39,10 +44,6 @@
.dotgov-table {
width: 100%;
th[data-sortable]:not([aria-sort]) .usa-table__header__button {
right: auto;
}
tbody th {
word-break: break-word;
}
@ -56,7 +57,7 @@
}
td, th {
border-bottom: 1px solid color('base-light');
border-bottom: 1px solid color('base-lighter');
}
thead th {
@ -72,11 +73,17 @@
td, th,
.usa-tabel th{
padding: units(2) units(2) units(2) 0;
padding: units(2) units(4) units(2) 0;
}
thead tr:first-child th:first-child {
border-top: none;
}
}
@include at-media(tablet-lg) {
th[data-sortable]:not([aria-sort]) .usa-table__header__button {
right: auto;
}
}
}

View file

@ -10,7 +10,6 @@
--- Custom Styles ---------------------------------*/
@forward "base";
@forward "typography";
@forward "links";
@forward "lists";
@forward "accordions";
@forward "buttons";

View file

@ -417,7 +417,7 @@ class SeniorOfficialContactForm(ContactForm):
# This action should be blocked by the UI, as the text fields are readonly.
# If they get past this point, we forbid it this way.
# This could be malicious, so lets reserve information for the backend only.
raise ValueError("Senior Official cannot be modified for federal or tribal domains.")
raise ValueError("Senior official cannot be modified for federal or tribal domains.")
elif db_so.has_more_than_one_join("information_senior_official"):
# Handle the case where the domain information object is available and the SO Contact
# has more than one joined object.

View file

@ -245,6 +245,49 @@ class User(AbstractUser):
return permission.portfolio
return None
def has_edit_requests(self, portfolio):
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_REQUESTS)
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_suborganization(portfolio), ["Admin"]),
(
self.has_view_all_domains_permission(portfolio)
and self.has_domain_requests_portfolio_permission(portfolio)
and self.has_edit_requests(portfolio),
["View-only admin", "Domain requestor"],
),
(
self.has_view_all_domains_permission(portfolio)
and self.has_domain_requests_portfolio_permission(portfolio),
["View-only admin"],
),
(
self.has_base_portfolio_permission(portfolio)
and self.has_edit_requests(portfolio)
and self.has_domains_portfolio_permission(portfolio),
["Domain requestor", "Domain manager"],
),
(self.has_base_portfolio_permission(portfolio) and self.has_edit_requests(portfolio), ["Domain requestor"]),
(
self.has_base_portfolio_permission(portfolio) and self.has_domains_portfolio_permission(portfolio),
["Domain manager"],
),
(self.has_base_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
@classmethod
def needs_identity_verification(cls, email, uuid):
"""A method used by our oidc classes to test whether a user needs email/uuid verification

View file

@ -17,7 +17,7 @@ Template for an input field with a clipboard
>
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
</svg>
<span>Copy</span>
Copy
</div>
</button>
</div>
@ -25,7 +25,7 @@ Template for an input field with a clipboard
<div class="admin-icon-group admin-icon-group__clipboard-link">
<input aria-hidden="true" class="display-none" value="{{ field.email }}" />
<button
class="usa-button usa-button--unstyled padding-right-1 usa-button--icon button--clipboard copy-to-clipboard text-no-underline"
class="usa-button usa-button--unstyled padding-right-1 usa-button--icon button--clipboard copy-to-clipboard text-no-underline padding-left-05"
type="button"
>
<svg
@ -33,7 +33,7 @@ Template for an input field with a clipboard
>
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
</svg>
<span class="padding-left-05">Copy</span>
Copy
</button>
</div>
{% endif %}

View file

@ -137,6 +137,16 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% endfor %}
{% endwith %}
</div>
{% elif field.field.name == "display_admins" %}
<div class="readonly">{{ field.contents|safe }}</div>
{% elif field.field.name == "display_members" %}
<div class="readonly">
{% if display_members_summary %}
{{ display_members_summary }}
{% else %}
<p>No additional members found.</p>
{% endif %}
</div>
{% else %}
<div class="readonly">{{ field.contents }}</div>
{% endif %}
@ -330,6 +340,13 @@ 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

@ -17,8 +17,7 @@
This is a placeholder for now.
Disclaimer:
When extending the fieldset view - *make a new one* that extends from detail_table_fieldset.
For instance, "portfolio_fieldset.html".
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 %}

View file

@ -7,7 +7,9 @@ for now we just carry the attribute to both the parent element and the select.
<div class="usa-combo-box"
{% for name, value in widget.attrs.items %}
{{ name }}="{{ value }}"
{% if name != 'id' %}
{{ name }}="{{ value }}"
{% endif %}
{% endfor %}
>
{% include "django/forms/widgets/select.html" %}

View file

@ -63,10 +63,10 @@
<div class="grid-row margin-top-1">
<div class="grid-col">
<button type="button" class="usa-button usa-button--unstyled display-block float-right-tablet delete-record">
<button type="button" class="usa-button usa-button--unstyled usa-button--with-icon float-right-tablet delete-record">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
</svg><span class="margin-left-05">Delete</span>
</svg>Delete
</button>
</div>
</div>
@ -74,10 +74,10 @@
</fieldset>
{% endfor %}
<button type="button" class="usa-button usa-button--unstyled display-block margin-bottom-2" id="add-form">
<button type="button" class="usa-button usa-button--unstyled usa-button--with-icon margin-bottom-2" id="add-form">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
</svg><span class="margin-left-05">Add new record</span>
</svg>Add new record
</button>
<button

View file

@ -52,20 +52,20 @@
{% endwith %}
</div>
<div class="tablet:grid-col-2">
<button type="button" class="usa-button usa-button--unstyled display-block delete-record margin-bottom-075">
<button type="button" class="usa-button usa-button--unstyled usa-button--with-icon delete-record margin-bottom-075">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
</svg><span class="margin-left-05">Delete</span>
</svg>Delete
</button>
</div>
</div>
</div>
{% endfor %}
<button type="button" class="usa-button usa-button--unstyled display-block" id="add-form">
<button type="button" class="usa-button usa-button--unstyled usa-button--with-icon" id="add-form">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
</svg><span class="margin-left-05">Add another name server</span>
</svg>Add another name server
</button>
{% comment %} Work around USWDS' button margins to add some spacing between the submit and the 'add more'

View file

@ -42,7 +42,7 @@
{% input_with_errors form.state_territory %}
{% with add_class="usa-input--small" %}
{% with add_class="usa-input--small" sublabel_text="Enter a zip code in the required format, like 12345 or 12345-6789." %}
{% input_with_errors form.zipcode %}
{% endwith %}

View file

@ -33,7 +33,7 @@
{% input_with_errors forms.0.state_territory %}
{% with add_class="usa-input--small" %}
{% with add_class="usa-input--small" sublabel_text="Enter a zip code in the required format, like 12345 or 12345-6789." %}
{% input_with_errors forms.0.zipcode %}
{% endwith %}

View file

@ -1,7 +1,7 @@
{% extends "domain_base.html" %}
{% load static field_helpers%}
{% block title %}Suborganization{% endblock %}
{% block title %}Suborganization{% if suborganization_name %} | suborganization_name{% endif %} | {% endblock %}
{% block domain_content %}
{# this is right after the messages block in the parent template #}

View file

@ -21,7 +21,7 @@
</ul>
{% if domain.permissions %}
<section class="section--outlined">
<section class="section-outlined">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table">
<h2 class> Domain managers </h2>
<caption class="sr-only">Domain managers</caption>
@ -112,7 +112,7 @@
</section>
{% if domain.invitations.exists %}
<section class="section--outlined">
<section class="section-outlined">
<h2>Invitations</h2>
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table">
<caption class="sr-only">Domain invitations</caption>

View file

@ -3,7 +3,7 @@
{% 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" id="domain-requests">
<section class="section-outlined domain-requests{% if portfolio %} section-outlined--border-base-light{% endif %}" id="domain-requests">
<div class="grid-row">
{% if not has_domain_requests_portfolio_permission %}
<div class="mobile:grid-col-12 desktop:grid-col-6">

View file

@ -5,8 +5,8 @@
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
{% url 'get_domains_json' as url %}
<span id="get_domains_json_url" class="display-none">{{url}}</span>
<section class="section--outlined domains{% if not portfolio %} margin-top-0{% endif %}" id="domains">
<div class="section--outlined__header margin-bottom-3 {% if not portfolio %} section--outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}">
<section class="section-outlined domains margin-top-0{% if portfolio %} section-outlined--border-base-light{% endif %}" id="domains">
<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="domains-header" class="display-inline-block">Domains</h2>
<span class="display-none" id="no-portfolio-js-flag"></span>
@ -14,7 +14,7 @@
<!-- Embedding the portfolio value in a data attribute -->
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>
{% endif %}
<div class="section--outlined__search {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6{% endif %}">
<div class="section-outlined__search {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6{% endif %}">
<section aria-label="Domains search component" class="margin-top-2">
<form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %}
@ -43,10 +43,10 @@
</section>
</div>
{% if user_domain_count and user_domain_count > 0 %}
<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 %}">
<section aria-label="Domains report component" class="mobile-lg:margin-top-205">
<a href="{% url 'export_data_type_user' %}" class="usa-button usa-button--unstyled" role="button">
<svg class="usa-icon usa-icon--big margin-right-neg-4px" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<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 %}">
<section aria-label="Domains report component" class="margin-top-205">
<a href="{% url 'export_data_type_user' %}" class="usa-button usa-button--unstyled usa-button--with-icon" 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>

View file

@ -12,6 +12,29 @@
<button type="button" class="usa-nav__close">
<img src="{%static 'img/usa-icons/close.svg'%}" role="img" alt="Close" />
</button>
<div class="usa-nav__secondary">
<ul class="usa-nav__secondary-links">
<li class="usa-nav__secondary-item">
{% if user.is_authenticated %}
<span class="ellipsis usa-nav__username">{{ user.email }}</span>
</li>
{% if has_profile_feature_flag %}
<li class="usa-nav__secondary-item">
{% url 'user-profile' as user_profile_url %}
{% url 'finish-user-profile-setup' as finish_setup_url %}
<a class="usa-nav-link {% if path == user_profile_url or path == finish_setup_url %}usa-current{% endif %}" href="{{ user_profile_url }}">
Your profile
</a>
</li>
{% endif %}
<li class="usa-nav__secondary-item">
<a class="usa-nav-link" href="{% url 'logout' %}">Sign out</a>
{% else %}
<a class="usa-nav-link" href="{% url 'login' %}">Sign in</a>
{% endif %}
</li>
</ul>
</div>
<ul class="usa-nav__primary usa-accordion">
<li class="usa-nav__primary-item">
{% if has_domains_portfolio_permission %}
@ -45,36 +68,13 @@
<li class="usa-nav__primary-item">
{% url 'organization' as url %}
<!-- Move the padding from the a to the span so that the descenders do not get cut off -->
<a href="{{ url }}" class="usa-nav-link padding-y-0">
<a href="{{ url }}" class="usa-nav-link padding-y-0 {% if request.path == '/organization/' %} usa-current{% endif %}">
<span class="ellipsis ellipsis--23 ellipsis--desktop-50 padding-y-1 desktop:padding-y-2">
{{ portfolio.organization_name }}
</span>
</a>
</li>
</ul>
<div class="usa-nav__secondary">
<ul class="usa-nav__secondary-links">
<li class="usa-nav__secondary-item">
{% if user.is_authenticated %}
<span class="ellipsis usa-nav__username">{{ user.email }}</span>
</li>
{% if has_profile_feature_flag %}
<li class="usa-nav__secondary-item">
{% url 'user-profile' as user_profile_url %}
{% url 'finish-user-profile-setup' as finish_setup_url %}
<a class="usa-nav-link {% if path == user_profile_url or path == finish_setup_url %}usa-current{% endif %}" href="{{ user_profile_url }}">
Your profile
</a>
</li>
{% endif %}
<li class="usa-nav__secondary-item">
<a class="usa-nav-link" href="{% url 'logout' %}">Sign out</a>
{% else %}
<a class="usa-nav-link" href="{% url 'login' %}">Sign in</a>
{% endif %}
</li>
</ul>
</div>
</div>
</nav>
{% endblock %}

View file

@ -4,7 +4,7 @@
{% include "includes/form_errors.html" with form=form %}
{% endif %}
<h1>Senior Official</h1>
<h1>Senior official</h1>
<p>
Your senior official is a person within your organization who can authorize domain requests.

View file

@ -1,6 +1,6 @@
{% load static field_helpers url_helpers custom_filters %}
<div id="{{field.name}}__edit-button-readonly" class="margin-top-2 margin-bottom-1 input-with-edit-button {% if not field.value and field.field.required %}input-with-edit-button__error{% endif %}">
<div id="{{field.name}}__edit-button-readonly" class="margin-top-2 margin-bottom-1 toggleable_input {% if not field.value and field.field.required %}toggleable_input__error{% endif %}">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
{% if field.value or not field.field.required %}
<use xlink:href="{%static 'img/sprite.svg'%}#check_circle"></use>
@ -8,7 +8,7 @@
<use xlink:href="{%static 'img/sprite.svg'%}#error"></use>
{%endif %}
</svg>
<div class="display-inline padding-left-05 margin-left-3 input-with-edit-button__readonly-field {% if not field.field.required %}text-base{% endif %}">
<div class="display-inline padding-left-05 margin-left-3 toggleable_input__readonly-field {% if not field.field.required %}text-base{% endif %}">
{% if field.name != "phone" %}
{{ field.value }}
{% else %}

View file

@ -5,9 +5,10 @@
{% block title %} Domains | {% endblock %}
{% block portfolio_content %}
<div id="main-content">
<h1 id="domains-header">Domains</h1>
<section class="section--outlined">
<div class="section--outlined__header margin-bottom-3">
<section class="section-outlined">
<div class="section-outlined__header margin-bottom-3">
<h2 id="domains-header" class="display-inline-block">You arent managing any domains.</h2>
{% if portfolio_administrators %}
<p>If you believe you should have access to a domain, reach out to your organizations administrators.</p>
@ -27,4 +28,5 @@
{% endif %}
</div>
</section>
</div>
{% endblock %}

View file

@ -1,8 +1,8 @@
{% extends "base.html" %}
{% block wrapper %}
<div id="wrapper" class="dashboard--portfolio">
{% block content %}
<div id="wrapper" class="{% block wrapper_class %}dashboard--portfolio{% endblock %}">
{% block content %}
<main id="main-content" class="grid-container {% if is_widescreen_mode %} grid-container--widescreen {% endif %}">
{% if user.is_authenticated %}
@ -26,10 +26,8 @@
{% endif %}
</main>
{% endblock %}
{% endblock content%}
<div role="complementary">{% block complementary %}{% endblock %}</div>
{% block content_bottom %}{% endblock %}
</div>
{% endblock wrapper %}

View file

@ -4,7 +4,13 @@
{% block title %} Domains | {% endblock %}
{% block wrapper_class %}
{{ block.super }} dashboard--grey-1
{% endblock %}
{% block portfolio_content %}
<div id="main-content">
<h1 id="domains-header">Domains</h1>
{% include "includes/domains_table.html" with portfolio=portfolio user_domain_count=user_domain_count %}
</div>
{% endblock %}

View file

@ -1,7 +1,7 @@
{% extends 'portfolio_base.html' %}
{% load static field_helpers%}
{% block title %}Organization mailing address | {{ portfolio.name }} | {% endblock %}
{% block title %}Organization mailing address | {{ portfolio.name }}{% endblock %}
{% load static %}
@ -17,7 +17,7 @@
{% include 'portfolio_organization_sidebar.html' %}
</div>
<div class="tablet:grid-col-9">
<div class="tablet:grid-col-9" id="main-content">
<h1>Organization</h1>
@ -41,7 +41,7 @@
{% input_with_errors form.address_line2 %}
{% input_with_errors form.city %}
{% input_with_errors form.state_territory %}
{% with add_class="usa-input--small" %}
{% with add_class="usa-input--small" sublabel_text="Enter a zip code in the required format, like 12345 or 12345-6789." %}
{% input_with_errors form.zipcode %}
{% endwith %}
<button type="submit" class="usa-button">

View file

@ -4,7 +4,12 @@
{% block title %} Domain requests | {% endblock %}
{% block wrapper_class %}
{{ block.super }} dashboard--grey-1
{% endblock %}
{% block portfolio_content %}
<div id="main-content">
<h1 id="domain-requests-header">Domain requests</h1>
{% comment %}
@ -20,4 +25,5 @@
</p>
{% include "includes/domain_requests_table.html" with portfolio=portfolio %}
</div>
{% endblock %}

View file

@ -1,7 +1,7 @@
{% extends 'portfolio_base.html' %}
{% load static field_helpers%}
{% block title %}Senior Official | {{ portfolio.name }} | {% endblock %}
{% block title %}Senior official | {{ portfolio.name }}{% endblock %}
{% load static %}
@ -17,7 +17,7 @@
{% include 'portfolio_organization_sidebar.html' %}
</div>
<div class="tablet:grid-col-9">
<div class="tablet:grid-col-9" id="main-content">
{% include "includes/senior_official.html" with can_edit=False %}
</div>
</div>

View file

@ -45,6 +45,8 @@ from registrar.models import (
from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.senior_official import SeniorOfficial
from registrar.models.user_domain_role import UserDomainRole
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.models.verified_by_staff import VerifiedByStaff
from .common import (
MockDbForSharedTests,
@ -2066,6 +2068,7 @@ class TestPortfolioAdmin(TestCase):
DomainRequest.objects.all().delete()
Domain.objects.all().delete()
Portfolio.objects.all().delete()
User.objects.all().delete()
@less_console_noise_decorator
def test_created_on_display(self):
@ -2117,3 +2120,91 @@ class TestPortfolioAdmin(TestCase):
domain_requests = self.admin.domain_requests(self.portfolio)
self.assertIn("2 domain requests", domain_requests)
@less_console_noise_decorator
def test_portfolio_members_display(self):
"""Tests the custom portfolio members field, admin and member sections"""
admin_user_1 = User.objects.create(
username="testuser1",
first_name="Gerald",
last_name="Meoward",
title="Captain",
email="meaoward@gov.gov",
)
UserPortfolioPermission.objects.all().create(
user=admin_user_1, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
admin_user_2 = User.objects.create(
username="testuser2",
first_name="Arnold",
last_name="Poopy",
title="Major",
email="poopy@gov.gov",
)
UserPortfolioPermission.objects.all().create(
user=admin_user_2, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
admin_user_3 = User.objects.create(
username="testuser3",
first_name="Mad",
last_name="Max",
title="Road warrior",
email="madmax@gov.gov",
)
UserPortfolioPermission.objects.all().create(
user=admin_user_3, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
)
admin_user_4 = User.objects.create(
username="testuser4",
first_name="Agent",
last_name="Smith",
title="Program",
email="thematrix@gov.gov",
)
UserPortfolioPermission.objects.all().create(
user=admin_user_4,
portfolio=self.portfolio,
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.EDIT_REQUESTS,
],
)
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,
)
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)

View file

@ -1311,6 +1311,7 @@ class TestUser(TestCase):
self.domain, _ = Domain.objects.get_or_create(name="igorville.gov")
self.user, _ = User.objects.get_or_create(email=self.email)
self.factory = RequestFactory()
self.portfolio = Portfolio.objects.create(organization_name="Test Portfolio", creator=self.user)
def tearDown(self):
super().tearDown()
@ -1325,6 +1326,65 @@ class TestUser(TestCase):
User.objects.all().delete()
UserDomainRole.objects.all().delete()
@patch.object(User, "has_edit_suborganization", return_value=True)
def test_portfolio_role_summary_admin(self, mock_edit_suborganization):
# Test if the user is recognized as an Admin
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Admin"])
@patch.multiple(
User,
has_view_all_domains_permission=lambda self, portfolio: True,
has_domain_requests_portfolio_permission=lambda self, portfolio: True,
has_edit_requests=lambda self, portfolio: True,
)
def test_portfolio_role_summary_view_only_admin_and_domain_requestor(self):
# Test if the user has both 'View-only admin' and 'Domain requestor' roles
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["View-only admin", "Domain requestor"])
@patch.multiple(
User,
has_view_all_domains_permission=lambda self, portfolio: True,
has_domain_requests_portfolio_permission=lambda self, portfolio: True,
)
def test_portfolio_role_summary_view_only_admin(self):
# Test if the user is recognized as a View-only admin
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["View-only admin"])
@patch.multiple(
User,
has_base_portfolio_permission=lambda self, portfolio: True,
has_edit_requests=lambda self, portfolio: True,
has_domains_portfolio_permission=lambda self, portfolio: True,
)
def test_portfolio_role_summary_member_domain_requestor_domain_manager(self):
# Test if the user has 'Member', 'Domain requestor', and 'Domain manager' roles
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Domain requestor", "Domain manager"])
@patch.multiple(
User, has_base_portfolio_permission=lambda self, portfolio: True, has_edit_requests=lambda self, portfolio: True
)
def test_portfolio_role_summary_member_domain_requestor(self):
# Test if the user has 'Member' and 'Domain requestor' roles
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Domain requestor"])
@patch.multiple(
User,
has_base_portfolio_permission=lambda self, portfolio: True,
has_domains_portfolio_permission=lambda self, portfolio: True,
)
def test_portfolio_role_summary_member_domain_manager(self):
# Test if the user has 'Member' and 'Domain manager' roles
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Domain manager"])
@patch.multiple(User, has_base_portfolio_permission=lambda self, portfolio: True)
def test_portfolio_role_summary_member(self):
# Test if the user is recognized as a Member
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Member"])
def test_portfolio_role_summary_empty(self):
# Test if the user has no roles
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), [])
@less_console_noise_decorator
def test_check_transition_domains_without_domains_on_login(self):
"""A user's on_each_login callback does not check transition domains.

View file

@ -1153,7 +1153,7 @@ class TestDomainSeniorOfficial(TestDomainOverview):
def test_domain_senior_official(self):
"""Can load domain's senior official page."""
page = self.client.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id}))
self.assertContains(page, "Senior official", count=3)
self.assertContains(page, "Senior official", count=4)
@less_console_noise_decorator
def test_domain_senior_official_content(self):