diff --git a/docs/operations/data_migration.md b/docs/operations/data_migration.md
index cd748b22d..5914eb179 100644
--- a/docs/operations/data_migration.md
+++ b/docs/operations/data_migration.md
@@ -816,3 +816,25 @@ Example: `cf ssh getgov-za`
| | Parameter | Description |
|:-:|:-------------------------- |:-----------------------------------------------------------------------------------|
| 1 | **federal_cio_csv_path** | Specifies where the federal CIO csv is |
+
+## Populate Domain Request Dates
+This section outlines how to run the populate_domain_request_dates script
+
+### Running on sandboxes
+
+#### Step 1: Login to CloudFoundry
+```cf login -a api.fr.cloud.gov --sso```
+
+#### Step 2: SSH into your environment
+```cf ssh getgov-{space}```
+
+Example: `cf ssh getgov-za`
+
+#### Step 3: Create a shell instance
+```/tmp/lifecycle/shell```
+
+#### Step 4: Running the script
+```./manage.py populate_domain_request_dates```
+
+### Running locally
+```docker-compose exec app ./manage.py populate_domain_request_dates```
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 3ad5e3ea0..11a41a22d 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"""
@@ -644,6 +673,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
@@ -710,19 +752,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 = (
@@ -742,9 +777,6 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
"fields": (
"is_active",
"groups",
- "portfolio",
- "portfolio_roles",
- "portfolio_additional_permissions",
)
},
),
@@ -799,9 +831,6 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
"Important dates",
"last_login",
"date_joined",
- "portfolio",
- "portfolio_roles",
- "portfolio_additional_permissions",
]
# TODO: delete after we merge organization feature
@@ -1210,6 +1239,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."""
@@ -1649,7 +1698,9 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
# Columns
list_display = [
"requested_domain",
- "submission_date",
+ "first_submitted_date",
+ "last_submitted_date",
+ "last_status_update",
"status",
"generic_org_type",
"federal_type",
@@ -1852,7 +1903,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
# Table ordering
# NOTE: This impacts the select2 dropdowns (combobox)
# Currentl, there's only one for requests on DomainInfo
- ordering = ["-submission_date", "requested_domain__name"]
+ ordering = ["-last_submitted_date", "requested_domain__name"]
change_form_template = "django/admin/domain_request_change_form.html"
@@ -2236,6 +2287,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."""
@@ -2689,6 +2751,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
@@ -2873,7 +2946,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",
@@ -2926,6 +2999,7 @@ class PortfolioAdmin(ListHeaderAdmin):
"domain_requests",
"suborganizations",
"portfolio_type",
+ "creator",
]
def federal_type(self, obj: models.Portfolio):
@@ -2955,18 +3029,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
@@ -3197,6 +3280,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/js/get-gov.js b/src/registrar/assets/js/get-gov.js
index 2066ca1d0..7b676eeb3 100644
--- a/src/registrar/assets/js/get-gov.js
+++ b/src/registrar/assets/js/get-gov.js
@@ -1599,7 +1599,7 @@ document.addEventListener('DOMContentLoaded', function() {
const domainName = request.requested_domain ? request.requested_domain : `New domain request (${utcDateString(request.created_at)})`;
const actionUrl = request.action_url;
const actionLabel = request.action_label;
- const submissionDate = request.submission_date ? new Date(request.submission_date).toLocaleDateString('en-US', options) : `Not submitted`;
+ const submissionDate = request.last_submitted_date ? new Date(request.last_submitted_date).toLocaleDateString('en-US', options) : `Not submitted`;
// Even if the request is not deletable, we may need this empty string for the td if the deletable column is displayed
let modalTrigger = '';
@@ -1699,7 +1699,7 @@ document.addEventListener('DOMContentLoaded', function() {
${domainName}
-
+
${submissionDate}
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 413449896..19fa99809 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
@@ -139,6 +142,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/fixtures_domain_requests.py b/src/registrar/fixtures_domain_requests.py
index 50f611474..a5ec3fc74 100644
--- a/src/registrar/fixtures_domain_requests.py
+++ b/src/registrar/fixtures_domain_requests.py
@@ -95,7 +95,7 @@ class DomainRequestFixture:
# TODO for a future ticket: Allow for more than just "federal" here
da.generic_org_type = app["generic_org_type"] if "generic_org_type" in app else "federal"
- da.submission_date = fake.date()
+ da.last_submitted_date = fake.date()
da.federal_type = (
app["federal_type"]
if "federal_type" in app
diff --git a/src/registrar/management/commands/populate_domain_request_dates.py b/src/registrar/management/commands/populate_domain_request_dates.py
new file mode 100644
index 000000000..d975a035d
--- /dev/null
+++ b/src/registrar/management/commands/populate_domain_request_dates.py
@@ -0,0 +1,45 @@
+import logging
+from django.core.management import BaseCommand
+from registrar.management.commands.utility.terminal_helper import PopulateScriptTemplate, TerminalColors
+from registrar.models import DomainRequest
+from auditlog.models import LogEntry
+
+logger = logging.getLogger(__name__)
+
+
+class Command(BaseCommand, PopulateScriptTemplate):
+ help = "Loops through each domain request object and populates the last_status_update and first_submitted_date"
+
+ def handle(self, **kwargs):
+ """Loops through each DomainRequest object and populates
+ its last_status_update and first_submitted_date values"""
+ self.mass_update_records(DomainRequest, None, ["last_status_update", "first_submitted_date"])
+
+ def update_record(self, record: DomainRequest):
+ """Defines how we update the first_submitted_date and last_status_update fields"""
+
+ # Retrieve and order audit log entries by timestamp in descending order
+ audit_log_entries = LogEntry.objects.filter(object_pk=record.pk).order_by("-timestamp")
+ # Loop through logs in descending order to find most recent status change
+ for log_entry in audit_log_entries:
+ if "status" in log_entry.changes_dict:
+ record.last_status_update = log_entry.timestamp.date()
+ break
+
+ # Loop through logs in ascending order to find first submission
+ for log_entry in audit_log_entries.reverse():
+ status = log_entry.changes_dict.get("status")
+ if status and status[1] == "submitted":
+ record.first_submitted_date = log_entry.timestamp.date()
+ break
+
+ logger.info(
+ f"""{TerminalColors.OKCYAN}Updating {record} =>
+ first submitted date: {record.first_submitted_date},
+ last status update: {record.last_status_update}{TerminalColors.ENDC}
+ """
+ )
+
+ def should_skip_record(self, record) -> bool:
+ # make sure the record had some kind of history
+ return not LogEntry.objects.filter(object_pk=record.pk).exists()
diff --git a/src/registrar/management/commands/utility/terminal_helper.py b/src/registrar/management/commands/utility/terminal_helper.py
index 2c69e1080..b9e11be5d 100644
--- a/src/registrar/management/commands/utility/terminal_helper.py
+++ b/src/registrar/management/commands/utility/terminal_helper.py
@@ -86,7 +86,7 @@ class PopulateScriptTemplate(ABC):
You must define update_record before you can use this function.
"""
- records = object_class.objects.filter(**filter_conditions)
+ records = object_class.objects.filter(**filter_conditions) if filter_conditions else object_class.objects.all()
readable_class_name = self.get_class_name(object_class)
# Code execution will stop here if the user prompts "N"
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/migrations/0120_add_domainrequest_submission_dates.py b/src/registrar/migrations/0120_add_domainrequest_submission_dates.py
new file mode 100644
index 000000000..df409cf39
--- /dev/null
+++ b/src/registrar/migrations/0120_add_domainrequest_submission_dates.py
@@ -0,0 +1,47 @@
+# Generated by Django 4.2.10 on 2024-08-16 15:28
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("registrar", "0119_remove_user_portfolio_and_more"),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name="domainrequest",
+ old_name="submission_date",
+ new_name="last_submitted_date",
+ ),
+ migrations.AlterField(
+ model_name="domainrequest",
+ name="last_submitted_date",
+ field=models.DateField(
+ blank=True, default=None, help_text="Date last submitted", null=True, verbose_name="last submitted on"
+ ),
+ ),
+ migrations.AddField(
+ model_name="domainrequest",
+ name="first_submitted_date",
+ field=models.DateField(
+ blank=True,
+ default=None,
+ help_text="Date initially submitted",
+ null=True,
+ verbose_name="first submitted on",
+ ),
+ ),
+ migrations.AddField(
+ model_name="domainrequest",
+ name="last_status_update",
+ field=models.DateField(
+ blank=True,
+ default=None,
+ help_text="Date of the last status update",
+ null=True,
+ verbose_name="last updated on",
+ ),
+ ),
+ ]
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/domain_request.py b/src/registrar/models/domain_request.py
index 966c880d7..7ee80e43a 100644
--- a/src/registrar/models/domain_request.py
+++ b/src/registrar/models/domain_request.py
@@ -563,15 +563,32 @@ class DomainRequest(TimeStampedModel):
help_text="Acknowledged .gov acceptable use policy",
)
- # submission date records when domain request is submitted
- submission_date = models.DateField(
+ # Records when the domain request was first submitted
+ first_submitted_date = models.DateField(
null=True,
blank=True,
default=None,
- verbose_name="submitted at",
- help_text="Date submitted",
+ verbose_name="first submitted on",
+ help_text="Date initially submitted",
)
+ # Records when domain request was last submitted
+ last_submitted_date = models.DateField(
+ null=True,
+ blank=True,
+ default=None,
+ verbose_name="last submitted on",
+ help_text="Date last submitted",
+ )
+
+ # Records when domain request status was last updated by an admin or analyst
+ last_status_update = models.DateField(
+ null=True,
+ blank=True,
+ default=None,
+ verbose_name="last updated on",
+ help_text="Date of the last status update",
+ )
notes = models.TextField(
null=True,
blank=True,
@@ -621,6 +638,9 @@ class DomainRequest(TimeStampedModel):
self.sync_organization_type()
self.sync_yes_no_form_fields()
+ if self._cached_status != self.status:
+ self.last_status_update = timezone.now().date()
+
super().save(*args, **kwargs)
# Handle the action needed email.
@@ -803,8 +823,12 @@ class DomainRequest(TimeStampedModel):
if not DraftDomain.string_could_be_domain(self.requested_domain.name):
raise ValueError("Requested domain is not a valid domain name.")
- # Update submission_date to today
- self.submission_date = timezone.now().date()
+ # if the domain has not been submitted before this must be the first time
+ if not self.first_submitted_date:
+ self.first_submitted_date = timezone.now().date()
+
+ # Update last_submitted_date to today
+ self.last_submitted_date = timezone.now().date()
self.save()
# Limit email notifications to transitions from Started and Withdrawn
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"" 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.")
diff --git a/src/registrar/registrar_middleware.py b/src/registrar/registrar_middleware.py
index 3bcb1dc23..4b590db1e 100644
--- a/src/registrar/registrar_middleware.py
+++ b/src/registrar/registrar_middleware.py
@@ -125,8 +125,9 @@ class CheckUserProfileMiddleware:
class CheckPortfolioMiddleware:
"""
- Checks if the current user has a portfolio
- If they do, redirect them to the portfolio homepage when they navigate to home.
+ this middleware should serve two purposes:
+ 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):
@@ -140,15 +141,24 @@ class CheckPortfolioMiddleware:
def process_view(self, request, view_func, view_args, view_kwargs):
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():
- portfolio = request.user.portfolio
+ # set the portfolio in the session if it is not set
+ 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
- request.portfolio = portfolio
-
- if request.user.has_domains_portfolio_permission():
+ if request.session["portfolio"] is not None and current_path == self.home:
+ if request.user.is_org_user(request):
+ if request.user.has_domains_portfolio_permission(request.session["portfolio"]):
portfolio_redirect = reverse("domains")
else:
portfolio_redirect = reverse("no-portfolio-domains")
diff --git a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
index 683f33117..3b4047d39 100644
--- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
+++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
@@ -145,20 +145,110 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% block field_other %}
{% if field.field.name == "action_needed_reason_email" %}
-
-
- {{ field.field.value|linebreaks }}
-
-
-
- {{ field.field }}
-
+
+ -
+
+
+
+
+
+
Auto-generated email that will be sent to the creator
+ 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.
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ field.field }}
+
+
+
+
+
+ {% 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 %}
+
{% else %}
{{ field.field }}
diff --git a/src/registrar/templates/django/admin/portfolio_change_form.html b/src/registrar/templates/django/admin/portfolio_change_form.html
index 9d59aae42..4eb941340 100644
--- a/src/registrar/templates/django/admin/portfolio_change_form.html
+++ b/src/registrar/templates/django/admin/portfolio_change_form.html
@@ -5,6 +5,8 @@
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
{% url 'get-senior-official-from-federal-agency-json' as url %}
+ {% url 'get-federal-and-portfolio-types-from-federal-agency-json' as url %}
+
{{ block.super }}
{% endblock content %}
diff --git a/src/registrar/templates/domain_detail.html b/src/registrar/templates/domain_detail.html
index 19f196e40..d7bc277b3 100644
--- a/src/registrar/templates/domain_detail.html
+++ b/src/registrar/templates/domain_detail.html
@@ -72,9 +72,9 @@
{% include "includes/summary_item.html" with title='DNSSEC' value='Not Enabled' edit_link=url editable=is_editable %}
{% 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 %}
- {% 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 %}
{% 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 %}
diff --git a/src/registrar/templates/domain_sidebar.html b/src/registrar/templates/domain_sidebar.html
index f2e9f190c..24f92bf16 100644
--- a/src/registrar/templates/domain_sidebar.html
+++ b/src/registrar/templates/domain_sidebar.html
@@ -61,7 +61,7 @@
{% if portfolio %}
{% 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" %}
{% include "includes/domain_sidenav_item.html" with item_text="Suborganization" %}
{% endwith %}
diff --git a/src/registrar/templates/domain_suborganization.html b/src/registrar/templates/domain_suborganization.html
index 412878960..823629213 100644
--- a/src/registrar/templates/domain_suborganization.html
+++ b/src/registrar/templates/domain_suborganization.html
@@ -15,7 +15,7 @@
If you believe there is an error please contact help@get.gov.
- {% if has_domains_portfolio_permission and request.user.has_edit_suborganization %}
+ {% if has_domains_portfolio_permission and has_edit_suborganization %}