merge main

This commit is contained in:
Rachid Mrad 2024-08-28 16:23:50 -04:00
commit c140f26a08
No known key found for this signature in database
16 changed files with 561 additions and 88 deletions

View file

@ -34,6 +34,7 @@ from django_fsm import TransitionNotAllowed # type: ignore
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.html import escape from django.utils.html import escape
from django.contrib.auth.forms import UserChangeForm, UsernameField from django.contrib.auth.forms import UserChangeForm, UsernameField
from django.contrib.admin.views.main import IGNORED_PARAMS
from django_admin_multiple_choice_list_filter.list_filters import MultipleChoiceListFilter from django_admin_multiple_choice_list_filter.list_filters import MultipleChoiceListFilter
from import_export import resources from import_export import resources
from import_export.admin import ImportExportModelAdmin from import_export.admin import ImportExportModelAdmin
@ -223,7 +224,7 @@ class DomainRequestAdminForm(forms.ModelForm):
"other_contacts": NoAutocompleteFilteredSelectMultiple("other_contacts", False), "other_contacts": NoAutocompleteFilteredSelectMultiple("other_contacts", False),
} }
labels = { labels = {
"action_needed_reason_email": "Auto-generated email", "action_needed_reason_email": "Email",
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -366,7 +367,9 @@ class DomainRequestAdminForm(forms.ModelForm):
class MultiFieldSortableChangeList(admin.views.main.ChangeList): class MultiFieldSortableChangeList(admin.views.main.ChangeList):
""" """
This class overrides the behavior of column sorting in django admin tables in order This class overrides the behavior of column sorting in django admin tables in order
to allow for multi field sorting on admin_order_field to allow for multi field sorting on admin_order_field. It also overrides behavior
of getting the filter params to allow portfolio filters to be executed without
displaying on the right side of the ChangeList view.
Usage: Usage:
@ -428,6 +431,24 @@ class MultiFieldSortableChangeList(admin.views.main.ChangeList):
return ordering return ordering
def get_filters_params(self, params=None):
"""
Add portfolio to ignored params to allow the portfolio filter while not
listing it as a filter option on the right side of Change List on the
portfolio list.
"""
params = params or self.params
lookup_params = params.copy() # a dictionary of the query string
# Remove all the parameters that are globally and systematically
# ignored.
# Remove portfolio so that it does not error as an invalid
# filter parameter.
ignored_params = list(IGNORED_PARAMS) + ["portfolio"]
for ignored in ignored_params:
if ignored in lookup_params:
del lookup_params[ignored]
return lookup_params
class CustomLogEntryAdmin(LogEntryAdmin): class CustomLogEntryAdmin(LogEntryAdmin):
"""Overwrite the generated LogEntry admin class""" """Overwrite the generated LogEntry admin class"""
@ -644,6 +665,19 @@ class ListHeaderAdmin(AuditedAdmin, OrderableFieldsMixin):
) )
except models.User.DoesNotExist: except models.User.DoesNotExist:
pass pass
elif parameter_name == "portfolio":
# Retrieves the corresponding portfolio from Portfolio
id_value = request.GET.get(param)
try:
portfolio = models.Portfolio.objects.get(id=id_value)
filters.append(
{
"parameter_name": "portfolio",
"parameter_value": portfolio.organization_name,
}
)
except models.Portfolio.DoesNotExist:
pass
else: else:
# For other parameter names, append a dictionary with the original # For other parameter names, append a dictionary with the original
# parameter_name and the corresponding parameter_value # parameter_name and the corresponding parameter_value
@ -2236,6 +2270,17 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
use_sort = db_field.name != "senior_official" use_sort = db_field.name != "senior_official"
return super().formfield_for_foreignkey(db_field, request, use_admin_sort_fields=use_sort, **kwargs) return super().formfield_for_foreignkey(db_field, request, use_admin_sort_fields=use_sort, **kwargs)
def get_queryset(self, request):
"""Custom get_queryset to filter by portfolio if portfolio is in the
request params."""
qs = super().get_queryset(request)
# Check if a 'portfolio' parameter is passed in the request
portfolio_id = request.GET.get("portfolio")
if portfolio_id:
# Further filter the queryset by the portfolio
qs = qs.filter(portfolio=portfolio_id)
return qs
class TransitionDomainAdmin(ListHeaderAdmin): class TransitionDomainAdmin(ListHeaderAdmin):
"""Custom transition domain admin class.""" """Custom transition domain admin class."""
@ -2689,6 +2734,17 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
return True return True
return super().has_change_permission(request, obj) return super().has_change_permission(request, obj)
def get_queryset(self, request):
"""Custom get_queryset to filter by portfolio if portfolio is in the
request params."""
qs = super().get_queryset(request)
# Check if a 'portfolio' parameter is passed in the request
portfolio_id = request.GET.get("portfolio")
if portfolio_id:
# Further filter the queryset by the portfolio
qs = qs.filter(domain_info__portfolio=portfolio_id)
return qs
class DraftDomainResource(resources.ModelResource): class DraftDomainResource(resources.ModelResource):
"""defines how each field in the referenced model should be mapped to the corresponding fields in the """defines how each field in the referenced model should be mapped to the corresponding fields in the
@ -2873,7 +2929,7 @@ class PortfolioAdmin(ListHeaderAdmin):
# "classes": ("collapse", "closed"), # "classes": ("collapse", "closed"),
# "fields": ["administrators", "members"]} # "fields": ["administrators", "members"]}
# ), # ),
("Portfolio domains", {"classes": ("collapse", "closed"), "fields": ["domains", "domain_requests"]}), ("Portfolio domains", {"fields": ["domains", "domain_requests"]}),
("Type of organization", {"fields": ["organization_type", "federal_type"]}), ("Type of organization", {"fields": ["organization_type", "federal_type"]}),
( (
"Organization name and mailing address", "Organization name and mailing address",
@ -2955,18 +3011,27 @@ class PortfolioAdmin(ListHeaderAdmin):
suborganizations.short_description = "Suborganizations" # type: ignore suborganizations.short_description = "Suborganizations" # type: ignore
def domains(self, obj: models.Portfolio): def domains(self, obj: models.Portfolio):
"""Returns a list of links for each related domain""" """Returns the count of domains with a link to view them in the admin."""
queryset = obj.get_domains() domain_count = obj.get_domains().count() # Count the related domains
return self.get_field_links_as_list( if domain_count > 0:
queryset, "domaininformation", link_info_attribute="get_state_display_of_domain" # Construct the URL to the admin page, filtered by portfolio
) url = reverse("admin:registrar_domain_changelist") + f"?portfolio={obj.id}"
label = "domain" if domain_count == 1 else "domains"
# Create a clickable link with the domain count
return format_html('<a href="{}">{} {}</a>', url, domain_count, label)
return "No domains"
domains.short_description = "Domains" # type: ignore domains.short_description = "Domains" # type: ignore
def domain_requests(self, obj: models.Portfolio): def domain_requests(self, obj: models.Portfolio):
"""Returns a list of links for each related domain request""" """Returns the count of domain requests with a link to view them in the admin."""
queryset = obj.get_domain_requests() domain_request_count = obj.get_domain_requests().count() # Count the related domain requests
return self.get_field_links_as_list(queryset, "domainrequest", link_info_attribute="get_status_display") if domain_request_count > 0:
# Construct the URL to the admin page, filtered by portfolio
url = reverse("admin:registrar_domainrequest_changelist") + f"?portfolio={obj.id}"
# Create a clickable link with the domain request count
return format_html('<a href="{}">{} domain requests</a>', url, domain_request_count)
return "No domain requests"
domain_requests.short_description = "Domain requests" # type: ignore domain_requests.short_description = "Domain requests" # type: ignore

View file

@ -351,7 +351,7 @@ function initializeWidgetOnList(list, parentId) {
let rejectionReasonFormGroup = document.querySelector('.field-rejection_reason') let rejectionReasonFormGroup = document.querySelector('.field-rejection_reason')
// This is the "action needed reason" field // This is the "action needed reason" field
let actionNeededReasonFormGroup = document.querySelector('.field-action_needed_reason'); let actionNeededReasonFormGroup = document.querySelector('.field-action_needed_reason');
// This is the "auto-generated email" field // This is the "Email" field
let actionNeededReasonEmailFormGroup = document.querySelector('.field-action_needed_reason_email') let actionNeededReasonEmailFormGroup = document.querySelector('.field-action_needed_reason_email')
if (rejectionReasonFormGroup && actionNeededReasonFormGroup && actionNeededReasonEmailFormGroup) { if (rejectionReasonFormGroup && actionNeededReasonFormGroup && actionNeededReasonEmailFormGroup) {
@ -507,22 +507,38 @@ function initializeWidgetOnList(list, parentId) {
(function () { (function () {
// Since this is an iife, these vars will be removed from memory afterwards // Since this is an iife, these vars will be removed from memory afterwards
var actionNeededReasonDropdown = document.querySelector("#id_action_needed_reason"); var actionNeededReasonDropdown = document.querySelector("#id_action_needed_reason");
var actionNeededEmail = document.querySelector("#id_action_needed_reason_email");
var readonlyView = document.querySelector("#action-needed-reason-email-readonly"); // Placeholder text (for certain "action needed" reasons that do not involve e=mails)
var placeholderText = document.querySelector("#action-needed-reason-email-placeholder-text")
// E-mail divs and textarea components
var actionNeededEmail = document.querySelector("#id_action_needed_reason_email")
var actionNeededEmailReadonly = document.querySelector("#action-needed-reason-email-readonly")
var actionNeededEmailReadonlyTextarea = document.querySelector("#action-needed-reason-email-readonly-textarea")
// Edit e-mail modal (and its confirmation button)
var actionNeededEmailAlreadySentModal = document.querySelector("#email-already-sent-modal")
var confirmEditEmailButton = document.querySelector("#email-already-sent-modal_continue-editing-button")
// Headers and footers (which change depending on if the e-mail was sent or not)
var actionNeededEmailHeader = document.querySelector("#action-needed-email-header")
var actionNeededEmailHeaderOnSave = document.querySelector("#action-needed-email-header-email-sent")
var actionNeededEmailFooter = document.querySelector("#action-needed-email-footer")
let emailWasSent = document.getElementById("action-needed-email-sent"); let emailWasSent = document.getElementById("action-needed-email-sent");
let lastSentEmailText = document.getElementById("action-needed-email-last-sent-text");
// Get the list of e-mails associated with each action-needed dropdown value
let emailData = document.getElementById('action-needed-emails-data'); let emailData = document.getElementById('action-needed-emails-data');
if (!emailData) { if (!emailData) {
return; return;
} }
let actionNeededEmailData = emailData.textContent; let actionNeededEmailData = emailData.textContent;
if(!actionNeededEmailData) { if(!actionNeededEmailData) {
return; return;
} }
let actionNeededEmailsJson = JSON.parse(actionNeededEmailData); let actionNeededEmailsJson = JSON.parse(actionNeededEmailData);
const domainRequestId = actionNeededReasonDropdown ? document.querySelector("#domain_request_id").value : null const domainRequestId = actionNeededReasonDropdown ? document.querySelector("#domain_request_id").value : null
const emailSentSessionVariableName = `actionNeededEmailSent-${domainRequestId}`; const emailSentSessionVariableName = `actionNeededEmailSent-${domainRequestId}`;
const oldDropdownValue = actionNeededReasonDropdown ? actionNeededReasonDropdown.value : null; const oldDropdownValue = actionNeededReasonDropdown ? actionNeededReasonDropdown.value : null;
@ -538,58 +554,117 @@ function initializeWidgetOnList(list, parentId) {
// An email was sent out - store that information in a session variable // An email was sent out - store that information in a session variable
addOrRemoveSessionBoolean(emailSentSessionVariableName, add=true); addOrRemoveSessionBoolean(emailSentSessionVariableName, add=true);
} }
// Show an editable email field or a readonly one // Show an editable email field or a readonly one
updateActionNeededEmailDisplay(reason) updateActionNeededEmailDisplay(reason)
}); });
editEmailButton.addEventListener("click", function() {
if (!checkEmailAlreadySent()) {
showEmail(canEdit=true)
}
});
confirmEditEmailButton.addEventListener("click", function() {
// Show editable view
showEmail(canEdit=true)
});
// Add a change listener to the action needed reason dropdown // Add a change listener to the action needed reason dropdown
actionNeededReasonDropdown.addEventListener("change", function() { actionNeededReasonDropdown.addEventListener("change", function() {
let reason = actionNeededReasonDropdown.value; let reason = actionNeededReasonDropdown.value;
let emailBody = reason in actionNeededEmailsJson ? actionNeededEmailsJson[reason] : null; let emailBody = reason in actionNeededEmailsJson ? actionNeededEmailsJson[reason] : null;
if (reason && emailBody) { if (reason && emailBody) {
// Replace the email content
actionNeededEmail.value = emailBody;
// Reset the session object on change since change refreshes the email content. // Reset the session object on change since change refreshes the email content.
if (oldDropdownValue !== actionNeededReasonDropdown.value || oldEmailValue !== actionNeededEmail.value) { if (oldDropdownValue !== actionNeededReasonDropdown.value || oldEmailValue !== actionNeededEmail.value) {
let emailSent = sessionStorage.getItem(emailSentSessionVariableName) // Replace the email content
if (emailSent !== null){ actionNeededEmail.value = emailBody;
addOrRemoveSessionBoolean(emailSentSessionVariableName, add=false) actionNeededEmailReadonlyTextarea.value = emailBody;
} hideEmailAlreadySentView();
} }
} }
// Show an editable email field or a readonly one // Show either a preview of the email or some text describing no email will be sent
updateActionNeededEmailDisplay(reason) updateActionNeededEmailDisplay(reason)
}); });
} }
// Shows an editable email field or a readonly one. function checkEmailAlreadySent()
{
lastEmailSent = lastSentEmailText.value.replace(/\s+/g, '')
currentEmailInTextArea = actionNeededEmail.value.replace(/\s+/g, '')
return lastEmailSent === currentEmailInTextArea
}
// Shows a readonly preview of the email with updated messaging to indicate this email was sent
function showEmailAlreadySentView()
{
hideElement(actionNeededEmailHeader)
showElement(actionNeededEmailHeaderOnSave)
actionNeededEmailFooter.innerHTML = "This email has been sent to the creator of this request";
}
// Shows a readonly preview of the email with updated messaging to indicate this email was sent
function hideEmailAlreadySentView()
{
showElement(actionNeededEmailHeader)
hideElement(actionNeededEmailHeaderOnSave)
actionNeededEmailFooter.innerHTML = "This email will be sent to the creator of this request after saving";
}
// Shows either a preview of the email or some text describing no email will be sent.
// If the email doesn't exist or if we're of reason "other", display that no email was sent. // If the email doesn't exist or if we're of reason "other", display that no email was sent.
// Likewise, if we've sent this email before, we should just display the content.
function updateActionNeededEmailDisplay(reason) { function updateActionNeededEmailDisplay(reason) {
let emailHasBeenSentBefore = sessionStorage.getItem(emailSentSessionVariableName) !== null; hideElement(actionNeededEmail.parentElement)
let collapseableDiv = readonlyView.querySelector(".collapse--dgsimple");
let showMoreButton = document.querySelector("#action_needed_reason_email__show_details"); if (reason) {
if ((reason && reason != "other") && !emailHasBeenSentBefore) { if (reason === "other") {
showElement(actionNeededEmail.parentElement) // Hide email preview and show this text instead
hideElement(readonlyView) showPlaceholderText("No email will be sent");
hideElement(showMoreButton)
} else {
if (!reason || reason === "other") {
collapseableDiv.innerHTML = reason ? "No email will be sent." : "-";
hideElement(showMoreButton)
if (collapseableDiv.classList.contains("collapsed")) {
showMoreButton.click()
}
}else {
showElement(showMoreButton)
} }
hideElement(actionNeededEmail.parentElement) else {
showElement(readonlyView) // Always show readonly view of email to start
showEmail(canEdit=false)
if(checkEmailAlreadySent())
{
showEmailAlreadySentView();
}
}
} else {
// Hide email preview and show this text instead
showPlaceholderText("Select an action needed reason to see email");
} }
} }
// Shows either a readonly view (canEdit=false) or editable view (canEdit=true) of the action needed email
function showEmail(canEdit)
{
if(!canEdit)
{
showElement(actionNeededEmailReadonly)
hideElement(actionNeededEmail.parentElement)
}
else
{
hideElement(actionNeededEmailReadonly)
showElement(actionNeededEmail.parentElement)
}
showElement(actionNeededEmailFooter) // this is the same for both views, so it was separated out
hideElement(placeholderText)
}
// Hides preview of action needed email and instead displays the given text (innerHTML)
function showPlaceholderText(innerHTML)
{
hideElement(actionNeededEmail.parentElement)
hideElement(actionNeededEmailReadonly)
hideElement(actionNeededEmailFooter)
placeholderText.innerHTML = innerHTML;
showElement(placeholderText)
}
})(); })();

View file

@ -66,6 +66,9 @@ html[data-theme="light"] {
// --object-tools-fg: var(--button-fg); // --object-tools-fg: var(--button-fg);
// --object-tools-bg: var(--close-button-bg); // --object-tools-bg: var(--close-button-bg);
// --object-tools-hover-bg: var(--close-button-hover-bg); // --object-tools-hover-bg: var(--close-button-hover-bg);
--summary-box-bg: #f1f1f1;
--summary-box-border: #d1d2d2;
} }
// Fold dark theme settings into our main CSS // Fold dark theme settings into our main CSS
@ -104,6 +107,9 @@ html[data-theme="light"] {
--close-button-bg: #333333; --close-button-bg: #333333;
--close-button-hover-bg: #666666; --close-button-hover-bg: #666666;
--summary-box-bg: #121212;
--summary-box-border: #666666;
} }
// Dark mode django (bug due to scss cascade) and USWDS tables // Dark mode django (bug due to scss cascade) and USWDS tables
@ -873,6 +879,26 @@ div.dja__model-description{
} }
} }
.vertical-separator {
min-height: 20px;
height: 100%;
width: 1px;
background-color: #d1d2d2;
vertical-align: middle
}
.usa-summary-box_admin {
color: var(--body-fg);
border-color: var(--summary-box-border);
background-color: var(--summary-box-bg);
min-width: fit-content;
padding: .5rem;
border-radius: .25rem;
}
.text-faded {
color: #{$dhs-gray-60};
}
ul.add-list-reset { ul.add-list-reset {
padding: 0 !important; padding: 0 !important;
margin: 0 !important; margin: 0 !important;

View file

@ -66,6 +66,11 @@ urlpatterns = [
views.PortfolioDomainsView.as_view(), views.PortfolioDomainsView.as_view(),
name="domains", name="domains",
), ),
path(
"no-organization-domains/",
views.PortfolioNoDomainsView.as_view(),
name="no-portfolio-domains",
),
path( path(
"requests/", "requests/",
views.PortfolioDomainRequestsView.as_view(), views.PortfolioDomainRequestsView.as_view(),

View file

@ -151,8 +151,7 @@ class CheckPortfolioMiddleware:
if request.user.has_domains_portfolio_permission(): if request.user.has_domains_portfolio_permission():
portfolio_redirect = reverse("domains") portfolio_redirect = reverse("domains")
else: else:
# View organization is the lowest access portfolio_redirect = reverse("no-portfolio-domains")
portfolio_redirect = reverse("organization")
return HttpResponseRedirect(portfolio_redirect) return HttpResponseRedirect(portfolio_redirect)

View file

@ -145,20 +145,110 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% block field_other %} {% block field_other %}
{% if field.field.name == "action_needed_reason_email" %} {% if field.field.name == "action_needed_reason_email" %}
<div id="action-needed-reason-email-readonly" class="readonly margin-top-0 padding-top-0 display-none">
<div class="margin-top-05 collapse--dgsimple collapsed">
{{ field.field.value|linebreaks }}
</div>
<button id="action_needed_reason_email__show_details" type="button" class="collapse-toggle--dgsimple usa-button usa-button--unstyled margin-top-0 margin-bottom-1 margin-left-1">
<span>Show details</span>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="/public/img/sprite.svg#expand_more"></use>
</svg>
</button>
</div>
<div> <div>
{{ field.field }} <div id="action-needed-reason-email-placeholder-text" class="margin-top-05 text-faded">
<input id="action-needed-email-sent" class="display-none" value="{{action_needed_email_sent}}"> -
</div>
<div>
<div id="action-needed-reason-email-readonly" class="display-none usa-summary-box_admin padding-top-0 margin-top-0">
<div class="flex-container">
<div class="margin-top-05">
<p class="{% if action_needed_email_sent %}display-none{% endif %}" id="action-needed-email-header"><b>Auto-generated email that will be sent to the creator</b></p>
<p class="{% if not action_needed_email_sent %}display-none{% endif %}" id="action-needed-email-header-email-sent">
<svg class="usa-icon text-green" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{%static 'img/sprite.svg'%}#check_circle"></use>
</svg>
<b>Email sent to the creator</b>
</p>
</div>
<div class="vertical-separator margin-top-1 margin-bottom-1"></div>
<a
href="#email-already-sent-modal"
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text text-no-underline margin-left-1"
aria-controls="email-already-sent-modal"
data-open-modal
>Edit email</a
>
</div>
<div
class="usa-modal"
id="email-already-sent-modal"
aria-labelledby="Are you sure you want to edit this email?"
aria-describedby="The creator of this request already received an email"
>
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading" id="modal-1-heading">
Are you sure you want to edit this email?
</h2>
<div class="usa-prose">
<p>
The creator of this request already received an email for this status/reason:
</p>
<ul>
<li class="font-body-sm">Status: <b>Action needed</b></li>
<li class="font-body-sm">Reason: <b>{{ original_object.get_action_needed_reason_display }}</b></li>
</ul>
<p>
If you edit this email's text, <b>the system will send another email</b> to
the creator after you “save” your changes. If you do not want to send another email, click “cancel” below.
</p>
</div>
<div class="usa-modal__footer">
<ul class="usa-button-group">
<li class="usa-button-group__item">
<button
type="submit"
class="usa-button"
id="email-already-sent-modal_continue-editing-button"
data-close-modal
>
Yes, continue editing
</button>
</li>
<li class="usa-button-group__item">
<button
type="button"
class="usa-button usa-button--unstyled padding-105 text-center"
name="_cancel_edit_email"
data-close-modal
>
Cancel
</button>
</li>
</ul>
</div>
</div>
<button
type="button"
class="usa-button usa-modal__close"
aria-label="Close this window"
data-close-modal
>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg>
</button>
</div>
</div>
<label class="sr-only" for="action-needed-reason-email-readonly-textarea">Email:</label>
<textarea cols="40" rows="10" class="vLargeTextField" id="action-needed-reason-email-readonly-textarea" readonly>{{ field.field.value|striptags }}
</textarea>
</div>
<div>
{{ field.field }}
<input id="action-needed-email-sent" class="display-none" value="{{action_needed_email_sent}}">
<input id="action-needed-email-last-sent-text" class="display-none" value="{{original_object.action_needed_reason_email}}">
</div>
</div>
<span id="action-needed-email-footer" class="help">
{% if not action_needed_email_sent %}
This email will be sent to the creator of this request after saving
{% else %}
This email has been sent to the creator of this request
{% endif %}
</span>
</div> </div>
{% else %} {% else %}
{{ field.field }} {{ field.field }}

View file

@ -5,16 +5,16 @@
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %} {% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
{% url 'get_domains_json' as url %} {% url 'get_domains_json' as url %}
<span id="get_domains_json_url" class="display-none">{{url}}</span> <span id="get_domains_json_url" class="display-none">{{url}}</span>
<section class="section--outlined domains{% if not has_domains_portfolio_permission %} margin-top-0{% endif %}" id="domains"> <section class="section--outlined domains{% if not portfolio %} margin-top-0{% endif %}" id="domains">
<div class="section--outlined__header margin-bottom-3 {% if not has_domains_portfolio_permission %} section--outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}"> <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 has_domains_portfolio_permission %} {% if not portfolio %}
<h2 id="domains-header" class="display-inline-block">Domains</h2> <h2 id="domains-header" class="display-inline-block">Domains</h2>
<span class="display-none" id="no-portfolio-js-flag"></span> <span class="display-none" id="no-portfolio-js-flag"></span>
{% else %} {% else %}
<!-- Embedding the portfolio value in a data attribute --> <!-- Embedding the portfolio value in a data attribute -->
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span> <span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>
{% endif %} {% endif %}
<div class="section--outlined__search {% if has_domains_portfolio_permission %} 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"> <section aria-label="Domains search component" class="margin-top-2">
<form class="usa-search usa-search--small" method="POST" role="search"> <form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %} {% csrf_token %}
@ -43,7 +43,7 @@
</section> </section>
</div> </div>
{% if user_domain_count and user_domain_count > 0 %} {% if user_domain_count and user_domain_count > 0 %}
<div class="section--outlined__utility-button mobile-lg:padding-right-105 {% if has_domains_portfolio_permission %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}"> <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"> <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"> <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"> <svg class="usa-icon usa-icon--big margin-right-neg-4px" aria-hidden="true" focusable="false" role="img" width="24" height="24">
@ -54,7 +54,7 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% if has_domains_portfolio_permission %} {% if portfolio %}
<div class="display-flex flex-align-center"> <div class="display-flex flex-align-center">
<span class="margin-right-2 margin-top-neg-1 usa-prose text-base-darker">Filter by</span> <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 usa-accordion--select margin-right-2">
@ -157,7 +157,7 @@
<th data-sortable="name" scope="col" role="columnheader">Domain name</th> <th data-sortable="name" scope="col" role="columnheader">Domain name</th>
<th data-sortable="expiration_date" scope="col" role="columnheader">Expires</th> <th data-sortable="expiration_date" scope="col" role="columnheader">Expires</th>
<th data-sortable="state_display" scope="col" role="columnheader">Status</th> <th data-sortable="state_display" scope="col" role="columnheader">Status</th>
{% if has_domains_portfolio_permission and request.user.has_view_suborganization %} {% if portfolio and request.user.has_view_suborganization %}
<th data-sortable="suborganization" scope="col" role="columnheader">Suborganization</th> <th data-sortable="suborganization" scope="col" role="columnheader">Suborganization</th>
{% endif %} {% endif %}
<th <th

View file

@ -13,19 +13,22 @@
<img src="{%static 'img/usa-icons/close.svg'%}" role="img" alt="Close" /> <img src="{%static 'img/usa-icons/close.svg'%}" role="img" alt="Close" />
</button> </button>
<ul class="usa-nav__primary usa-accordion"> <ul class="usa-nav__primary usa-accordion">
{% if has_domains_portfolio_permission %} <li class="usa-nav__primary-item">
<li class="usa-nav__primary-item"> {% if has_domains_portfolio_permission %}
{% url 'domains' as url %} {% url 'domains' as url %}
<a href="{{ url }}" class="usa-nav-link{% if 'domain'|in_path:request.path %} usa-current{% endif %}"> {%else %}
Domains {% url 'no-portfolio-domains' as url %}
</a> {% endif %}
</li> <a href="{{ url }}" class="usa-nav-link{% if 'domain'|in_path:request.path %} usa-current{% endif %}">
{% endif %} Domains
</a>
</li>
<li class="usa-nav__primary-item"> <li class="usa-nav__primary-item">
<a href="#" class="usa-nav-link"> <a href="#" class="usa-nav-link">
Domain groups Domain groups
</a> </a>
</li> </li>
{% if has_domain_requests_portfolio_permission %} {% if has_domain_requests_portfolio_permission %}
<li class="usa-nav__primary-item"> <li class="usa-nav__primary-item">
{% url 'domain-requests' as url %} {% url 'domain-requests' as url %}

View file

@ -0,0 +1,30 @@
{% extends 'portfolio_base.html' %}
{% load static %}
{% block title %} Domains | {% endblock %}
{% block portfolio_content %}
<h1 id="domains-header">Domains</h1>
<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>
<p>Your organizations administrators:</p>
<ul class="margin-top-0">
{% for administrator in portfolio_administrators %}
{% if administrator.email %}
<li>{{ administrator.email }}</li>
{% else %}
<li>{{ administrator }}</li>
{% endif %}
{% endfor %}
</ul>
{% else %}
<p><strong>No administrators were found on your organization.</strong></p>
<p>If you believe you should have access to a domain, email <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.</p>
{% endif %}
</div>
</section>
{% endblock %}

View file

@ -2109,9 +2109,7 @@ class TestPortfolioAdmin(TestCase):
domain_2.save() domain_2.save()
domains = self.admin.domains(self.portfolio) domains = self.admin.domains(self.portfolio)
self.assertIn("domain1.gov", domains) self.assertIn("2 domains", domains)
self.assertIn("domain2.gov", domains)
self.assertIn('<ul class="add-list-reset">', domains)
@less_console_noise_decorator @less_console_noise_decorator
def test_domain_requests_display(self): def test_domain_requests_display(self):
@ -2120,6 +2118,7 @@ class TestPortfolioAdmin(TestCase):
completed_domain_request(name="request2.gov", portfolio=self.portfolio) completed_domain_request(name="request2.gov", portfolio=self.portfolio)
domain_requests = self.admin.domain_requests(self.portfolio) domain_requests = self.admin.domain_requests(self.portfolio)
self.assertIn("2 domain requests", domain_requests)
self.assertIn("request1.gov", domain_requests) self.assertIn("request1.gov", domain_requests)
self.assertIn("request2.gov", domain_requests) self.assertIn("request2.gov", domain_requests)
self.assertIn('<ul class="add-list-reset">', domain_requests) self.assertIn('<ul class="add-list-reset">', domain_requests)

View file

@ -14,7 +14,9 @@ from registrar.models import (
DomainInformation, DomainInformation,
User, User,
Host, Host,
Portfolio,
) )
from registrar.models.user_domain_role import UserDomainRole
from .common import ( from .common import (
MockSESClient, MockSESClient,
completed_domain_request, completed_domain_request,
@ -356,9 +358,11 @@ class TestDomainAdminWithClient(TestCase):
def tearDown(self): def tearDown(self):
super().tearDown() super().tearDown()
Host.objects.all().delete() Host.objects.all().delete()
UserDomainRole.objects.all().delete()
Domain.objects.all().delete() Domain.objects.all().delete()
DomainInformation.objects.all().delete() DomainInformation.objects.all().delete()
DomainRequest.objects.all().delete() DomainRequest.objects.all().delete()
Portfolio.objects.all().delete()
@classmethod @classmethod
def tearDownClass(self): def tearDownClass(self):
@ -452,6 +456,36 @@ class TestDomainAdminWithClient(TestCase):
domain_request.delete() domain_request.delete()
_creator.delete() _creator.delete()
@less_console_noise_decorator
def test_domains_by_portfolio(self):
"""
Tests that domains display for a portfolio. And that domains outside the portfolio do not display.
"""
portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test Portfolio", creator=self.superuser)
# Create a fake domain request and domain
_domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.IN_REVIEW, portfolio=portfolio
)
_domain_request.approve()
domain = _domain_request.approved_domain
domain2, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY)
UserDomainRole.objects.get_or_create()
UserDomainRole.objects.get_or_create(user=self.superuser, domain=domain2, role=UserDomainRole.Roles.MANAGER)
self.client.force_login(self.superuser)
response = self.client.get(
"/admin/registrar/domain/?portfolio={}".format(portfolio.pk),
follow=True,
)
# Make sure the page loaded, and that we're on the right page
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain.name)
self.assertNotContains(response, domain2.name)
self.assertContains(response, portfolio.organization_name)
@less_console_noise_decorator @less_console_noise_decorator
def test_helper_text(self): def test_helper_text(self):
""" """

View file

@ -22,6 +22,7 @@ from registrar.models import (
Contact, Contact,
Website, Website,
SeniorOfficial, SeniorOfficial,
Portfolio,
) )
from .common import ( from .common import (
MockSESClient, MockSESClient,
@ -78,6 +79,7 @@ class TestDomainRequestAdmin(MockEppLib):
Contact.objects.all().delete() Contact.objects.all().delete()
Website.objects.all().delete() Website.objects.all().delete()
SeniorOfficial.objects.all().delete() SeniorOfficial.objects.all().delete()
Portfolio.objects.all().delete()
self.mock_client.EMAILS_SENT.clear() self.mock_client.EMAILS_SENT.clear()
@classmethod @classmethod
@ -263,6 +265,33 @@ class TestDomainRequestAdmin(MockEppLib):
self.assertContains(response, domain_request.requested_domain.name) self.assertContains(response, domain_request.requested_domain.name)
self.assertContains(response, "<span>Show details</span>") self.assertContains(response, "<span>Show details</span>")
@less_console_noise_decorator
def test_domain_requests_by_portfolio(self):
"""
Tests that domain_requests display for a portfolio. And requests not in portfolio do not display.
"""
portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test Portfolio", creator=self.superuser)
# Create a fake domain request and domain
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.IN_REVIEW, portfolio=portfolio
)
domain_request2 = completed_domain_request(
name="testdomain2.gov", status=DomainRequest.DomainRequestStatus.IN_REVIEW
)
self.client.force_login(self.superuser)
response = self.client.get(
"/admin/registrar/domainrequest/?portfolio={}".format(portfolio.pk),
follow=True,
)
# Make sure the page loaded, and that we're on the right page
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain_request.requested_domain.name)
self.assertNotContains(response, domain_request2.requested_domain.name)
self.assertContains(response, portfolio.organization_name)
@less_console_noise_decorator @less_console_noise_decorator
def test_analyst_can_see_and_edit_alternative_domain(self): def test_analyst_can_see_and_edit_alternative_domain(self):
"""Tests if an analyst can still see and edit the alternative domain field""" """Tests if an analyst can still see and edit the alternative domain field"""

View file

@ -4,6 +4,8 @@ from registrar.models import FederalAgency, SeniorOfficial, User
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from registrar.tests.common import create_superuser, create_user from registrar.tests.common import create_superuser, create_user
from api.tests.common import less_console_noise_decorator
class GetSeniorOfficialJsonTest(TestCase): class GetSeniorOfficialJsonTest(TestCase):
def setUp(self): def setUp(self):
@ -26,6 +28,7 @@ class GetSeniorOfficialJsonTest(TestCase):
SeniorOfficial.objects.all().delete() SeniorOfficial.objects.all().delete()
FederalAgency.objects.all().delete() FederalAgency.objects.all().delete()
@less_console_noise_decorator
def test_get_senior_official_json_authenticated_superuser(self): def test_get_senior_official_json_authenticated_superuser(self):
"""Test that a superuser can fetch the senior official information.""" """Test that a superuser can fetch the senior official information."""
p = "adminpass" p = "adminpass"
@ -38,6 +41,7 @@ class GetSeniorOfficialJsonTest(TestCase):
self.assertEqual(data["last_name"], "Doe") self.assertEqual(data["last_name"], "Doe")
self.assertEqual(data["title"], "Director") self.assertEqual(data["title"], "Director")
@less_console_noise_decorator
def test_get_senior_official_json_authenticated_analyst(self): def test_get_senior_official_json_authenticated_analyst(self):
"""Test that an analyst user can fetch the senior official's information.""" """Test that an analyst user can fetch the senior official's information."""
p = "userpass" p = "userpass"
@ -50,6 +54,7 @@ class GetSeniorOfficialJsonTest(TestCase):
self.assertEqual(data["last_name"], "Doe") self.assertEqual(data["last_name"], "Doe")
self.assertEqual(data["title"], "Director") self.assertEqual(data["title"], "Director")
@less_console_noise_decorator
def test_get_senior_official_json_unauthenticated(self): def test_get_senior_official_json_unauthenticated(self):
"""Test that an unauthenticated user receives a 403 with an error message.""" """Test that an unauthenticated user receives a 403 with an error message."""
p = "password" p = "password"
@ -57,6 +62,7 @@ class GetSeniorOfficialJsonTest(TestCase):
response = self.client.get(self.api_url, {"agency_name": "Test Agency"}) response = self.client.get(self.api_url, {"agency_name": "Test Agency"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
@less_console_noise_decorator
def test_get_senior_official_json_not_found(self): def test_get_senior_official_json_not_found(self):
"""Test that a request for a non-existent agency returns a 404 with an error message.""" """Test that a request for a non-existent agency returns a 404 with an error message."""
p = "adminpass" p = "adminpass"

View file

@ -97,8 +97,8 @@ class TestPortfolio(WebTest):
self.assertNotContains(portfolio_page, self.portfolio.organization_name) self.assertNotContains(portfolio_page, self.portfolio.organization_name)
@less_console_noise_decorator @less_console_noise_decorator
def test_middleware_redirects_to_portfolio_organization_page(self): def test_middleware_redirects_to_portfolio_no_domains_page(self):
"""Test that user with a portfolio and VIEW_PORTFOLIO is redirected to portfolio organization page""" """Test that user with a portfolio and VIEW_PORTFOLIO is redirected to the no domains page"""
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio self.user.portfolio = self.portfolio
self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO] self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO]
@ -110,7 +110,8 @@ class TestPortfolio(WebTest):
portfolio_page = self.app.get(reverse("home")).follow() portfolio_page = self.app.get(reverse("home")).follow()
# Assert that we're on the right page # Assert that we're on the right page
self.assertContains(portfolio_page, self.portfolio.organization_name) self.assertContains(portfolio_page, self.portfolio.organization_name)
self.assertContains(portfolio_page, "<h1>Organization</h1>") self.assertContains(portfolio_page, '<h1 id="domains-header">Domains</h1>')
self.assertContains(portfolio_page, "You arent managing any domains")
@less_console_noise_decorator @less_console_noise_decorator
def test_middleware_redirects_to_portfolio_domains_page(self): def test_middleware_redirects_to_portfolio_domains_page(self):
@ -221,8 +222,8 @@ class TestPortfolio(WebTest):
self.assertContains(response, 'for="id_city"') self.assertContains(response, 'for="id_city"')
@less_console_noise_decorator @less_console_noise_decorator
def test_navigation_links_hidden_when_user_not_have_permission(self): def test_accessible_pages_when_user_does_not_have_permission(self):
"""Test that navigation links are hidden when user does not have portfolio permissions""" """Tests which pages are accessible when user does not have portfolio permissions"""
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio self.user.portfolio = self.portfolio
self.user.portfolio_additional_permissions = [ self.user.portfolio_additional_permissions = [
@ -249,16 +250,29 @@ class TestPortfolio(WebTest):
self.user.save() self.user.save()
self.user.refresh_from_db() self.user.refresh_from_db()
# Members should be redirected to the readonly domains page
portfolio_page = self.app.get(reverse("home")).follow() portfolio_page = self.app.get(reverse("home")).follow()
self.assertContains(portfolio_page, self.portfolio.organization_name) self.assertContains(portfolio_page, self.portfolio.organization_name)
self.assertContains(portfolio_page, "<h1>Organization</h1>") self.assertNotContains(portfolio_page, "<h1>Organization</h1>")
self.assertNotContains(portfolio_page, '<h1 id="domains-header">Domains</h1>') self.assertContains(portfolio_page, '<h1 id="domains-header">Domains</h1>')
self.assertContains(portfolio_page, "You arent managing any domains")
self.assertNotContains(portfolio_page, reverse("domains")) self.assertNotContains(portfolio_page, reverse("domains"))
self.assertNotContains(portfolio_page, reverse("domain-requests")) self.assertNotContains(portfolio_page, reverse("domain-requests"))
# The organization page should still be accessible
org_page = self.app.get(reverse("organization"))
self.assertContains(org_page, self.portfolio.organization_name)
self.assertContains(org_page, "<h1>Organization</h1>")
# Both domain pages should not be accessible
domain_page = self.app.get(reverse("domains"), expect_errors=True)
self.assertEquals(domain_page.status_code, 403)
domain_request_page = self.app.get(reverse("domain-requests"), expect_errors=True)
self.assertEquals(domain_request_page.status_code, 403)
@less_console_noise_decorator @less_console_noise_decorator
def test_navigation_links_hidden_when_user_not_have_role(self): def test_accessible_pages_when_user_does_not_have_role(self):
"""Test that admin / memmber roles are associated with the right access""" """Test that admin / memmber roles are associated with the right access"""
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio self.user.portfolio = self.portfolio
@ -282,14 +296,27 @@ class TestPortfolio(WebTest):
self.user.save() self.user.save()
self.user.refresh_from_db() self.user.refresh_from_db()
# Members should be redirected to the readonly domains page
portfolio_page = self.app.get(reverse("home")).follow() portfolio_page = self.app.get(reverse("home")).follow()
self.assertContains(portfolio_page, self.portfolio.organization_name) self.assertContains(portfolio_page, self.portfolio.organization_name)
self.assertContains(portfolio_page, "<h1>Organization</h1>") self.assertNotContains(portfolio_page, "<h1>Organization</h1>")
self.assertNotContains(portfolio_page, '<h1 id="domains-header">Domains</h1>') self.assertContains(portfolio_page, '<h1 id="domains-header">Domains</h1>')
self.assertContains(portfolio_page, "You arent managing any domains")
self.assertNotContains(portfolio_page, reverse("domains")) self.assertNotContains(portfolio_page, reverse("domains"))
self.assertNotContains(portfolio_page, reverse("domain-requests")) self.assertNotContains(portfolio_page, reverse("domain-requests"))
# The organization page should still be accessible
org_page = self.app.get(reverse("organization"))
self.assertContains(org_page, self.portfolio.organization_name)
self.assertContains(org_page, "<h1>Organization</h1>")
# Both domain pages should not be accessible
domain_page = self.app.get(reverse("domains"), expect_errors=True)
self.assertEquals(domain_page.status_code, 403)
domain_request_page = self.app.get(reverse("domain-requests"), expect_errors=True)
self.assertEquals(domain_request_page.status_code, 403)
@less_console_noise_decorator @less_console_noise_decorator
def test_portfolio_org_name(self): def test_portfolio_org_name(self):
"""Can load portfolio's org name page.""" """Can load portfolio's org name page."""
@ -355,3 +382,51 @@ class TestPortfolio(WebTest):
self.assertContains(success_result_page, "6 Downing st") self.assertContains(success_result_page, "6 Downing st")
self.assertContains(success_result_page, "London") self.assertContains(success_result_page, "London")
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
def test_org_member_can_only_see_domains_with_appropriate_permissions(self):
"""A user with the role organization_member should not have access to the domains page
if they do not have the right permissions.
"""
# A default organization member should not be able to see any domains
self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio
self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
self.user.save()
self.user.refresh_from_db()
self.assertFalse(self.user.has_domains_portfolio_permission())
response = self.app.get(reverse("no-portfolio-domains"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "You arent managing any domains.")
# Test the domains page - this user should not have access
response = self.app.get(reverse("domains"), expect_errors=True)
self.assertEqual(response.status_code, 403)
# Ensure that this user can see domains with the right permissions
self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS]
self.user.save()
self.user.refresh_from_db()
self.assertTrue(self.user.has_domains_portfolio_permission())
# Test the domains page - this user should have access
response = self.app.get(reverse("domains"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Domain name")
# Test the managed domains permission
self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS]
self.user.save()
self.user.refresh_from_db()
self.assertTrue(self.user.has_domains_portfolio_permission())
# Test the domains page - this user should have access
response = self.app.get(reverse("domains"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Domain name")

View file

@ -4,11 +4,13 @@ from django.shortcuts import render
from django.urls import reverse from django.urls import reverse
from django.contrib import messages from django.contrib import messages
from registrar.forms.portfolio import PortfolioOrgAddressForm, PortfolioSeniorOfficialForm from registrar.forms.portfolio import PortfolioOrgAddressForm, PortfolioSeniorOfficialForm
from registrar.models.portfolio import Portfolio from registrar.models import Portfolio, User
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
from registrar.views.utility.permission_views import ( from registrar.views.utility.permission_views import (
PortfolioDomainRequestsPermissionView, PortfolioDomainRequestsPermissionView,
PortfolioDomainsPermissionView, PortfolioDomainsPermissionView,
PortfolioBasePermissionView, PortfolioBasePermissionView,
NoPortfolioDomainsPermissionView,
) )
from django.views.generic import View from django.views.generic import View
from django.views.generic.edit import FormMixin from django.views.generic.edit import FormMixin
@ -38,6 +40,32 @@ class PortfolioDomainRequestsView(PortfolioDomainRequestsPermissionView, View):
return render(request, "portfolio_requests.html") return render(request, "portfolio_requests.html")
class PortfolioNoDomainsView(NoPortfolioDomainsPermissionView, View):
"""Some users have access to the underlying portfolio, but not any domains.
This is a custom view which explains that to the user - and denotes who to contact.
"""
model = Portfolio
template_name = "no_portfolio_domains.html"
def get(self, request):
return render(request, self.template_name, context=self.get_context_data())
def get_context_data(self, **kwargs):
"""Add additional context data to the template."""
# We can override the base class. This view only needs this item.
context = {}
portfolio = self.request.user.portfolio if self.request and self.request.user else None
if portfolio:
context["portfolio_administrators"] = User.objects.filter(
portfolio=portfolio,
portfolio_roles__overlap=[
UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
],
)
return context
class PortfolioOrganizationView(PortfolioBasePermissionView, FormMixin): class PortfolioOrganizationView(PortfolioBasePermissionView, FormMixin):
""" """
View to handle displaying and updating the portfolio's organization details. View to handle displaying and updating the portfolio's organization details.

View file

@ -214,6 +214,15 @@ class PortfolioDomainsPermissionView(PortfolioDomainsPermission, PortfolioBasePe
""" """
class NoPortfolioDomainsPermissionView(PortfolioBasePermissionView, abc.ABC):
"""Abstract base view for a user without access to the
portfolio domains views that enforces permissions.
This abstract view cannot be instantiated. Actual views must specify
`template_name`.
"""
class PortfolioDomainRequestsPermissionView(PortfolioDomainRequestsPermission, PortfolioBasePermissionView, abc.ABC): class PortfolioDomainRequestsPermissionView(PortfolioDomainRequestsPermission, PortfolioBasePermissionView, abc.ABC):
"""Abstract base view for portfolio domain request views that enforces permissions. """Abstract base view for portfolio domain request views that enforces permissions.