Merge remote-tracking branch 'origin/main' into ms/2451-additional-domain-request-dates

This commit is contained in:
Matthew Spence 2024-08-29 09:52:48 -05:00
commit 72375e3c63
No known key found for this signature in database
32 changed files with 1328 additions and 378 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
@ -131,14 +132,6 @@ class MyUserAdminForm(UserChangeForm):
widgets = { widgets = {
"groups": NoAutocompleteFilteredSelectMultiple("groups", False), "groups": NoAutocompleteFilteredSelectMultiple("groups", False),
"user_permissions": NoAutocompleteFilteredSelectMultiple("user_permissions", False), "user_permissions": NoAutocompleteFilteredSelectMultiple("user_permissions", False),
"portfolio_roles": FilteredSelectMultipleArrayWidget(
"portfolio_roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices
),
"portfolio_additional_permissions": FilteredSelectMultipleArrayWidget(
"portfolio_additional_permissions",
is_stacked=False,
choices=UserPortfolioPermissionChoices.choices,
),
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -170,6 +163,22 @@ class MyUserAdminForm(UserChangeForm):
) )
class UserPortfolioPermissionsForm(forms.ModelForm):
class Meta:
model = models.UserPortfolioPermission
fields = "__all__"
widgets = {
"roles": FilteredSelectMultipleArrayWidget(
"roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices
),
"additional_permissions": FilteredSelectMultipleArrayWidget(
"additional_permissions",
is_stacked=False,
choices=UserPortfolioPermissionChoices.choices,
),
}
class PortfolioInvitationAdminForm(UserChangeForm): class PortfolioInvitationAdminForm(UserChangeForm):
"""This form utilizes the custom widget for its class's ManyToMany UIs.""" """This form utilizes the custom widget for its class's ManyToMany UIs."""
@ -223,7 +232,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 +375,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 +439,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 +673,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
@ -710,19 +752,12 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
"is_superuser", "is_superuser",
"groups", "groups",
"user_permissions", "user_permissions",
"portfolio",
"portfolio_roles",
"portfolio_additional_permissions",
) )
}, },
), ),
("Important dates", {"fields": ("last_login", "date_joined")}), ("Important dates", {"fields": ("last_login", "date_joined")}),
) )
autocomplete_fields = [
"portfolio",
]
readonly_fields = ("verification_type",) readonly_fields = ("verification_type",)
analyst_fieldsets = ( analyst_fieldsets = (
@ -742,9 +777,6 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
"fields": ( "fields": (
"is_active", "is_active",
"groups", "groups",
"portfolio",
"portfolio_roles",
"portfolio_additional_permissions",
) )
}, },
), ),
@ -799,9 +831,6 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
"Important dates", "Important dates",
"last_login", "last_login",
"date_joined", "date_joined",
"portfolio",
"portfolio_roles",
"portfolio_additional_permissions",
] ]
# TODO: delete after we merge organization feature # TODO: delete after we merge organization feature
@ -1210,6 +1239,26 @@ class UserDomainRoleResource(resources.ModelResource):
model = models.UserDomainRole model = models.UserDomainRole
class UserPortfolioPermissionAdmin(ListHeaderAdmin):
form = UserPortfolioPermissionsForm
class Meta:
"""Contains meta information about this class"""
model = models.UserPortfolioPermission
fields = "__all__"
_meta = Meta()
# Columns
list_display = [
"user",
"portfolio",
]
autocomplete_fields = ["user", "portfolio"]
class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin): class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"""Custom user domain role admin class.""" """Custom user domain role admin class."""
@ -2238,6 +2287,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."""
@ -2691,6 +2751,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
@ -2875,7 +2946,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",
@ -2957,18 +3028,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
@ -3199,6 +3279,7 @@ admin.site.register(models.Portfolio, PortfolioAdmin)
admin.site.register(models.DomainGroup, DomainGroupAdmin) admin.site.register(models.DomainGroup, DomainGroupAdmin)
admin.site.register(models.Suborganization, SuborganizationAdmin) admin.site.register(models.Suborganization, SuborganizationAdmin)
admin.site.register(models.SeniorOfficial, SeniorOfficialAdmin) admin.site.register(models.SeniorOfficial, SeniorOfficialAdmin)
admin.site.register(models.UserPortfolioPermission, UserPortfolioPermissionAdmin)
# Register our custom waffle implementations # Register our custom waffle implementations
admin.site.register(models.WaffleFlag, WaffleFlagAdmin) admin.site.register(models.WaffleFlag, WaffleFlagAdmin)

View file

@ -353,7 +353,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) {
@ -509,22 +509,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;
@ -540,58 +556,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
@ -848,6 +854,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

@ -65,6 +65,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

@ -61,27 +61,37 @@ def add_has_profile_feature_flag_to_context(request):
def portfolio_permissions(request): def portfolio_permissions(request):
"""Make portfolio permissions for the request user available in global context""" """Make portfolio permissions for the request user available in global context"""
try: try:
if not request.user or not request.user.is_authenticated or not flag_is_active(request, "organization_feature"): portfolio = request.session.get("portfolio")
if portfolio:
return { return {
"has_base_portfolio_permission": False, "has_base_portfolio_permission": request.user.has_base_portfolio_permission(portfolio),
"has_domains_portfolio_permission": False, "has_domains_portfolio_permission": request.user.has_domains_portfolio_permission(portfolio),
"has_domain_requests_portfolio_permission": False, "has_domain_requests_portfolio_permission": request.user.has_domain_requests_portfolio_permission(
"portfolio": None, portfolio
"has_organization_feature_flag": False, ),
"has_view_suborganization": request.user.has_view_suborganization(portfolio),
"has_edit_suborganization": request.user.has_edit_suborganization(portfolio),
"portfolio": portfolio,
"has_organization_feature_flag": True,
} }
return { return {
"has_base_portfolio_permission": request.user.has_base_portfolio_permission(), "has_base_portfolio_permission": False,
"has_domains_portfolio_permission": request.user.has_domains_portfolio_permission(), "has_domains_portfolio_permission": False,
"has_domain_requests_portfolio_permission": request.user.has_domain_requests_portfolio_permission(), "has_domain_requests_portfolio_permission": False,
"portfolio": request.user.portfolio, "has_view_suborganization": False,
"has_organization_feature_flag": True, "has_edit_suborganization": False,
"portfolio": None,
"has_organization_feature_flag": False,
} }
except AttributeError: except AttributeError:
# Handles cases where request.user might not exist # Handles cases where request.user might not exist
return { return {
"has_base_portfolio_permission": False, "has_base_portfolio_permission": False,
"has_domains_portfolio_permission": False, "has_domains_portfolio_permission": False,
"has_domain_requests_portfolio_permission": False, "has_domain_requests_portfolio_permission": False,
"has_view_suborganization": False,
"has_edit_suborganization": False,
"portfolio": None, "portfolio": None,
"has_organization_feature_flag": False, "has_organization_feature_flag": False,
} }

View file

@ -0,0 +1,97 @@
# Generated by Django 4.2.10 on 2024-08-19 20:24
from django.conf import settings
import django.contrib.postgres.fields
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("registrar", "0118_alter_portfolio_options_alter_portfolio_creator_and_more"),
]
operations = [
migrations.RemoveField(
model_name="user",
name="portfolio",
),
migrations.RemoveField(
model_name="user",
name="portfolio_additional_permissions",
),
migrations.RemoveField(
model_name="user",
name="portfolio_roles",
),
migrations.CreateModel(
name="UserPortfolioPermission",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"roles",
django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(
choices=[
("organization_admin", "Admin"),
("organization_admin_read_only", "Admin read only"),
("organization_member", "Member"),
],
max_length=50,
),
blank=True,
help_text="Select one or more roles.",
null=True,
size=None,
),
),
(
"additional_permissions",
django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(
choices=[
("view_all_domains", "View all domains and domain reports"),
("view_managed_domains", "View managed domains"),
("view_member", "View members"),
("edit_member", "Create and edit members"),
("view_all_requests", "View all requests"),
("view_created_requests", "View created requests"),
("edit_requests", "Create and edit requests"),
("view_portfolio", "View organization"),
("edit_portfolio", "Edit organization"),
("view_suborganization", "View suborganization"),
("edit_suborganization", "Edit suborganization"),
],
max_length=50,
),
blank=True,
help_text="Select one or more additional permissions.",
null=True,
size=None,
),
),
(
"portfolio",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="portfolio_users",
to="registrar.portfolio",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="portfolio_permissions",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"unique_together": {("user", "portfolio")},
},
),
]

View file

@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("registrar", "0118_alter_portfolio_options_alter_portfolio_creator_and_more"), ("registrar", "0119_remove_user_portfolio_and_more"),
] ]
operations = [ operations = [

View file

@ -21,6 +21,7 @@ from .portfolio import Portfolio
from .domain_group import DomainGroup from .domain_group import DomainGroup
from .suborganization import Suborganization from .suborganization import Suborganization
from .senior_official import SeniorOfficial from .senior_official import SeniorOfficial
from .user_portfolio_permission import UserPortfolioPermission
__all__ = [ __all__ = [
@ -46,6 +47,7 @@ __all__ = [
"DomainGroup", "DomainGroup",
"Suborganization", "Suborganization",
"SeniorOfficial", "SeniorOfficial",
"UserPortfolioPermission",
] ]
auditlog.register(Contact) auditlog.register(Contact)
@ -70,3 +72,4 @@ auditlog.register(Portfolio)
auditlog.register(DomainGroup) auditlog.register(DomainGroup)
auditlog.register(Suborganization) auditlog.register(Suborganization)
auditlog.register(SeniorOfficial) auditlog.register(SeniorOfficial)
auditlog.register(UserPortfolioPermission)

View file

@ -1,13 +1,11 @@
"""People are invited by email to administer domains.""" """People are invited by email to administer domains."""
import logging import logging
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import models from django.db import models
from django_fsm import FSMField, transition from django_fsm import FSMField, transition
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from .utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices # type: ignore from .utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices # type: ignore
from .utility.time_stamped_model import TimeStampedModel from .utility.time_stamped_model import TimeStampedModel
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
@ -87,9 +85,11 @@ class PortfolioInvitation(TimeStampedModel):
raise RuntimeError("Cannot find the user to retrieve this portfolio invitation.") raise RuntimeError("Cannot find the user to retrieve this portfolio invitation.")
# and create a role for that user on this portfolio # and create a role for that user on this portfolio
user.portfolio = self.portfolio user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
portfolio=self.portfolio, user=user
)
if self.portfolio_roles and len(self.portfolio_roles) > 0: if self.portfolio_roles and len(self.portfolio_roles) > 0:
user.portfolio_roles = self.portfolio_roles user_portfolio_permission.roles = self.portfolio_roles
if self.portfolio_additional_permissions and len(self.portfolio_additional_permissions) > 0: if self.portfolio_additional_permissions and len(self.portfolio_additional_permissions) > 0:
user.portfolio_additional_permissions = self.portfolio_additional_permissions user_portfolio_permission.additional_permissions = self.portfolio_additional_permissions
user.save() user_portfolio_permission.save()

View file

@ -3,10 +3,9 @@ import logging
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from django.db.models import Q from django.db.models import Q
from django.forms import ValidationError from django.http import HttpRequest
from registrar.models.domain_information import DomainInformation from registrar.models import DomainInformation, UserDomainRole
from registrar.models.user_domain_role import UserDomainRole
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from .domain_invitation import DomainInvitation from .domain_invitation import DomainInvitation
@ -15,7 +14,6 @@ from .transition_domain import TransitionDomain
from .verified_by_staff import VerifiedByStaff from .verified_by_staff import VerifiedByStaff
from .domain import Domain from .domain import Domain
from .domain_request import DomainRequest from .domain_request import DomainRequest
from django.contrib.postgres.fields import ArrayField
from waffle.decorators import flag_is_active from waffle.decorators import flag_is_active
from phonenumber_field.modelfields import PhoneNumberField # type: ignore from phonenumber_field.modelfields import PhoneNumberField # type: ignore
@ -112,34 +110,6 @@ class User(AbstractUser):
related_name="users", related_name="users",
) )
portfolio = models.ForeignKey(
"registrar.Portfolio",
null=True,
blank=True,
related_name="user",
on_delete=models.SET_NULL,
)
portfolio_roles = ArrayField(
models.CharField(
max_length=50,
choices=UserPortfolioRoleChoices.choices,
),
null=True,
blank=True,
help_text="Select one or more roles.",
)
portfolio_additional_permissions = ArrayField(
models.CharField(
max_length=50,
choices=UserPortfolioPermissionChoices.choices,
),
null=True,
blank=True,
help_text="Select one or more additional permissions.",
)
phone = PhoneNumberField( phone = PhoneNumberField(
null=True, null=True,
blank=True, blank=True,
@ -230,68 +200,50 @@ class User(AbstractUser):
def has_contact_info(self): def has_contact_info(self):
return bool(self.title or self.email or self.phone) return bool(self.title or self.email or self.phone)
def clean(self): def _has_portfolio_permission(self, portfolio, portfolio_permission):
"""Extends clean method to perform additional validation, which can raise errors in django admin."""
super().clean()
if self.portfolio is None and self._get_portfolio_permissions():
raise ValidationError("When portfolio roles or additional permissions are assigned, portfolio is required.")
if self.portfolio is not None and not self._get_portfolio_permissions():
raise ValidationError("When portfolio is assigned, portfolio roles or additional permissions are required.")
def _get_portfolio_permissions(self):
"""
Retrieve the permissions for the user's portfolio roles.
"""
portfolio_permissions = set() # Use a set to avoid duplicate permissions
if self.portfolio_roles:
for role in self.portfolio_roles:
if role in self.PORTFOLIO_ROLE_PERMISSIONS:
portfolio_permissions.update(self.PORTFOLIO_ROLE_PERMISSIONS[role])
if self.portfolio_additional_permissions:
portfolio_permissions.update(self.portfolio_additional_permissions)
return list(portfolio_permissions) # Convert back to list if necessary
def _has_portfolio_permission(self, portfolio_permission):
"""The views should only call this function when testing for perms and not rely on roles.""" """The views should only call this function when testing for perms and not rely on roles."""
if not self.portfolio: if not portfolio:
return False return False
portfolio_permissions = self._get_portfolio_permissions() user_portfolio_perms = self.portfolio_permissions.filter(portfolio=portfolio, user=self).first()
if not user_portfolio_perms:
return False
return portfolio_permission in portfolio_permissions return portfolio_permission in user_portfolio_perms._get_portfolio_permissions()
# the methods below are checks for individual portfolio permissions. They are defined here def has_base_portfolio_permission(self, portfolio):
# to make them easier to call elsewhere throughout the application return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_PORTFOLIO)
def has_base_portfolio_permission(self):
return self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_PORTFOLIO)
def has_edit_org_portfolio_permission(self): def has_edit_org_portfolio_permission(self, portfolio):
return self._has_portfolio_permission(UserPortfolioPermissionChoices.EDIT_PORTFOLIO) return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_PORTFOLIO)
def has_domains_portfolio_permission(self): def has_domains_portfolio_permission(self, portfolio):
return self._has_portfolio_permission( return self._has_portfolio_permission(
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS portfolio, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS
) or self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS) ) or self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS)
def has_domain_requests_portfolio_permission(self): def has_domain_requests_portfolio_permission(self, portfolio):
return self._has_portfolio_permission( return self._has_portfolio_permission(
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS portfolio, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS
) or self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS) ) or self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS)
def has_view_all_domains_permission(self): def has_view_all_domains_permission(self, portfolio):
"""Determines if the current user can view all available domains in a given portfolio""" """Determines if the current user can view all available domains in a given portfolio"""
return self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS) return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)
# Field specific permission checks # Field specific permission checks
def has_view_suborganization(self): def has_view_suborganization(self, portfolio):
return self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION) return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION)
def has_edit_suborganization(self): def has_edit_suborganization(self, portfolio):
return self._has_portfolio_permission(UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION) return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION)
def get_first_portfolio(self):
permission = self.portfolio_permissions.first()
if permission:
return permission.portfolio
return None
@classmethod @classmethod
def needs_identity_verification(cls, email, uuid): def needs_identity_verification(cls, email, uuid):
@ -406,7 +358,14 @@ class User(AbstractUser):
for invitation in PortfolioInvitation.objects.filter( for invitation in PortfolioInvitation.objects.filter(
email__iexact=self.email, status=PortfolioInvitation.PortfolioInvitationStatus.INVITED email__iexact=self.email, status=PortfolioInvitation.PortfolioInvitationStatus.INVITED
): ):
if self.portfolio is None: # need to create a bogus request and assign user to it, in order to pass request
# to flag_is_active
request = HttpRequest()
request.user = self
only_single_portfolio = (
not flag_is_active(request, "multiple_portfolios") and self.get_first_portfolio() is None
)
if only_single_portfolio or flag_is_active(None, "multiple_portfolios"):
try: try:
invitation.retrieve() invitation.retrieve()
invitation.save() invitation.save()
@ -431,13 +390,17 @@ class User(AbstractUser):
self.check_domain_invitations_on_login() self.check_domain_invitations_on_login()
self.check_portfolio_invitations_on_login() self.check_portfolio_invitations_on_login()
# NOTE TO DAVE: I'd simply suggest that we move these functions outside of the user object,
# and move them to some sort of utility file. That way we aren't calling request inside here.
def is_org_user(self, request): def is_org_user(self, request):
has_organization_feature_flag = flag_is_active(request, "organization_feature") has_organization_feature_flag = flag_is_active(request, "organization_feature")
return has_organization_feature_flag and self.has_base_portfolio_permission() portfolio = request.session.get("portfolio")
return has_organization_feature_flag and self.has_base_portfolio_permission(portfolio)
def get_user_domain_ids(self, request): def get_user_domain_ids(self, request):
"""Returns either the domains ids associated with this user on UserDomainRole or Portfolio""" """Returns either the domains ids associated with this user on UserDomainRole or Portfolio"""
if self.is_org_user(request) and self.has_view_all_domains_permission(): portfolio = request.session.get("portfolio")
return DomainInformation.objects.filter(portfolio=self.portfolio).values_list("domain_id", flat=True) if self.is_org_user(request) and self.has_view_all_domains_permission(portfolio):
return DomainInformation.objects.filter(portfolio=portfolio).values_list("domain_id", flat=True)
else: else:
return UserDomainRole.objects.filter(user=self).values_list("domain_id", flat=True) return UserDomainRole.objects.filter(user=self).values_list("domain_id", flat=True)

View file

@ -0,0 +1,119 @@
from django.db import models
from django.forms import ValidationError
from django.http import HttpRequest
from waffle import flag_is_active
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from .utility.time_stamped_model import TimeStampedModel
from django.contrib.postgres.fields import ArrayField
class UserPortfolioPermission(TimeStampedModel):
"""This is a linking table that connects a user with a role on a portfolio."""
class Meta:
unique_together = ["user", "portfolio"]
PORTFOLIO_ROLE_PERMISSIONS = {
UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
UserPortfolioPermissionChoices.VIEW_MEMBER,
UserPortfolioPermissionChoices.EDIT_MEMBER,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.EDIT_REQUESTS,
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
# Domain: field specific permissions
UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION,
UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION,
],
UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY: [
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
UserPortfolioPermissionChoices.VIEW_MEMBER,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
# Domain: field specific permissions
UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION,
],
UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
],
}
user = models.ForeignKey(
"registrar.User",
null=False,
# when a user is deleted, permissions are too
on_delete=models.CASCADE,
related_name="portfolio_permissions",
)
portfolio = models.ForeignKey(
"registrar.Portfolio",
null=False,
# when a portfolio is deleted, permissions are too
on_delete=models.CASCADE,
related_name="portfolio_users",
)
roles = ArrayField(
models.CharField(
max_length=50,
choices=UserPortfolioRoleChoices.choices,
),
null=True,
blank=True,
help_text="Select one or more roles.",
)
additional_permissions = ArrayField(
models.CharField(
max_length=50,
choices=UserPortfolioPermissionChoices.choices,
),
null=True,
blank=True,
help_text="Select one or more additional permissions.",
)
def __str__(self):
return f"User '{self.user}' on Portfolio '{self.portfolio}' " f"<Roles: {self.roles}>" if self.roles else ""
def _get_portfolio_permissions(self):
"""
Retrieve the permissions for the user's portfolio roles.
"""
# Use a set to avoid duplicate permissions
portfolio_permissions = set()
if self.roles:
for role in self.roles:
portfolio_permissions.update(self.PORTFOLIO_ROLE_PERMISSIONS.get(role, []))
if self.additional_permissions:
portfolio_permissions.update(self.additional_permissions)
return list(portfolio_permissions)
def clean(self):
"""Extends clean method to perform additional validation, which can raise errors in django admin."""
super().clean()
# Check if a user is set without accessing the related object.
has_user = bool(self.user_id)
if self.pk is None and has_user:
# Have to create a bogus request to set the user and pass to flag_is_active
request = HttpRequest()
request.user = self.user
existing_permissions = UserPortfolioPermission.objects.filter(user=self.user)
if not flag_is_active(request, "multiple_portfolios") and existing_permissions.exists():
raise ValidationError(
"Only one portfolio permission is allowed per user when multiple portfolios are disabled."
)
# Check if portfolio is set without accessing the related object.
has_portfolio = bool(self.portfolio_id)
if not has_portfolio and self._get_portfolio_permissions():
raise ValidationError("When portfolio roles or additional permissions are assigned, portfolio is required.")
if has_portfolio and not self._get_portfolio_permissions():
raise ValidationError("When portfolio is assigned, portfolio roles or additional permissions are required.")

View file

@ -125,8 +125,9 @@ class CheckUserProfileMiddleware:
class CheckPortfolioMiddleware: class CheckPortfolioMiddleware:
""" """
Checks if the current user has a portfolio this middleware should serve two purposes:
If they do, redirect them to the portfolio homepage when they navigate to home. 1 - set the portfolio in session if appropriate # views will need the session portfolio
2 - if path is home and session portfolio is set, redirect based on permissions of user
""" """
def __init__(self, get_response): def __init__(self, get_response):
@ -140,19 +141,27 @@ class CheckPortfolioMiddleware:
def process_view(self, request, view_func, view_args, view_kwargs): def process_view(self, request, view_func, view_args, view_kwargs):
current_path = request.path current_path = request.path
if current_path == self.home and request.user.is_authenticated and request.user.is_org_user(request): if not request.user.is_authenticated:
return None
if request.user.has_base_portfolio_permission(): # set the portfolio in the session if it is not set
portfolio = request.user.portfolio if "portfolio" not in request.session or request.session["portfolio"] is None:
# if multiple portfolios are allowed for this user
if flag_is_active(request, "multiple_portfolios"):
# NOTE: we will want to change later to have a workflow for selecting
# portfolio and another for switching portfolio; for now, select first
request.session["portfolio"] = request.user.get_first_portfolio()
elif flag_is_active(request, "organization_feature"):
request.session["portfolio"] = request.user.get_first_portfolio()
else:
request.session["portfolio"] = None
# Add the portfolio to the request object if request.session["portfolio"] is not None and current_path == self.home:
request.portfolio = portfolio if request.user.is_org_user(request):
if request.user.has_domains_portfolio_permission(request.session["portfolio"]):
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

@ -72,9 +72,9 @@
{% include "includes/summary_item.html" with title='DNSSEC' value='Not Enabled' edit_link=url editable=is_editable %} {% include "includes/summary_item.html" with title='DNSSEC' value='Not Enabled' edit_link=url editable=is_editable %}
{% endif %} {% endif %}
{% if portfolio and has_domains_portfolio_permission and request.user.has_view_suborganization %} {% if portfolio and has_domains_portfolio_permission and has_view_suborganization %}
{% url 'domain-suborganization' pk=domain.id as url %} {% url 'domain-suborganization' pk=domain.id as url %}
{% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:request.user.has_edit_suborganization %} {% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:has_edit_suborganization %}
{% else %} {% else %}
{% url 'domain-org-name-address' pk=domain.id as url %} {% url 'domain-org-name-address' pk=domain.id as url %}
{% include "includes/summary_item.html" with title='Organization name and mailing address' value=domain.domain_info address='true' edit_link=url editable=is_editable %} {% include "includes/summary_item.html" with title='Organization name and mailing address' value=domain.domain_info address='true' edit_link=url editable=is_editable %}

View file

@ -61,7 +61,7 @@
{% if portfolio %} {% if portfolio %}
{% comment %} Only show this menu option if the user has the perms to do so {% endcomment %} {% comment %} Only show this menu option if the user has the perms to do so {% endcomment %}
{% if has_domains_portfolio_permission and request.user.has_view_suborganization %} {% if has_domains_portfolio_permission and has_view_suborganization %}
{% with url_name="domain-suborganization" %} {% with url_name="domain-suborganization" %}
{% include "includes/domain_sidenav_item.html" with item_text="Suborganization" %} {% include "includes/domain_sidenav_item.html" with item_text="Suborganization" %}
{% endwith %} {% endwith %}

View file

@ -15,7 +15,7 @@
If you believe there is an error please contact <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>. If you believe there is an error please contact <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
</p> </p>
{% if has_domains_portfolio_permission and request.user.has_edit_suborganization %} {% if has_domains_portfolio_permission and has_edit_suborganization %}
<form class="usa-form usa-form--large" method="post" novalidate id="form-container"> <form class="usa-form usa-form--large" method="post" novalidate id="form-container">
{% csrf_token %} {% csrf_token %}
{% input_with_errors form.sub_organization %} {% input_with_errors form.sub_organization %}

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

@ -27,6 +27,8 @@ from registrar.models import (
PublicContact, PublicContact,
Domain, Domain,
FederalAgency, FederalAgency,
UserPortfolioPermission,
Portfolio,
) )
from epplibwrapper import ( from epplibwrapper import (
commands, commands,
@ -791,6 +793,8 @@ class MockDb(TestCase):
DomainInformation.objects.all().delete() DomainInformation.objects.all().delete()
DomainRequest.objects.all().delete() DomainRequest.objects.all().delete()
UserDomainRole.objects.all().delete() UserDomainRole.objects.all().delete()
Portfolio.objects.all().delete()
UserPortfolioPermission.objects.all().delete()
User.objects.all().delete() User.objects.all().delete()
DomainInvitation.objects.all().delete() DomainInvitation.objects.all().delete()
cls.federal_agency_1.delete() cls.federal_agency_1.delete()
@ -1743,3 +1747,12 @@ class MockEppLib(TestCase):
def tearDown(self): def tearDown(self):
self.mockSendPatch.stop() self.mockSendPatch.stop()
def get_wsgi_request_object(client, user, url="/"):
"""Returns client.get(url).wsgi_request for testing functions or classes
that need a request object directly passed to them."""
client.force_login(user)
request = client.get(url).wsgi_request
request.user = user
return request

View file

@ -2107,9 +2107,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):
@ -2118,6 +2116,4 @@ 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("request1.gov", domain_requests) self.assertIn("2 domain requests", domain_requests)
self.assertIn("request2.gov", 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

@ -17,6 +17,7 @@ from registrar.models import (
DomainInvitation, DomainInvitation,
UserDomainRole, UserDomainRole,
FederalAgency, FederalAgency,
UserPortfolioPermission,
) )
import boto3_mocking import boto3_mocking
@ -1142,19 +1143,24 @@ class TestPortfolioInvitations(TestCase):
def tearDown(self): def tearDown(self):
super().tearDown() super().tearDown()
UserPortfolioPermission.objects.all().delete()
Portfolio.objects.all().delete() Portfolio.objects.all().delete()
PortfolioInvitation.objects.all().delete() PortfolioInvitation.objects.all().delete()
User.objects.all().delete() User.objects.all().delete()
@less_console_noise_decorator @less_console_noise_decorator
def test_retrieval(self): def test_retrieval(self):
self.assertFalse(self.user.portfolio) portfolio_role_exists = UserPortfolioPermission.objects.filter(
user=self.user, portfolio=self.portfolio
).exists()
self.assertFalse(portfolio_role_exists)
self.invitation.retrieve() self.invitation.retrieve()
self.user.refresh_from_db() self.user.refresh_from_db()
self.assertEqual(self.user.portfolio.organization_name, "Hotel California") created_role = UserPortfolioPermission.objects.get(user=self.user, portfolio=self.portfolio)
self.assertEqual(self.user.portfolio_roles, [self.portfolio_role_base, self.portfolio_role_admin]) self.assertEqual(created_role.portfolio.organization_name, "Hotel California")
self.assertEqual(created_role.roles, [self.portfolio_role_base, self.portfolio_role_admin])
self.assertEqual( self.assertEqual(
self.user.portfolio_additional_permissions, [self.portfolio_permission_1, self.portfolio_permission_2] created_role.additional_permissions, [self.portfolio_permission_1, self.portfolio_permission_2]
) )
self.assertEqual(self.invitation.status, PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED) self.assertEqual(self.invitation.status, PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED)
@ -1167,16 +1173,129 @@ class TestPortfolioInvitations(TestCase):
@less_console_noise_decorator @less_console_noise_decorator
def test_retrieve_user_already_member_error(self): def test_retrieve_user_already_member_error(self):
self.assertFalse(self.user.portfolio) portfolio_role_exists = UserPortfolioPermission.objects.filter(
portfolio2, _ = Portfolio.objects.get_or_create(creator=self.user2, organization_name="Tokyo Hotel") user=self.user, portfolio=self.portfolio
self.user.portfolio = portfolio2 ).exists()
self.assertEqual(self.user.portfolio.organization_name, "Tokyo Hotel") self.assertFalse(portfolio_role_exists)
self.user.save() portfolio_role, _ = UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio)
self.assertEqual(portfolio_role.portfolio.organization_name, "Hotel California")
self.user.check_portfolio_invitations_on_login() self.user.check_portfolio_invitations_on_login()
self.user.refresh_from_db() self.user.refresh_from_db()
self.assertEqual(self.user.portfolio.organization_name, "Tokyo Hotel")
roles = UserPortfolioPermission.objects.filter(user=self.user)
self.assertEqual(len(roles), 1)
self.assertEqual(self.invitation.status, PortfolioInvitation.PortfolioInvitationStatus.INVITED) self.assertEqual(self.invitation.status, PortfolioInvitation.PortfolioInvitationStatus.INVITED)
@less_console_noise_decorator
def test_retrieve_user_multiple_invitations(self):
"""Retrieve user portfolio invitations when there are multiple and multiple_options flag true."""
# create a 2nd portfolio and a 2nd portfolio invitation to self.user
portfolio2, _ = Portfolio.objects.get_or_create(creator=self.user2, organization_name="Take It Easy")
PortfolioInvitation.objects.get_or_create(
email=self.email,
portfolio=portfolio2,
portfolio_roles=[self.portfolio_role_base, self.portfolio_role_admin],
portfolio_additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
)
with override_flag("multiple_portfolios", active=True):
self.user.check_portfolio_invitations_on_login()
self.user.refresh_from_db()
roles = UserPortfolioPermission.objects.filter(user=self.user)
self.assertEqual(len(roles), 2)
updated_invitation1, _ = PortfolioInvitation.objects.get_or_create(
email=self.email, portfolio=self.portfolio
)
self.assertEqual(updated_invitation1.status, PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED)
updated_invitation2, _ = PortfolioInvitation.objects.get_or_create(email=self.email, portfolio=portfolio2)
self.assertEqual(updated_invitation2.status, PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED)
@less_console_noise_decorator
def test_retrieve_user_multiple_invitations_when_multiple_portfolios_inactive(self):
"""Attempt to retrieve user portfolio invitations when there are multiple
but multiple_portfolios flag set to False"""
# create a 2nd portfolio and a 2nd portfolio invitation to self.user
portfolio2, _ = Portfolio.objects.get_or_create(creator=self.user2, organization_name="Take It Easy")
PortfolioInvitation.objects.get_or_create(
email=self.email,
portfolio=portfolio2,
portfolio_roles=[self.portfolio_role_base, self.portfolio_role_admin],
portfolio_additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
)
self.user.check_portfolio_invitations_on_login()
self.user.refresh_from_db()
roles = UserPortfolioPermission.objects.filter(user=self.user)
self.assertEqual(len(roles), 1)
updated_invitation1, _ = PortfolioInvitation.objects.get_or_create(email=self.email, portfolio=self.portfolio)
self.assertEqual(updated_invitation1.status, PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED)
updated_invitation2, _ = PortfolioInvitation.objects.get_or_create(email=self.email, portfolio=portfolio2)
self.assertEqual(updated_invitation2.status, PortfolioInvitation.PortfolioInvitationStatus.INVITED)
class TestUserPortfolioPermission(TestCase):
@less_console_noise_decorator
def setUp(self):
self.user, _ = User.objects.get_or_create(email="mayor@igorville.gov")
super().setUp()
def tearDown(self):
super().tearDown()
Domain.objects.all().delete()
DomainInformation.objects.all().delete()
DomainRequest.objects.all().delete()
UserPortfolioPermission.objects.all().delete()
Portfolio.objects.all().delete()
User.objects.all().delete()
UserDomainRole.objects.all().delete()
@less_console_noise_decorator
@override_flag("multiple_portfolios", active=True)
def test_clean_on_multiple_portfolios_when_flag_active(self):
"""Ensures that a user can create multiple portfolio permission objects when the flag is enabled"""
# Create an instance of User with a portfolio but no roles or additional permissions
portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California")
portfolio_2, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Motel California")
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
portfolio=portfolio, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
portfolio_permission_2 = UserPortfolioPermission(
portfolio=portfolio_2, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
# Clean should pass on both of these objects
try:
portfolio_permission.clean()
portfolio_permission_2.clean()
except ValidationError as error:
self.fail(f"Raised ValidationError unexpectedly: {error}")
@less_console_noise_decorator
@override_flag("multiple_portfolios", active=False)
def test_clean_on_creates_multiple_portfolios(self):
"""Ensures that a user cannot create multiple portfolio permission objects when the flag is disabled"""
# Create an instance of User with a portfolio but no roles or additional permissions
portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California")
portfolio_2, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Motel California")
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
portfolio=portfolio, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
portfolio_permission_2 = UserPortfolioPermission(
portfolio=portfolio_2, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
# This should work as intended
portfolio_permission.clean()
# Test if the ValidationError is raised with the correct message
with self.assertRaises(ValidationError) as cm:
portfolio_permission_2.clean()
portfolio_permission_2, _ = UserPortfolioPermission.objects.get_or_create(portfolio=portfolio, user=self.user)
self.assertEqual(
cm.exception.message,
"Only one portfolio permission is allowed per user when multiple portfolios are disabled.",
)
class TestUser(TestCase): class TestUser(TestCase):
"""Test actions that occur on user login, """Test actions that occur on user login,
@ -1188,6 +1307,7 @@ class TestUser(TestCase):
self.domain_name = "igorvilleInTransition.gov" self.domain_name = "igorvilleInTransition.gov"
self.domain, _ = Domain.objects.get_or_create(name="igorville.gov") self.domain, _ = Domain.objects.get_or_create(name="igorville.gov")
self.user, _ = User.objects.get_or_create(email=self.email) self.user, _ = User.objects.get_or_create(email=self.email)
self.factory = RequestFactory()
def tearDown(self): def tearDown(self):
super().tearDown() super().tearDown()
@ -1197,6 +1317,7 @@ class TestUser(TestCase):
DomainRequest.objects.all().delete() DomainRequest.objects.all().delete()
DraftDomain.objects.all().delete() DraftDomain.objects.all().delete()
TransitionDomain.objects.all().delete() TransitionDomain.objects.all().delete()
UserPortfolioPermission.objects.all().delete()
Portfolio.objects.all().delete() Portfolio.objects.all().delete()
User.objects.all().delete() User.objects.all().delete()
UserDomainRole.objects.all().delete() UserDomainRole.objects.all().delete()
@ -1359,44 +1480,41 @@ class TestUser(TestCase):
Note: This tests _get_portfolio_permissions as a side effect Note: This tests _get_portfolio_permissions as a side effect
""" """
portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California") portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California")
self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS] user_can_view_all_domains = self.user.has_domains_portfolio_permission(portfolio)
self.user.save() user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission(portfolio)
self.user.refresh_from_db()
user_can_view_all_domains = self.user.has_domains_portfolio_permission()
user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission()
self.assertFalse(user_can_view_all_domains) self.assertFalse(user_can_view_all_domains)
self.assertFalse(user_can_view_all_requests) self.assertFalse(user_can_view_all_requests)
self.user.portfolio = portfolio portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
self.user.save() portfolio=portfolio,
self.user.refresh_from_db() user=self.user,
additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS],
)
user_can_view_all_domains = self.user.has_domains_portfolio_permission() user_can_view_all_domains = self.user.has_domains_portfolio_permission(portfolio)
user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission() user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission(portfolio)
self.assertTrue(user_can_view_all_domains) self.assertTrue(user_can_view_all_domains)
self.assertFalse(user_can_view_all_requests) self.assertFalse(user_can_view_all_requests)
self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN] portfolio_permission.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
self.user.save() portfolio_permission.save()
self.user.refresh_from_db() portfolio_permission.refresh_from_db()
user_can_view_all_domains = self.user.has_domains_portfolio_permission() user_can_view_all_domains = self.user.has_domains_portfolio_permission(portfolio)
user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission() user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission(portfolio)
self.assertTrue(user_can_view_all_domains) self.assertTrue(user_can_view_all_domains)
self.assertTrue(user_can_view_all_requests) self.assertTrue(user_can_view_all_requests)
UserDomainRole.objects.all().get_or_create( UserDomainRole.objects.get_or_create(user=self.user, domain=self.domain, role=UserDomainRole.Roles.MANAGER)
user=self.user, domain=self.domain, role=UserDomainRole.Roles.MANAGER
)
user_can_view_all_domains = self.user.has_domains_portfolio_permission() user_can_view_all_domains = self.user.has_domains_portfolio_permission(portfolio)
user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission() user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission(portfolio)
self.assertTrue(user_can_view_all_domains) self.assertTrue(user_can_view_all_domains)
self.assertTrue(user_can_view_all_requests) self.assertTrue(user_can_view_all_requests)
@ -1407,13 +1525,15 @@ class TestUser(TestCase):
def test_user_with_portfolio_but_no_roles(self): def test_user_with_portfolio_but_no_roles(self):
# Create an instance of User with a portfolio but no roles or additional permissions # Create an instance of User with a portfolio but no roles or additional permissions
portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California") portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California")
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(portfolio=portfolio, user=self.user)
self.user.portfolio = portfolio # Try to remove the role
self.user.portfolio_roles = [] portfolio_permission.portfolio = portfolio
portfolio_permission.roles = []
# Test if the ValidationError is raised with the correct message # Test if the ValidationError is raised with the correct message
with self.assertRaises(ValidationError) as cm: with self.assertRaises(ValidationError) as cm:
self.user.clean() portfolio_permission.clean()
self.assertEqual( self.assertEqual(
cm.exception.message, "When portfolio is assigned, portfolio roles or additional permissions are required." cm.exception.message, "When portfolio is assigned, portfolio roles or additional permissions are required."
@ -1422,13 +1542,18 @@ class TestUser(TestCase):
@less_console_noise_decorator @less_console_noise_decorator
def test_user_with_portfolio_roles_but_no_portfolio(self): def test_user_with_portfolio_roles_but_no_portfolio(self):
# Create an instance of User with a portfolio role but no portfolio portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California")
self.user.portfolio = None portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN] portfolio=portfolio, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
# Try to remove the portfolio
portfolio_permission.portfolio = None
portfolio_permission.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
# Test if the ValidationError is raised with the correct message # Test if the ValidationError is raised with the correct message
with self.assertRaises(ValidationError) as cm: with self.assertRaises(ValidationError) as cm:
self.user.clean() portfolio_permission.clean()
self.assertEqual( self.assertEqual(
cm.exception.message, "When portfolio roles or additional permissions are assigned, portfolio is required." cm.exception.message, "When portfolio roles or additional permissions are assigned, portfolio is required."

View file

@ -7,6 +7,7 @@ from registrar.models import (
UserDomainRole, UserDomainRole,
) )
from registrar.models import Portfolio from registrar.models import Portfolio
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
from registrar.utility.csv_export import ( from registrar.utility.csv_export import (
DomainDataFull, DomainDataFull,
@ -33,7 +34,14 @@ import boto3_mocking
from registrar.utility.s3_bucket import S3ClientError, S3ClientErrorCodes # type: ignore from registrar.utility.s3_bucket import S3ClientError, S3ClientErrorCodes # type: ignore
from django.utils import timezone from django.utils import timezone
from api.tests.common import less_console_noise_decorator from api.tests.common import less_console_noise_decorator
from .common import MockDbForSharedTests, MockDbForIndividualTests, MockEppLib, less_console_noise, get_time_aware_date from .common import (
MockDbForSharedTests,
MockDbForIndividualTests,
MockEppLib,
get_wsgi_request_object,
less_console_noise,
get_time_aware_date,
)
from waffle.testutils import override_flag from waffle.testutils import override_flag
@ -281,10 +289,8 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
# Create a user and associate it with some domains # Create a user and associate it with some domains
UserDomainRole.objects.create(user=self.user, domain=self.domain_2) UserDomainRole.objects.create(user=self.user, domain=self.domain_2)
# Create a request object # Make a GET request using self.client to get a request object
factory = RequestFactory() request = get_wsgi_request_object(client=self.client, user=self.user)
request = factory.get("/")
request.user = self.user
# Create a CSV file in memory # Create a CSV file in memory
csv_file = StringIO() csv_file = StringIO()
@ -321,8 +327,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
# Create a portfolio and assign it to the user # Create a portfolio and assign it to the user
portfolio = Portfolio.objects.create(creator=self.user, organization_name="Test Portfolio") portfolio = Portfolio.objects.create(creator=self.user, organization_name="Test Portfolio")
self.user.portfolio = portfolio portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(portfolio=portfolio, user=self.user)
self.user.save()
UserDomainRole.objects.create(user=self.user, domain=self.domain_2) UserDomainRole.objects.create(user=self.user, domain=self.domain_2)
UserDomainRole.objects.filter(user=self.user, domain=self.domain_1).delete() UserDomainRole.objects.filter(user=self.user, domain=self.domain_1).delete()
@ -336,14 +341,12 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
self.domain_3.domain_info.save() self.domain_3.domain_info.save()
# Set up user permissions # Set up user permissions
self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN] portfolio_permission.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
self.user.save() portfolio_permission.save()
self.user.refresh_from_db() portfolio_permission.refresh_from_db()
# Create a request object # Make a GET request using self.client to get a request object
factory = RequestFactory() request = get_wsgi_request_object(client=self.client, user=self.user)
request = factory.get("/")
request.user = self.user
# Get the csv content # Get the csv content
csv_content = self._run_domain_data_type_user_export(request) csv_content = self._run_domain_data_type_user_export(request)
@ -354,19 +357,22 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
self.assertNotIn(self.domain_2.name, csv_content) self.assertNotIn(self.domain_2.name, csv_content)
# Test the output for readonly admin # Test the output for readonly admin
self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY] portfolio_permission.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY]
self.user.save() portfolio_permission.save()
portfolio_permission.refresh_from_db()
# Get the csv content
csv_content = self._run_domain_data_type_user_export(request)
self.assertIn(self.domain_1.name, csv_content) self.assertIn(self.domain_1.name, csv_content)
self.assertIn(self.domain_3.name, csv_content) self.assertIn(self.domain_3.name, csv_content)
self.assertNotIn(self.domain_2.name, csv_content) self.assertNotIn(self.domain_2.name, csv_content)
portfolio_permission.roles = [UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
portfolio_permission.save()
portfolio_permission.refresh_from_db()
# Get the csv content # Get the csv content
self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
self.user.save()
csv_content = self._run_domain_data_type_user_export(request) csv_content = self._run_domain_data_type_user_export(request)
self.assertNotIn(self.domain_1.name, csv_content) self.assertNotIn(self.domain_1.name, csv_content)
self.assertNotIn(self.domain_3.name, csv_content) self.assertNotIn(self.domain_3.name, csv_content)
self.assertIn(self.domain_2.name, csv_content) self.assertIn(self.domain_2.name, csv_content)

View file

@ -6,7 +6,7 @@ from django.urls import reverse
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from waffle.testutils import override_flag from waffle.testutils import override_flag
from api.tests.common import less_console_noise_decorator from api.tests.common import less_console_noise_decorator
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
from .common import MockEppLib, MockSESClient, create_user # type: ignore from .common import MockEppLib, MockSESClient, create_user # type: ignore
from django_webtest import WebTest # type: ignore from django_webtest import WebTest # type: ignore
import boto3_mocking # type: ignore import boto3_mocking # type: ignore
@ -36,6 +36,7 @@ from registrar.models import (
FederalAgency, FederalAgency,
Portfolio, Portfolio,
Suborganization, Suborganization,
UserPortfolioPermission,
) )
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from django.utils import timezone from django.utils import timezone
@ -316,6 +317,7 @@ class TestDomainDetail(TestDomainOverview):
self.assertContains(detail_page, "Domain missing domain information") self.assertContains(detail_page, "Domain missing domain information")
@less_console_noise_decorator @less_console_noise_decorator
@override_flag("organization_feature", active=True)
def test_domain_readonly_on_detail_page(self): def test_domain_readonly_on_detail_page(self):
"""Test that a domain, which is part of a portfolio, but for which the user is not a domain manager, """Test that a domain, which is part of a portfolio, but for which the user is not a domain manager,
properly displays read only""" properly displays read only"""
@ -328,11 +330,14 @@ class TestDomainDetail(TestDomainOverview):
email="bogus@example.gov", email="bogus@example.gov",
phone="8003111234", phone="8003111234",
title="test title", title="test title",
portfolio=portfolio,
portfolio_roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
) )
domain, _ = Domain.objects.get_or_create(name="bogusdomain.gov") domain, _ = Domain.objects.get_or_create(name="bogusdomain.gov")
DomainInformation.objects.get_or_create(creator=user, domain=domain, portfolio=portfolio) DomainInformation.objects.get_or_create(creator=user, domain=domain, portfolio=portfolio)
UserPortfolioPermission.objects.get_or_create(
user=user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
user.refresh_from_db()
self.client.force_login(user) self.client.force_login(user)
detail_page = self.client.get(f"/domain/{domain.id}") detail_page = self.client.get(f"/domain/{domain.id}")
# Check that alert message displays properly # Check that alert message displays properly
@ -1477,10 +1482,9 @@ class TestDomainSuborganization(TestDomainOverview):
self.domain_information.refresh_from_db() self.domain_information.refresh_from_db()
# Add portfolio perms to the user object # Add portfolio perms to the user object
self.user.portfolio = portfolio portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN] user=self.user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
self.user.save() )
self.user.refresh_from_db()
self.assertEqual(self.domain_information.sub_organization, suborg) self.assertEqual(self.domain_information.sub_organization, suborg)
@ -1536,10 +1540,9 @@ class TestDomainSuborganization(TestDomainOverview):
self.domain_information.refresh_from_db() self.domain_information.refresh_from_db()
# Add portfolio perms to the user object # Add portfolio perms to the user object
self.user.portfolio = portfolio portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY] user=self.user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY]
self.user.save() )
self.user.refresh_from_db()
self.assertEqual(self.domain_information.sub_organization, suborg) self.assertEqual(self.domain_information.sub_organization, suborg)
@ -1577,9 +1580,9 @@ class TestDomainSuborganization(TestDomainOverview):
self.domain_information.refresh_from_db() self.domain_information.refresh_from_db()
# Add portfolio perms to the user object # Add portfolio perms to the user object
self.user.portfolio = portfolio UserPortfolioPermission.objects.get_or_create(
self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO] user=self.user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
self.user.save() )
self.user.refresh_from_db() self.user.refresh_from_db()
# Navigate to the domain overview page # Navigate to the domain overview page

View file

@ -10,9 +10,11 @@ from registrar.models import (
UserDomainRole, UserDomainRole,
User, User,
) )
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from .common import create_test_user from .common import create_test_user
from waffle.testutils import override_flag from waffle.testutils import override_flag
from django.contrib.sessions.middleware import SessionMiddleware
import logging import logging
@ -30,6 +32,7 @@ class TestPortfolio(WebTest):
) )
def tearDown(self): def tearDown(self):
UserPortfolioPermission.objects.all().delete()
Portfolio.objects.all().delete() Portfolio.objects.all().delete()
UserDomainRole.objects.all().delete() UserDomainRole.objects.all().delete()
DomainRequest.objects.all().delete() DomainRequest.objects.all().delete()
@ -52,10 +55,11 @@ class TestPortfolio(WebTest):
self.portfolio.save() self.portfolio.save()
self.portfolio.refresh_from_db() self.portfolio.refresh_from_db()
self.user.portfolio = self.portfolio portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO] user=self.user,
self.user.save() portfolio=self.portfolio,
self.user.refresh_from_db() additional_permissions=[UserPortfolioPermissionChoices.VIEW_PORTFOLIO],
)
so_portfolio_page = self.app.get(reverse("senior-official")) so_portfolio_page = self.app.get(reverse("senior-official"))
# Assert that we're on the right page # Assert that we're on the right page
@ -72,6 +76,9 @@ class TestPortfolio(WebTest):
def test_middleware_does_not_redirect_if_no_permission(self): def test_middleware_does_not_redirect_if_no_permission(self):
"""Test that user with no portfolio permission is not redirected when attempting to access home""" """Test that user with no portfolio permission is not redirected when attempting to access home"""
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
user=self.user, portfolio=self.portfolio, additional_permissions=[]
)
self.user.portfolio = self.portfolio self.user.portfolio = self.portfolio
self.user.save() self.user.save()
self.user.refresh_from_db() self.user.refresh_from_db()
@ -86,9 +93,6 @@ class TestPortfolio(WebTest):
def test_middleware_does_not_redirect_if_no_portfolio(self): def test_middleware_does_not_redirect_if_no_portfolio(self):
"""Test that user with no assigned portfolio is not redirected when attempting to access home""" """Test that user with no assigned portfolio is not redirected when attempting to access home"""
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO]
self.user.save()
self.user.refresh_from_db()
with override_flag("organization_feature", active=True): with override_flag("organization_feature", active=True):
# This will redirect the user to the portfolio page. # This will redirect the user to the portfolio page.
# Follow implicity checks if our redirect is working. # Follow implicity checks if our redirect is working.
@ -97,33 +101,36 @@ 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 UserPortfolioPermission.objects.get_or_create(
self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO] user=self.user,
self.user.save() portfolio=self.portfolio,
self.user.refresh_from_db() additional_permissions=[UserPortfolioPermissionChoices.VIEW_PORTFOLIO],
)
with override_flag("organization_feature", active=True): with override_flag("organization_feature", active=True):
# This will redirect the user to the portfolio page. # This will redirect the user to the portfolio page.
# Follow implicity checks if our redirect is working. # Follow implicity checks if our redirect is working.
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):
"""Test that user with a portfolio, VIEW_PORTFOLIO, VIEW_ALL_DOMAINS """Test that user with a portfolio, VIEW_PORTFOLIO, VIEW_ALL_DOMAINS
is redirected to portfolio domains page""" is redirected to portfolio domains page"""
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio UserPortfolioPermission.objects.get_or_create(
self.user.portfolio_additional_permissions = [ user=self.user,
UserPortfolioPermissionChoices.VIEW_PORTFOLIO, portfolio=self.portfolio,
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, additional_permissions=[
] UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
self.user.save() UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
self.user.refresh_from_db() ],
)
with override_flag("organization_feature", active=True): with override_flag("organization_feature", active=True):
# This will redirect the user to the portfolio page. # This will redirect the user to the portfolio page.
# Follow implicity checks if our redirect is working. # Follow implicity checks if our redirect is working.
@ -137,9 +144,9 @@ class TestPortfolio(WebTest):
def test_portfolio_domains_page_403_when_user_not_have_permission(self): def test_portfolio_domains_page_403_when_user_not_have_permission(self):
"""Test that user without proper permission is denied access to portfolio domain view""" """Test that user without proper permission is denied access to portfolio domain view"""
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio UserPortfolioPermission.objects.get_or_create(
self.user.save() user=self.user, portfolio=self.portfolio, additional_permissions=[]
self.user.refresh_from_db() )
with override_flag("organization_feature", active=True): with override_flag("organization_feature", active=True):
# This will redirect the user to the portfolio page. # This will redirect the user to the portfolio page.
# Follow implicity checks if our redirect is working. # Follow implicity checks if our redirect is working.
@ -151,9 +158,9 @@ class TestPortfolio(WebTest):
def test_portfolio_domain_requests_page_403_when_user_not_have_permission(self): def test_portfolio_domain_requests_page_403_when_user_not_have_permission(self):
"""Test that user without proper permission is denied access to portfolio domain view""" """Test that user without proper permission is denied access to portfolio domain view"""
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio UserPortfolioPermission.objects.get_or_create(
self.user.save() user=self.user, portfolio=self.portfolio, additional_permissions=[]
self.user.refresh_from_db() )
with override_flag("organization_feature", active=True): with override_flag("organization_feature", active=True):
# This will redirect the user to the portfolio page. # This will redirect the user to the portfolio page.
# Follow implicity checks if our redirect is working. # Follow implicity checks if our redirect is working.
@ -165,9 +172,9 @@ class TestPortfolio(WebTest):
def test_portfolio_organization_page_403_when_user_not_have_permission(self): def test_portfolio_organization_page_403_when_user_not_have_permission(self):
"""Test that user without proper permission is not allowed access to portfolio organization page""" """Test that user without proper permission is not allowed access to portfolio organization page"""
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
self.user.save() user=self.user, portfolio=self.portfolio, additional_permissions=[]
self.user.refresh_from_db() )
with override_flag("organization_feature", active=True): with override_flag("organization_feature", active=True):
# This will redirect the user to the portfolio page. # This will redirect the user to the portfolio page.
# Follow implicity checks if our redirect is working. # Follow implicity checks if our redirect is working.
@ -179,12 +186,13 @@ class TestPortfolio(WebTest):
def test_portfolio_organization_page_read_only(self): def test_portfolio_organization_page_read_only(self):
"""Test that user with a portfolio can access the portfolio organization page, read only""" """Test that user with a portfolio can access the portfolio organization page, read only"""
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
additional_permissions=[UserPortfolioPermissionChoices.VIEW_PORTFOLIO],
)
self.portfolio.city = "Los Angeles" self.portfolio.city = "Los Angeles"
self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO]
self.portfolio.save() self.portfolio.save()
self.user.save()
self.user.refresh_from_db()
with override_flag("organization_feature", active=True): with override_flag("organization_feature", active=True):
response = self.app.get(reverse("organization")) response = self.app.get(reverse("organization"))
# Assert the response is a 200 # Assert the response is a 200
@ -200,15 +208,16 @@ class TestPortfolio(WebTest):
def test_portfolio_organization_page_edit_access(self): def test_portfolio_organization_page_edit_access(self):
"""Test that user with a portfolio can access the portfolio organization page, read only""" """Test that user with a portfolio can access the portfolio organization page, read only"""
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
self.user.portfolio_additional_permissions = [ user=self.user,
UserPortfolioPermissionChoices.VIEW_PORTFOLIO, portfolio=self.portfolio,
UserPortfolioPermissionChoices.EDIT_PORTFOLIO, additional_permissions=[
] UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
],
)
self.portfolio.city = "Los Angeles" self.portfolio.city = "Los Angeles"
self.portfolio.save() self.portfolio.save()
self.user.save()
self.user.refresh_from_db()
with override_flag("organization_feature", active=True): with override_flag("organization_feature", active=True):
response = self.app.get(reverse("organization")) response = self.app.get(reverse("organization"))
# Assert the response is a 200 # Assert the response is a 200
@ -221,17 +230,17 @@ 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 portfolio_additional_permissions = [
self.user.portfolio_additional_permissions = [
UserPortfolioPermissionChoices.VIEW_PORTFOLIO, UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
] ]
self.user.save() portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
self.user.refresh_from_db() user=self.user, portfolio=self.portfolio, additional_permissions=portfolio_additional_permissions
)
with override_flag("organization_feature", active=True): with override_flag("organization_feature", active=True):
# This will redirect the user to the portfolio page. # This will redirect the user to the portfolio page.
# Follow implicity checks if our redirect is working. # Follow implicity checks if our redirect is working.
@ -245,26 +254,39 @@ class TestPortfolio(WebTest):
# removing non-basic portfolio perms, which should remove domains # removing non-basic portfolio perms, which should remove domains
# and domain requests from nav # and domain requests from nav
self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO] portfolio_permission.additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO]
self.user.save() portfolio_permission.save()
self.user.refresh_from_db() portfolio_permission.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 portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN] portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
self.user.save() user=self.user, portfolio=self.portfolio, roles=portfolio_roles
self.user.refresh_from_db() )
with override_flag("organization_feature", active=True): with override_flag("organization_feature", active=True):
# This will redirect the user to the portfolio page. # This will redirect the user to the portfolio page.
# Follow implicity checks if our redirect is working. # Follow implicity checks if our redirect is working.
@ -278,31 +300,43 @@ class TestPortfolio(WebTest):
# removing non-basic portfolio role, which should remove domains # removing non-basic portfolio role, which should remove domains
# and domain requests from nav # and domain requests from nav
self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_MEMBER] portfolio_permission.roles = [UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
self.user.save() portfolio_permission.save()
self.user.refresh_from_db() portfolio_permission.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."""
with override_flag("organization_feature", active=True): with override_flag("organization_feature", active=True):
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio portfolio_additional_permissions = [
self.user.portfolio_additional_permissions = [
UserPortfolioPermissionChoices.VIEW_PORTFOLIO, UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.EDIT_PORTFOLIO, UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
] ]
self.user.save() portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
self.user.refresh_from_db() user=self.user, portfolio=self.portfolio, additional_permissions=portfolio_additional_permissions
)
page = self.app.get(reverse("organization")) page = self.app.get(reverse("organization"))
self.assertContains( self.assertContains(
page, "The name of your federal agency will be publicly listed as the domain registrant." page, "The name of your federal agency will be publicly listed as the domain registrant."
@ -313,13 +347,13 @@ class TestPortfolio(WebTest):
"""Org name and address information appears on the page.""" """Org name and address information appears on the page."""
with override_flag("organization_feature", active=True): with override_flag("organization_feature", active=True):
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio portfolio_additional_permissions = [
self.user.portfolio_additional_permissions = [
UserPortfolioPermissionChoices.VIEW_PORTFOLIO, UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.EDIT_PORTFOLIO, UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
] ]
self.user.save() portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
self.user.refresh_from_db() user=self.user, portfolio=self.portfolio, additional_permissions=portfolio_additional_permissions
)
self.portfolio.organization_name = "Hotel California" self.portfolio.organization_name = "Hotel California"
self.portfolio.save() self.portfolio.save()
@ -333,13 +367,13 @@ class TestPortfolio(WebTest):
"""Submitting changes works on the org name address page.""" """Submitting changes works on the org name address page."""
with override_flag("organization_feature", active=True): with override_flag("organization_feature", active=True):
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio portfolio_additional_permissions = [
self.user.portfolio_additional_permissions = [
UserPortfolioPermissionChoices.VIEW_PORTFOLIO, UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.EDIT_PORTFOLIO, UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
] ]
self.user.save() portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
self.user.refresh_from_db() user=self.user, portfolio=self.portfolio, additional_permissions=portfolio_additional_permissions
)
self.portfolio.address_line1 = "1600 Penn Ave" self.portfolio.address_line1 = "1600 Penn Ave"
self.portfolio.save() self.portfolio.save()
@ -355,3 +389,146 @@ 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
def test_portfolio_in_session_when_organization_feature_active(self):
"""When organization_feature flag is true and user has a portfolio,
the portfolio should be set in session."""
self.client.force_login(self.user)
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles)
with override_flag("organization_feature", active=True):
response = self.client.get(reverse("home"))
# Ensure that middleware processes the session
session_middleware = SessionMiddleware(lambda request: None)
session_middleware.process_request(response.wsgi_request)
response.wsgi_request.session.save()
# Access the session via the request
session = response.wsgi_request.session
# Check if the 'portfolio' session variable exists
self.assertIn("portfolio", session, "Portfolio session variable should exist.")
# Check the value of the 'portfolio' session variable
self.assertEqual(session["portfolio"], self.portfolio, "Portfolio session variable has the wrong value.")
@less_console_noise_decorator
def test_portfolio_in_session_is_none_when_organization_feature_inactive(self):
"""When organization_feature flag is false and user has a portfolio,
the portfolio should be set to None in session.
This test also satisfies the condition when multiple_portfolios flag
is false and user has a portfolio, so won't add a redundant test for that."""
self.client.force_login(self.user)
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles)
response = self.client.get(reverse("home"))
# Ensure that middleware processes the session
session_middleware = SessionMiddleware(lambda request: None)
session_middleware.process_request(response.wsgi_request)
response.wsgi_request.session.save()
# Access the session via the request
session = response.wsgi_request.session
# Check if the 'portfolio' session variable exists
self.assertIn("portfolio", session, "Portfolio session variable should exist.")
# Check the value of the 'portfolio' session variable
self.assertIsNone(session["portfolio"])
@less_console_noise_decorator
def test_portfolio_in_session_is_none_when_organization_feature_active_and_no_portfolio(self):
"""When organization_feature flag is true and user does not have a portfolio,
the portfolio should be set to None in session."""
self.client.force_login(self.user)
with override_flag("organization_feature", active=True):
response = self.client.get(reverse("home"))
# Ensure that middleware processes the session
session_middleware = SessionMiddleware(lambda request: None)
session_middleware.process_request(response.wsgi_request)
response.wsgi_request.session.save()
# Access the session via the request
session = response.wsgi_request.session
# Check if the 'portfolio' session variable exists
self.assertIn("portfolio", session, "Portfolio session variable should exist.")
# Check the value of the 'portfolio' session variable
self.assertIsNone(session["portfolio"])
@less_console_noise_decorator
def test_portfolio_in_session_when_multiple_portfolios_active(self):
"""When multiple_portfolios flag is true and user has a portfolio,
the portfolio should be set in session."""
self.client.force_login(self.user)
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles)
with override_flag("organization_feature", active=True), override_flag("multiple_portfolios", active=True):
response = self.client.get(reverse("home"))
# Ensure that middleware processes the session
session_middleware = SessionMiddleware(lambda request: None)
session_middleware.process_request(response.wsgi_request)
response.wsgi_request.session.save()
# Access the session via the request
session = response.wsgi_request.session
# Check if the 'portfolio' session variable exists
self.assertIn("portfolio", session, "Portfolio session variable should exist.")
# Check the value of the 'portfolio' session variable
self.assertEqual(session["portfolio"], self.portfolio, "Portfolio session variable has the wrong value.")
@less_console_noise_decorator
def test_portfolio_in_session_is_none_when_multiple_portfolios_active_and_no_portfolio(self):
"""When multiple_portfolios flag is true and user does not have a portfolio,
the portfolio should be set to None in session."""
self.client.force_login(self.user)
with override_flag("multiple_portfolios", active=True):
response = self.client.get(reverse("home"))
# Ensure that middleware processes the session
session_middleware = SessionMiddleware(lambda request: None)
session_middleware.process_request(response.wsgi_request)
response.wsgi_request.session.save()
# Access the session via the request
session = response.wsgi_request.session
# Check if the 'portfolio' session variable exists
self.assertIn("portfolio", session, "Portfolio session variable should exist.")
# Check the value of the 'portfolio' session variable
self.assertIsNone(session["portfolio"])
@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.
"""
permission, _ = UserPortfolioPermission.objects.get_or_create(
user=self.user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
)
# A default organization member should not be able to see any domains
self.client.force_login(self.user)
response = self.client.get(reverse("home"), follow=True)
self.assertFalse(self.user.has_domains_portfolio_permission(response.wsgi_request.session.get("portfolio")))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "You aren")
# Test the domains page - this user should not have access
response = self.client.get(reverse("domains"))
self.assertEqual(response.status_code, 403)
# Ensure that this user can see domains with the right permissions
permission.additional_permissions = [UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS]
permission.save()
permission.refresh_from_db()
# Test the domains page - this user should have access
response = self.client.get(reverse("domains"))
self.assertTrue(self.user.has_domains_portfolio_permission(response.wsgi_request.session.get("portfolio")))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Domain name")
# Test the managed domains permission
permission.additional_permissions = [UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS]
permission.save()
permission.refresh_from_db()
# Test the domains page - this user should have access
response = self.client.get(reverse("domains"))
self.assertTrue(self.user.has_domains_portfolio_permission(response.wsgi_request.session.get("portfolio")))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Domain name")
permission.delete()

View file

@ -174,10 +174,11 @@ class DomainView(DomainBaseView):
"""Most views should not allow permission to portfolio users. """Most views should not allow permission to portfolio users.
If particular views allow permissions, they will need to override If particular views allow permissions, they will need to override
this function.""" this function."""
if self.request.user.has_domains_portfolio_permission(): portfolio = self.request.session.get("portfolio")
if self.request.user.has_domains_portfolio_permission(portfolio):
if Domain.objects.filter(id=pk).exists(): if Domain.objects.filter(id=pk).exists():
domain = Domain.objects.get(id=pk) domain = Domain.objects.get(id=pk)
if domain.domain_info.portfolio == self.request.user.portfolio: if domain.domain_info.portfolio == portfolio:
return True return True
return False return False
@ -236,7 +237,8 @@ class DomainOrgNameAddressView(DomainFormBaseView):
# Org users shouldn't have access to this page # Org users shouldn't have access to this page
is_org_user = self.request.user.is_org_user(self.request) is_org_user = self.request.user.is_org_user(self.request)
if self.request.user.portfolio and is_org_user: portfolio = self.request.session.get("portfolio")
if portfolio and is_org_user:
return False return False
else: else:
return super().has_permission() return super().has_permission()
@ -255,7 +257,8 @@ class DomainSubOrganizationView(DomainFormBaseView):
# non-org users shouldn't have access to this page # non-org users shouldn't have access to this page
is_org_user = self.request.user.is_org_user(self.request) is_org_user = self.request.user.is_org_user(self.request)
if self.request.user.portfolio and is_org_user: portfolio = self.request.session.get("portfolio")
if portfolio and is_org_user:
return super().has_permission() return super().has_permission()
else: else:
return False return False
@ -335,7 +338,8 @@ class DomainSeniorOfficialView(DomainFormBaseView):
# Org users shouldn't have access to this page # Org users shouldn't have access to this page
is_org_user = self.request.user.is_org_user(self.request) is_org_user = self.request.user.is_org_user(self.request)
if self.request.user.portfolio and is_org_user: portfolio = self.request.session.get("portfolio")
if portfolio and is_org_user:
return False return False
else: else:
return super().has_permission() return super().has_permission()

View file

@ -4,11 +4,14 @@ 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.user_portfolio_permission import UserPortfolioPermission
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 +41,35 @@ 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.session.get("portfolio")
if portfolio:
admin_ids = UserPortfolioPermission.objects.filter(
portfolio=portfolio,
roles__overlap=[
UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
],
).values_list("user__id", flat=True)
admin_users = User.objects.filter(id__in=admin_ids)
context["portfolio_administrators"] = admin_users
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.
@ -51,12 +83,13 @@ class PortfolioOrganizationView(PortfolioBasePermissionView, FormMixin):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""Add additional context data to the template.""" """Add additional context data to the template."""
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["has_edit_org_portfolio_permission"] = self.request.user.has_edit_org_portfolio_permission() portfolio = self.request.session.get("portfolio")
context["has_edit_org_portfolio_permission"] = self.request.user.has_edit_org_portfolio_permission(portfolio)
return context return context
def get_object(self, queryset=None): def get_object(self, queryset=None):
"""Get the portfolio object based on the request user.""" """Get the portfolio object based on the session."""
portfolio = self.request.user.portfolio portfolio = self.request.session.get("portfolio")
if portfolio is None: if portfolio is None:
raise Http404("No organization found for this user") raise Http404("No organization found for this user")
return portfolio return portfolio
@ -111,8 +144,8 @@ class PortfolioSeniorOfficialView(PortfolioBasePermissionView, FormMixin):
context_object_name = "portfolio" context_object_name = "portfolio"
def get_object(self, queryset=None): def get_object(self, queryset=None):
"""Get the portfolio object based on the request user.""" """Get the portfolio object based on the session."""
portfolio = self.request.user.portfolio portfolio = self.request.session.get("portfolio")
if portfolio is None: if portfolio is None:
raise Http404("No organization found for this user") raise Http404("No organization found for this user")
return portfolio return portfolio

View file

@ -419,7 +419,7 @@ class PortfolioBasePermission(PermissionsLoginMixin):
if not self.request.user.is_authenticated: if not self.request.user.is_authenticated:
return False return False
return self.request.user.has_base_portfolio_permission() return self.request.user.is_org_user(self.request)
class PortfolioDomainsPermission(PortfolioBasePermission): class PortfolioDomainsPermission(PortfolioBasePermission):
@ -432,9 +432,11 @@ class PortfolioDomainsPermission(PortfolioBasePermission):
The user is in self.request.user and the portfolio can be looked The user is in self.request.user and the portfolio can be looked
up from the portfolio's primary key in self.kwargs["pk"]""" up from the portfolio's primary key in self.kwargs["pk"]"""
if not self.request.user.is_authenticated: portfolio = self.request.session.get("portfolio")
if not self.request.user.has_domains_portfolio_permission(portfolio):
return False return False
return self.request.user.has_domains_portfolio_permission()
return super().has_permission()
class PortfolioDomainRequestsPermission(PortfolioBasePermission): class PortfolioDomainRequestsPermission(PortfolioBasePermission):
@ -447,6 +449,8 @@ class PortfolioDomainRequestsPermission(PortfolioBasePermission):
The user is in self.request.user and the portfolio can be looked The user is in self.request.user and the portfolio can be looked
up from the portfolio's primary key in self.kwargs["pk"]""" up from the portfolio's primary key in self.kwargs["pk"]"""
if not self.request.user.is_authenticated: portfolio = self.request.session.get("portfolio")
if not self.request.user.has_domain_requests_portfolio_permission(portfolio):
return False return False
return self.request.user.has_domain_requests_portfolio_permission()
return super().has_permission()

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.