diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 636a9fa5c..38c06c2b8 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -34,6 +34,7 @@ from django_fsm import TransitionNotAllowed # type: ignore
from django.utils.safestring import mark_safe
from django.utils.html import escape
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 import_export import resources
from import_export.admin import ImportExportModelAdmin
@@ -131,14 +132,6 @@ class MyUserAdminForm(UserChangeForm):
widgets = {
"groups": NoAutocompleteFilteredSelectMultiple("groups", 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):
@@ -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):
"""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),
}
labels = {
- "action_needed_reason_email": "Auto-generated email",
+ "action_needed_reason_email": "Email",
}
def __init__(self, *args, **kwargs):
@@ -366,7 +375,9 @@ class DomainRequestAdminForm(forms.ModelForm):
class MultiFieldSortableChangeList(admin.views.main.ChangeList):
"""
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:
@@ -428,6 +439,24 @@ class MultiFieldSortableChangeList(admin.views.main.ChangeList):
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):
"""Overwrite the generated LogEntry admin class"""
@@ -643,6 +672,19 @@ class ListHeaderAdmin(AuditedAdmin, OrderableFieldsMixin):
)
except models.User.DoesNotExist:
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:
# For other parameter names, append a dictionary with the original
# parameter_name and the corresponding parameter_value
@@ -709,19 +751,12 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
"is_superuser",
"groups",
"user_permissions",
- "portfolio",
- "portfolio_roles",
- "portfolio_additional_permissions",
)
},
),
("Important dates", {"fields": ("last_login", "date_joined")}),
)
- autocomplete_fields = [
- "portfolio",
- ]
-
readonly_fields = ("verification_type",)
analyst_fieldsets = (
@@ -741,9 +776,6 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
"fields": (
"is_active",
"groups",
- "portfolio",
- "portfolio_roles",
- "portfolio_additional_permissions",
)
},
),
@@ -798,9 +830,6 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
"Important dates",
"last_login",
"date_joined",
- "portfolio",
- "portfolio_roles",
- "portfolio_additional_permissions",
]
# TODO: delete after we merge organization feature
@@ -1209,6 +1238,26 @@ class UserDomainRoleResource(resources.ModelResource):
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):
"""Custom user domain role admin class."""
@@ -2221,6 +2270,17 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
use_sort = db_field.name != "senior_official"
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):
"""Custom transition domain admin class."""
@@ -2674,6 +2734,17 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
return True
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):
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
@@ -2858,7 +2929,7 @@ class PortfolioAdmin(ListHeaderAdmin):
# "classes": ("collapse", "closed"),
# "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"]}),
(
"Organization name and mailing address",
@@ -2911,6 +2982,7 @@ class PortfolioAdmin(ListHeaderAdmin):
"domain_requests",
"suborganizations",
"portfolio_type",
+ "creator",
]
def federal_type(self, obj: models.Portfolio):
@@ -2940,18 +3012,27 @@ class PortfolioAdmin(ListHeaderAdmin):
suborganizations.short_description = "Suborganizations" # type: ignore
def domains(self, obj: models.Portfolio):
- """Returns a list of links for each related domain"""
- queryset = obj.get_domains()
- return self.get_field_links_as_list(
- queryset, "domaininformation", link_info_attribute="get_state_display_of_domain"
- )
+ """Returns the count of domains with a link to view them in the admin."""
+ domain_count = obj.get_domains().count() # Count the related domains
+ if domain_count > 0:
+ # 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('{} {}', url, domain_count, label)
+ return "No domains"
domains.short_description = "Domains" # type: ignore
def domain_requests(self, obj: models.Portfolio):
- """Returns a list of links for each related domain request"""
- queryset = obj.get_domain_requests()
- return self.get_field_links_as_list(queryset, "domainrequest", link_info_attribute="get_status_display")
+ """Returns the count of domain requests with a link to view them in the admin."""
+ domain_request_count = obj.get_domain_requests().count() # Count the related domain requests
+ 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('{} domain requests', url, domain_request_count)
+ return "No domain requests"
domain_requests.short_description = "Domain requests" # type: ignore
@@ -3182,6 +3263,7 @@ admin.site.register(models.Portfolio, PortfolioAdmin)
admin.site.register(models.DomainGroup, DomainGroupAdmin)
admin.site.register(models.Suborganization, SuborganizationAdmin)
admin.site.register(models.SeniorOfficial, SeniorOfficialAdmin)
+admin.site.register(models.UserPortfolioPermission, UserPortfolioPermissionAdmin)
# Register our custom waffle implementations
admin.site.register(models.WaffleFlag, WaffleFlagAdmin)
diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js
index 01c93abf6..24f020b75 100644
--- a/src/registrar/assets/js/get-gov-admin.js
+++ b/src/registrar/assets/js/get-gov-admin.js
@@ -353,7 +353,7 @@ function initializeWidgetOnList(list, parentId) {
let rejectionReasonFormGroup = document.querySelector('.field-rejection_reason')
// This is the "action needed reason" field
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')
if (rejectionReasonFormGroup && actionNeededReasonFormGroup && actionNeededReasonEmailFormGroup) {
@@ -509,22 +509,38 @@ function initializeWidgetOnList(list, parentId) {
(function () {
// Since this is an iife, these vars will be removed from memory afterwards
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 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');
if (!emailData) {
return;
}
-
let actionNeededEmailData = emailData.textContent;
if(!actionNeededEmailData) {
return;
}
-
let actionNeededEmailsJson = JSON.parse(actionNeededEmailData);
+
const domainRequestId = actionNeededReasonDropdown ? document.querySelector("#domain_request_id").value : null
const emailSentSessionVariableName = `actionNeededEmailSent-${domainRequestId}`;
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
addOrRemoveSessionBoolean(emailSentSessionVariableName, add=true);
}
-
+
// Show an editable email field or a readonly one
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
actionNeededReasonDropdown.addEventListener("change", function() {
let reason = actionNeededReasonDropdown.value;
let emailBody = reason in actionNeededEmailsJson ? actionNeededEmailsJson[reason] : null;
+
if (reason && emailBody) {
- // Replace the email content
- actionNeededEmail.value = emailBody;
-
// Reset the session object on change since change refreshes the email content.
if (oldDropdownValue !== actionNeededReasonDropdown.value || oldEmailValue !== actionNeededEmail.value) {
- let emailSent = sessionStorage.getItem(emailSentSessionVariableName)
- if (emailSent !== null){
- addOrRemoveSessionBoolean(emailSentSessionVariableName, add=false)
- }
+ // Replace the email content
+ actionNeededEmail.value = emailBody;
+ 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)
});
}
- // 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.
- // Likewise, if we've sent this email before, we should just display the content.
function updateActionNeededEmailDisplay(reason) {
- let emailHasBeenSentBefore = sessionStorage.getItem(emailSentSessionVariableName) !== null;
- let collapseableDiv = readonlyView.querySelector(".collapse--dgsimple");
- let showMoreButton = document.querySelector("#action_needed_reason_email__show_details");
- if ((reason && reason != "other") && !emailHasBeenSentBefore) {
- showElement(actionNeededEmail.parentElement)
- hideElement(readonlyView)
- 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)
+
+ if (reason) {
+ if (reason === "other") {
+ // Hide email preview and show this text instead
+ showPlaceholderText("No email will be sent");
}
- hideElement(actionNeededEmail.parentElement)
- showElement(readonlyView)
+ else {
+ // 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)
+ }
})();
@@ -833,10 +908,28 @@ function initializeWidgetOnList(list, parentId) {
return;
}
+ // Determine if any changes are necessary to the display of portfolio type or federal type
+ // based on changes to the Federal Agency
+ let federalPortfolioApi = document.getElementById("federal_and_portfolio_types_from_agency_json_url").value;
+ fetch(`${federalPortfolioApi}?organization_type=${organizationType.value}&agency_name=${selectedText}`)
+ .then(response => {
+ const statusCode = response.status;
+ return response.json().then(data => ({ statusCode, data }));
+ })
+ .then(({ statusCode, data }) => {
+ if (data.error) {
+ console.error("Error in AJAX call: " + data.error);
+ return;
+ }
+ updateReadOnly(data.federal_type, '.field-federal_type');
+ updateReadOnly(data.portfolio_type, '.field-portfolio_type');
+ })
+ .catch(error => console.error("Error fetching federal and portfolio types: ", error));
+
// Hide the contactList initially.
// If we can update the contact information, it'll be shown again.
hideElement(contactList.parentElement);
-
+
let seniorOfficialApi = document.getElementById("senior_official_from_agency_json_url").value;
fetch(`${seniorOfficialApi}?agency_name=${selectedText}`)
.then(response => {
@@ -879,6 +972,7 @@ function initializeWidgetOnList(list, parentId) {
}
})
.catch(error => console.error("Error fetching senior official: ", error));
+
}
function handleStateTerritoryChange(stateTerritory, urbanizationField) {
@@ -890,6 +984,26 @@ function initializeWidgetOnList(list, parentId) {
}
}
+ /**
+ * Utility that selects a div from the DOM using selectorString,
+ * and updates a div within that div which has class of 'readonly'
+ * so that the text of the div is updated to updateText
+ * @param {*} updateText
+ * @param {*} selectorString
+ */
+ function updateReadOnly(updateText, selectorString) {
+ // find the div by selectorString
+ const selectedDiv = document.querySelector(selectorString);
+ if (selectedDiv) {
+ // find the nested div with class 'readonly' inside the selectorString div
+ const readonlyDiv = selectedDiv.querySelector('.readonly');
+ if (readonlyDiv) {
+ // Update the text content of the readonly div
+ readonlyDiv.textContent = updateText !== null ? updateText : '-';
+ }
+ }
+ }
+
function updateContactInfo(data) {
if (!contactList) return;
diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss
index 8ca6b5465..f7d1e5788 100644
--- a/src/registrar/assets/sass/_theme/_admin.scss
+++ b/src/registrar/assets/sass/_theme/_admin.scss
@@ -66,6 +66,9 @@ html[data-theme="light"] {
// --object-tools-fg: var(--button-fg);
// --object-tools-bg: var(--close-button-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
@@ -104,6 +107,9 @@ html[data-theme="light"] {
--close-button-bg: #333333;
--close-button-hover-bg: #666666;
+
+ --summary-box-bg: #121212;
+ --summary-box-border: #666666;
}
// 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 {
padding: 0 !important;
margin: 0 !important;
diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py
index 7e483dc92..4c1f2639c 100644
--- a/src/registrar/config/urls.py
+++ b/src/registrar/config/urls.py
@@ -24,7 +24,10 @@ from registrar.views.report_views import (
from registrar.views.domain_request import Step
from registrar.views.domain_requests_json import get_domain_requests_json
-from registrar.views.utility.api_views import get_senior_official_from_federal_agency_json
+from registrar.views.utility.api_views import (
+ get_senior_official_from_federal_agency_json,
+ get_federal_and_portfolio_types_from_federal_agency_json,
+)
from registrar.views.domains_json import get_domains_json
from registrar.views.utility import always_404
from api.views import available, get_current_federal, get_current_full
@@ -138,6 +141,11 @@ urlpatterns = [
get_senior_official_from_federal_agency_json,
name="get-senior-official-from-federal-agency-json",
),
+ path(
+ "admin/api/get-federal-and-portfolio-types-from-federal-agency-json/",
+ get_federal_and_portfolio_types_from_federal_agency_json,
+ name="get-federal-and-portfolio-types-from-federal-agency-json",
+ ),
path("admin/", admin.site.urls),
path(
"reports/export_data_type_user/",
diff --git a/src/registrar/context_processors.py b/src/registrar/context_processors.py
index ee5f8aee1..ea04dca80 100644
--- a/src/registrar/context_processors.py
+++ b/src/registrar/context_processors.py
@@ -61,27 +61,37 @@ def add_has_profile_feature_flag_to_context(request):
def portfolio_permissions(request):
"""Make portfolio permissions for the request user available in global context"""
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 {
- "has_base_portfolio_permission": False,
- "has_domains_portfolio_permission": False,
- "has_domain_requests_portfolio_permission": False,
- "portfolio": None,
- "has_organization_feature_flag": False,
+ "has_base_portfolio_permission": request.user.has_base_portfolio_permission(portfolio),
+ "has_domains_portfolio_permission": request.user.has_domains_portfolio_permission(portfolio),
+ "has_domain_requests_portfolio_permission": request.user.has_domain_requests_portfolio_permission(
+ portfolio
+ ),
+ "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 {
- "has_base_portfolio_permission": request.user.has_base_portfolio_permission(),
- "has_domains_portfolio_permission": request.user.has_domains_portfolio_permission(),
- "has_domain_requests_portfolio_permission": request.user.has_domain_requests_portfolio_permission(),
- "portfolio": request.user.portfolio,
- "has_organization_feature_flag": True,
+ "has_base_portfolio_permission": False,
+ "has_domains_portfolio_permission": False,
+ "has_domain_requests_portfolio_permission": False,
+ "has_view_suborganization": False,
+ "has_edit_suborganization": False,
+ "portfolio": None,
+ "has_organization_feature_flag": False,
}
+
except AttributeError:
# Handles cases where request.user might not exist
return {
"has_base_portfolio_permission": False,
"has_domains_portfolio_permission": False,
"has_domain_requests_portfolio_permission": False,
+ "has_view_suborganization": False,
+ "has_edit_suborganization": False,
"portfolio": None,
"has_organization_feature_flag": False,
}
diff --git a/src/registrar/migrations/0119_remove_user_portfolio_and_more.py b/src/registrar/migrations/0119_remove_user_portfolio_and_more.py
new file mode 100644
index 000000000..84ed45cd1
--- /dev/null
+++ b/src/registrar/migrations/0119_remove_user_portfolio_and_more.py
@@ -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")},
+ },
+ ),
+ ]
diff --git a/src/registrar/models/__init__.py b/src/registrar/models/__init__.py
index 1e0aad0b1..c1023cafe 100644
--- a/src/registrar/models/__init__.py
+++ b/src/registrar/models/__init__.py
@@ -21,6 +21,7 @@ from .portfolio import Portfolio
from .domain_group import DomainGroup
from .suborganization import Suborganization
from .senior_official import SeniorOfficial
+from .user_portfolio_permission import UserPortfolioPermission
__all__ = [
@@ -46,6 +47,7 @@ __all__ = [
"DomainGroup",
"Suborganization",
"SeniorOfficial",
+ "UserPortfolioPermission",
]
auditlog.register(Contact)
@@ -70,3 +72,4 @@ auditlog.register(Portfolio)
auditlog.register(DomainGroup)
auditlog.register(Suborganization)
auditlog.register(SeniorOfficial)
+auditlog.register(UserPortfolioPermission)
diff --git a/src/registrar/models/portfolio.py b/src/registrar/models/portfolio.py
index 0f9904c31..fadcf8cac 100644
--- a/src/registrar/models/portfolio.py
+++ b/src/registrar/models/portfolio.py
@@ -131,9 +131,13 @@ class Portfolio(TimeStampedModel):
Returns a combination of organization_type / federal_type, seperated by ' - '.
If no federal_type is found, we just return the org type.
"""
- org_type_label = self.OrganizationChoices.get_org_label(self.organization_type)
- agency_type_label = BranchChoices.get_branch_label(self.federal_type)
- if self.organization_type == self.OrganizationChoices.FEDERAL and agency_type_label:
+ return self.get_portfolio_type(self.organization_type, self.federal_type)
+
+ @classmethod
+ def get_portfolio_type(cls, organization_type, federal_type):
+ org_type_label = cls.OrganizationChoices.get_org_label(organization_type)
+ agency_type_label = BranchChoices.get_branch_label(federal_type)
+ if organization_type == cls.OrganizationChoices.FEDERAL and agency_type_label:
return " - ".join([org_type_label, agency_type_label])
else:
return org_type_label
@@ -141,7 +145,11 @@ class Portfolio(TimeStampedModel):
@property
def federal_type(self):
"""Returns the federal_type value on the underlying federal_agency field"""
- return self.federal_agency.federal_type if self.federal_agency else None
+ return self.get_federal_type(self.federal_agency)
+
+ @classmethod
+ def get_federal_type(cls, federal_agency):
+ return federal_agency.federal_type if federal_agency else None
# == Getters for domains == #
def get_domains(self):
diff --git a/src/registrar/models/portfolio_invitation.py b/src/registrar/models/portfolio_invitation.py
index 2ad780429..46d7bf124 100644
--- a/src/registrar/models/portfolio_invitation.py
+++ b/src/registrar/models/portfolio_invitation.py
@@ -1,13 +1,11 @@
"""People are invited by email to administer domains."""
import logging
-
from django.contrib.auth import get_user_model
from django.db import models
-
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.time_stamped_model import TimeStampedModel
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.")
# 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:
- 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:
- user.portfolio_additional_permissions = self.portfolio_additional_permissions
- user.save()
+ user_portfolio_permission.additional_permissions = self.portfolio_additional_permissions
+ user_portfolio_permission.save()
diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py
index 81d3b9b61..a7ea1e14a 100644
--- a/src/registrar/models/user.py
+++ b/src/registrar/models/user.py
@@ -3,10 +3,9 @@ import logging
from django.contrib.auth.models import AbstractUser
from django.db import models
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.user_domain_role import UserDomainRole
+from registrar.models import DomainInformation, UserDomainRole
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from .domain_invitation import DomainInvitation
@@ -15,7 +14,6 @@ from .transition_domain import TransitionDomain
from .verified_by_staff import VerifiedByStaff
from .domain import Domain
from .domain_request import DomainRequest
-from django.contrib.postgres.fields import ArrayField
from waffle.decorators import flag_is_active
from phonenumber_field.modelfields import PhoneNumberField # type: ignore
@@ -112,34 +110,6 @@ class User(AbstractUser):
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(
null=True,
blank=True,
@@ -230,68 +200,50 @@ class User(AbstractUser):
def has_contact_info(self):
return bool(self.title or self.email or self.phone)
- def clean(self):
- """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):
+ def _has_portfolio_permission(self, portfolio, portfolio_permission):
"""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
- 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
- # to make them easier to call elsewhere throughout the application
- def has_base_portfolio_permission(self):
- return self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_PORTFOLIO)
+ def has_base_portfolio_permission(self, portfolio):
+ return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_PORTFOLIO)
- def has_edit_org_portfolio_permission(self):
- return self._has_portfolio_permission(UserPortfolioPermissionChoices.EDIT_PORTFOLIO)
+ def has_edit_org_portfolio_permission(self, 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(
- UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS
- ) or self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS)
+ portfolio, UserPortfolioPermissionChoices.VIEW_ALL_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(
- UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS
- ) or self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS)
+ portfolio, UserPortfolioPermissionChoices.VIEW_ALL_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"""
- return self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)
+ return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)
# Field specific permission checks
- def has_view_suborganization(self):
- return self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION)
+ def has_view_suborganization(self, portfolio):
+ return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION)
- def has_edit_suborganization(self):
- return self._has_portfolio_permission(UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION)
+ def has_edit_suborganization(self, portfolio):
+ 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
def needs_identity_verification(cls, email, uuid):
@@ -406,7 +358,14 @@ class User(AbstractUser):
for invitation in PortfolioInvitation.objects.filter(
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:
invitation.retrieve()
invitation.save()
@@ -431,13 +390,17 @@ class User(AbstractUser):
self.check_domain_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):
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):
"""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():
- return DomainInformation.objects.filter(portfolio=self.portfolio).values_list("domain_id", flat=True)
+ portfolio = request.session.get("portfolio")
+ 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:
return UserDomainRole.objects.filter(user=self).values_list("domain_id", flat=True)
diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py
new file mode 100644
index 000000000..bf1c3e566
--- /dev/null
+++ b/src/registrar/models/user_portfolio_permission.py
@@ -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" Auto-generated email that will be sent to the creator
+
+ Email sent to the creator
+
+ The creator of this request already received an email for this status/reason:
+
+ If you edit this email's text, the system will send another email to
+ the creator after you “save” your changes. If you do not want to send another email, click “cancel” below.
+
+ Are you sure you want to edit this email?
+
+
+
+