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 }} - +
+ - +
+
+ +
+ {{ 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 %}
{% csrf_token %} {% input_with_errors form.sub_organization %} diff --git a/src/registrar/templates/emails/action_needed_reasons/already_has_domains.txt b/src/registrar/templates/emails/action_needed_reasons/already_has_domains.txt index b1b3b0a1c..2e3012c91 100644 --- a/src/registrar/templates/emails/action_needed_reasons/already_has_domains.txt +++ b/src/registrar/templates/emails/action_needed_reasons/already_has_domains.txt @@ -4,7 +4,7 @@ Hi, {{ recipient.first_name }}. We've identified an action that you’ll need to complete before we continue reviewing your .gov domain request. DOMAIN REQUESTED: {{ domain_request.requested_domain.name }} -REQUEST RECEIVED ON: {{ domain_request.submission_date|date }} +REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }} STATUS: Action needed ---------------------------------------------------------------- diff --git a/src/registrar/templates/emails/action_needed_reasons/bad_name.txt b/src/registrar/templates/emails/action_needed_reasons/bad_name.txt index 7d088aa4e..9481a1e63 100644 --- a/src/registrar/templates/emails/action_needed_reasons/bad_name.txt +++ b/src/registrar/templates/emails/action_needed_reasons/bad_name.txt @@ -4,7 +4,7 @@ Hi, {{ recipient.first_name }}. We've identified an action that you’ll need to complete before we continue reviewing your .gov domain request. DOMAIN REQUESTED: {{ domain_request.requested_domain.name }} -REQUEST RECEIVED ON: {{ domain_request.submission_date|date }} +REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }} STATUS: Action needed ---------------------------------------------------------------- diff --git a/src/registrar/templates/emails/action_needed_reasons/eligibility_unclear.txt b/src/registrar/templates/emails/action_needed_reasons/eligibility_unclear.txt index d3a986183..705805998 100644 --- a/src/registrar/templates/emails/action_needed_reasons/eligibility_unclear.txt +++ b/src/registrar/templates/emails/action_needed_reasons/eligibility_unclear.txt @@ -4,7 +4,7 @@ Hi, {{ recipient.first_name }}. We've identified an action that you’ll need to complete before we continue reviewing your .gov domain request. DOMAIN REQUESTED: {{ domain_request.requested_domain.name }} -REQUEST RECEIVED ON: {{ domain_request.submission_date|date }} +REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }} STATUS: Action needed ---------------------------------------------------------------- diff --git a/src/registrar/templates/emails/action_needed_reasons/questionable_senior_official.txt b/src/registrar/templates/emails/action_needed_reasons/questionable_senior_official.txt index e20e4cb60..5967d7089 100644 --- a/src/registrar/templates/emails/action_needed_reasons/questionable_senior_official.txt +++ b/src/registrar/templates/emails/action_needed_reasons/questionable_senior_official.txt @@ -4,7 +4,7 @@ Hi, {{ recipient.first_name }}. We've identified an action that you’ll need to complete before we continue reviewing your .gov domain request. DOMAIN REQUESTED: {{ domain_request.requested_domain.name }} -REQUEST RECEIVED ON: {{ domain_request.submission_date|date }} +REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }} STATUS: Action needed ---------------------------------------------------------------- diff --git a/src/registrar/templates/emails/domain_request_withdrawn.txt b/src/registrar/templates/emails/domain_request_withdrawn.txt index 6efa92d64..0db00feea 100644 --- a/src/registrar/templates/emails/domain_request_withdrawn.txt +++ b/src/registrar/templates/emails/domain_request_withdrawn.txt @@ -4,7 +4,7 @@ Hi, {{ recipient.first_name }}. Your .gov domain request has been withdrawn and will not be reviewed by our team. DOMAIN REQUESTED: {{ domain_request.requested_domain.name }} -REQUEST RECEIVED ON: {{ domain_request.submission_date|date }} +REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }} STATUS: Withdrawn ---------------------------------------------------------------- diff --git a/src/registrar/templates/emails/status_change_approved.txt b/src/registrar/templates/emails/status_change_approved.txt index 70f813599..66f8f8b6c 100644 --- a/src/registrar/templates/emails/status_change_approved.txt +++ b/src/registrar/templates/emails/status_change_approved.txt @@ -4,7 +4,7 @@ Hi, {{ recipient.first_name }}. Congratulations! Your .gov domain request has been approved. DOMAIN REQUESTED: {{ domain_request.requested_domain.name }} -REQUEST RECEIVED ON: {{ domain_request.submission_date|date }} +REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }} STATUS: Approved You can manage your approved domain on the .gov registrar . diff --git a/src/registrar/templates/emails/status_change_rejected.txt b/src/registrar/templates/emails/status_change_rejected.txt index 2fcbb1d83..4e5250162 100644 --- a/src/registrar/templates/emails/status_change_rejected.txt +++ b/src/registrar/templates/emails/status_change_rejected.txt @@ -4,7 +4,7 @@ Hi, {{ recipient.first_name }}. Your .gov domain request has been rejected. DOMAIN REQUESTED: {{ domain_request.requested_domain.name }} -REQUEST RECEIVED ON: {{ domain_request.submission_date|date }} +REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }} STATUS: Rejected ---------------------------------------------------------------- diff --git a/src/registrar/templates/emails/submission_confirmation.txt b/src/registrar/templates/emails/submission_confirmation.txt index 740e6f393..c8ff4c7eb 100644 --- a/src/registrar/templates/emails/submission_confirmation.txt +++ b/src/registrar/templates/emails/submission_confirmation.txt @@ -4,7 +4,7 @@ Hi, {{ recipient.first_name }}. We received your .gov domain request. DOMAIN REQUESTED: {{ domain_request.requested_domain.name }} -REQUEST RECEIVED ON: {{ domain_request.submission_date|date }} +REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }} STATUS: Submitted ---------------------------------------------------------------- diff --git a/src/registrar/templates/includes/domain_requests_table.html b/src/registrar/templates/includes/domain_requests_table.html index 9ad49c50d..f73f8079f 100644 --- a/src/registrar/templates/includes/domain_requests_table.html +++ b/src/registrar/templates/includes/domain_requests_table.html @@ -45,7 +45,7 @@ Domain name - Date submitted + Date submitted Status Action diff --git a/src/registrar/templates/includes/domains_table.html b/src/registrar/templates/includes/domains_table.html index 680ff8254..57eeef8d6 100644 --- a/src/registrar/templates/includes/domains_table.html +++ b/src/registrar/templates/includes/domains_table.html @@ -157,7 +157,7 @@ Domain name Expires Status - {% if portfolio and request.user.has_view_suborganization %} + {% if portfolio and has_view_suborganization %} Suborganization {% endif %} ', domains) + self.assertIn("2 domains", domains) @less_console_noise_decorator def test_domain_requests_display(self): @@ -2118,6 +2116,4 @@ class TestPortfolioAdmin(TestCase): completed_domain_request(name="request2.gov", portfolio=self.portfolio) domain_requests = self.admin.domain_requests(self.portfolio) - self.assertIn("request1.gov", domain_requests) - self.assertIn("request2.gov", domain_requests) - self.assertIn('
    ', domain_requests) + self.assertIn("2 domain requests", domain_requests) diff --git a/src/registrar/tests/test_admin_domain.py b/src/registrar/tests/test_admin_domain.py index e156dd377..385a00800 100644 --- a/src/registrar/tests/test_admin_domain.py +++ b/src/registrar/tests/test_admin_domain.py @@ -14,7 +14,9 @@ from registrar.models import ( DomainInformation, User, Host, + Portfolio, ) +from registrar.models.user_domain_role import UserDomainRole from .common import ( MockSESClient, completed_domain_request, @@ -356,9 +358,11 @@ class TestDomainAdminWithClient(TestCase): def tearDown(self): super().tearDown() Host.objects.all().delete() + UserDomainRole.objects.all().delete() Domain.objects.all().delete() DomainInformation.objects.all().delete() DomainRequest.objects.all().delete() + Portfolio.objects.all().delete() @classmethod def tearDownClass(self): @@ -452,6 +456,36 @@ class TestDomainAdminWithClient(TestCase): domain_request.delete() _creator.delete() + @less_console_noise_decorator + def test_domains_by_portfolio(self): + """ + Tests that domains display for a portfolio. And that domains outside the portfolio do not display. + """ + + portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test Portfolio", creator=self.superuser) + # Create a fake domain request and domain + _domain_request = completed_domain_request( + status=DomainRequest.DomainRequestStatus.IN_REVIEW, portfolio=portfolio + ) + _domain_request.approve() + + domain = _domain_request.approved_domain + domain2, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY) + UserDomainRole.objects.get_or_create() + UserDomainRole.objects.get_or_create(user=self.superuser, domain=domain2, role=UserDomainRole.Roles.MANAGER) + + self.client.force_login(self.superuser) + response = self.client.get( + "/admin/registrar/domain/?portfolio={}".format(portfolio.pk), + follow=True, + ) + + # Make sure the page loaded, and that we're on the right page + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain.name) + self.assertNotContains(response, domain2.name) + self.assertContains(response, portfolio.organization_name) + @less_console_noise_decorator def test_helper_text(self): """ diff --git a/src/registrar/tests/test_admin_request.py b/src/registrar/tests/test_admin_request.py index c4fc8bcee..b1169a9ef 100644 --- a/src/registrar/tests/test_admin_request.py +++ b/src/registrar/tests/test_admin_request.py @@ -22,6 +22,7 @@ from registrar.models import ( Contact, Website, SeniorOfficial, + Portfolio, ) from .common import ( MockSESClient, @@ -78,6 +79,7 @@ class TestDomainRequestAdmin(MockEppLib): Contact.objects.all().delete() Website.objects.all().delete() SeniorOfficial.objects.all().delete() + Portfolio.objects.all().delete() self.mock_client.EMAILS_SENT.clear() @classmethod @@ -263,6 +265,33 @@ class TestDomainRequestAdmin(MockEppLib): self.assertContains(response, domain_request.requested_domain.name) self.assertContains(response, "Show details") + @less_console_noise_decorator + def test_domain_requests_by_portfolio(self): + """ + Tests that domain_requests display for a portfolio. And requests not in portfolio do not display. + """ + + portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test Portfolio", creator=self.superuser) + # Create a fake domain request and domain + domain_request = completed_domain_request( + status=DomainRequest.DomainRequestStatus.IN_REVIEW, portfolio=portfolio + ) + domain_request2 = completed_domain_request( + name="testdomain2.gov", status=DomainRequest.DomainRequestStatus.IN_REVIEW + ) + + self.client.force_login(self.superuser) + response = self.client.get( + "/admin/registrar/domainrequest/?portfolio={}".format(portfolio.pk), + follow=True, + ) + + # Make sure the page loaded, and that we're on the right page + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain_request.requested_domain.name) + self.assertNotContains(response, domain_request2.requested_domain.name) + self.assertContains(response, portfolio.organization_name) + @less_console_noise_decorator def test_analyst_can_see_and_edit_alternative_domain(self): """Tests if an analyst can still see and edit the alternative domain field""" @@ -422,7 +451,7 @@ class TestDomainRequestAdmin(MockEppLib): # Assert that our sort works correctly self.test_helper.assert_table_sorted( - "11", + "13", ( "submitter__first_name", "submitter__last_name", @@ -431,7 +460,7 @@ class TestDomainRequestAdmin(MockEppLib): # Assert that sorting in reverse works correctly self.test_helper.assert_table_sorted( - "-11", + "-13", ( "-submitter__first_name", "-submitter__last_name", @@ -454,7 +483,7 @@ class TestDomainRequestAdmin(MockEppLib): # Assert that our sort works correctly self.test_helper.assert_table_sorted( - "12", + "14", ( "investigator__first_name", "investigator__last_name", @@ -463,7 +492,7 @@ class TestDomainRequestAdmin(MockEppLib): # Assert that sorting in reverse works correctly self.test_helper.assert_table_sorted( - "-12", + "-14", ( "-investigator__first_name", "-investigator__last_name", @@ -476,7 +505,7 @@ class TestDomainRequestAdmin(MockEppLib): @less_console_noise_decorator def test_default_sorting_in_domain_requests_list(self): """ - Make sure the default sortin in on the domain requests list page is reverse submission_date + Make sure the default sortin in on the domain requests list page is reverse last_submitted_date then alphabetical requested_domain """ @@ -486,12 +515,12 @@ class TestDomainRequestAdmin(MockEppLib): for name in ["ccc.gov", "bbb.gov", "eee.gov", "aaa.gov", "zzz.gov", "ddd.gov"] ] - domain_requests[0].submission_date = timezone.make_aware(datetime(2024, 10, 16)) - domain_requests[1].submission_date = timezone.make_aware(datetime(2001, 10, 16)) - domain_requests[2].submission_date = timezone.make_aware(datetime(1980, 10, 16)) - domain_requests[3].submission_date = timezone.make_aware(datetime(1998, 10, 16)) - domain_requests[4].submission_date = timezone.make_aware(datetime(2013, 10, 16)) - domain_requests[5].submission_date = timezone.make_aware(datetime(1980, 10, 16)) + domain_requests[0].last_submitted_date = timezone.make_aware(datetime(2024, 10, 16)) + domain_requests[1].last_submitted_date = timezone.make_aware(datetime(2001, 10, 16)) + domain_requests[2].last_submitted_date = timezone.make_aware(datetime(1980, 10, 16)) + domain_requests[3].last_submitted_date = timezone.make_aware(datetime(1998, 10, 16)) + domain_requests[4].last_submitted_date = timezone.make_aware(datetime(2013, 10, 16)) + domain_requests[5].last_submitted_date = timezone.make_aware(datetime(1980, 10, 16)) # Save the modified domain requests to update their attributes in the database for domain_request in domain_requests: @@ -1556,7 +1585,9 @@ class TestDomainRequestAdmin(MockEppLib): "cisa_representative_last_name", "has_cisa_representative", "is_policy_acknowledged", - "submission_date", + "first_submitted_date", + "last_submitted_date", + "last_status_update", "notes", "alternative_domains", ] diff --git a/src/registrar/tests/test_api.py b/src/registrar/tests/test_api.py index f6a14aad9..2597e65c2 100644 --- a/src/registrar/tests/test_api.py +++ b/src/registrar/tests/test_api.py @@ -4,6 +4,9 @@ from registrar.models import FederalAgency, SeniorOfficial, User from django.contrib.auth import get_user_model from registrar.tests.common import create_superuser, create_user +from api.tests.common import less_console_noise_decorator +from registrar.utility.constants import BranchChoices + class GetSeniorOfficialJsonTest(TestCase): def setUp(self): @@ -26,6 +29,7 @@ class GetSeniorOfficialJsonTest(TestCase): SeniorOfficial.objects.all().delete() FederalAgency.objects.all().delete() + @less_console_noise_decorator def test_get_senior_official_json_authenticated_superuser(self): """Test that a superuser can fetch the senior official information.""" p = "adminpass" @@ -38,6 +42,7 @@ class GetSeniorOfficialJsonTest(TestCase): self.assertEqual(data["last_name"], "Doe") self.assertEqual(data["title"], "Director") + @less_console_noise_decorator def test_get_senior_official_json_authenticated_analyst(self): """Test that an analyst user can fetch the senior official's information.""" p = "userpass" @@ -50,6 +55,7 @@ class GetSeniorOfficialJsonTest(TestCase): self.assertEqual(data["last_name"], "Doe") self.assertEqual(data["title"], "Director") + @less_console_noise_decorator def test_get_senior_official_json_unauthenticated(self): """Test that an unauthenticated user receives a 403 with an error message.""" p = "password" @@ -57,6 +63,7 @@ class GetSeniorOfficialJsonTest(TestCase): response = self.client.get(self.api_url, {"agency_name": "Test Agency"}) self.assertEqual(response.status_code, 302) + @less_console_noise_decorator def test_get_senior_official_json_not_found(self): """Test that a request for a non-existent agency returns a 404 with an error message.""" p = "adminpass" @@ -64,4 +71,41 @@ class GetSeniorOfficialJsonTest(TestCase): response = self.client.get(self.api_url, {"agency_name": "Non-Federal Agency"}) self.assertEqual(response.status_code, 404) data = response.json() - self.assertEqual(data["error"], "Senior official not found") + self.assertEqual(data["error"], "Senior Official not found") + + +class GetFederalPortfolioTypeJsonTest(TestCase): + def setUp(self): + self.client = Client() + p = "password" + self.user = get_user_model().objects.create_user(username="testuser", password=p) + + self.superuser = create_superuser() + self.analyst_user = create_user() + + self.agency = FederalAgency.objects.create(agency="Test Agency", federal_type=BranchChoices.JUDICIAL) + + self.api_url = reverse("get-federal-and-portfolio-types-from-federal-agency-json") + + def tearDown(self): + User.objects.all().delete() + FederalAgency.objects.all().delete() + + @less_console_noise_decorator + def test_get_federal_and_portfolio_types_json_authenticated_superuser(self): + """Test that a superuser can fetch the federal and portfolio types.""" + p = "adminpass" + self.client.login(username="superuser", password=p) + response = self.client.get(self.api_url, {"agency_name": "Test Agency", "organization_type": "federal"}) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["federal_type"], "Judicial") + self.assertEqual(data["portfolio_type"], "Federal - Judicial") + + @less_console_noise_decorator + def test_get_federal_and_portfolio_types_json_authenticated_regularuser(self): + """Test that a regular user receives a 403 with an error message.""" + p = "password" + self.client.login(username="testuser", password=p) + response = self.client.get(self.api_url, {"agency_name": "Test Agency", "organization_type": "federal"}) + self.assertEqual(response.status_code, 302) diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index f4e998fff..9f55fced1 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -17,6 +17,7 @@ from registrar.models import ( DomainInvitation, UserDomainRole, FederalAgency, + UserPortfolioPermission, ) import boto3_mocking @@ -1142,19 +1143,24 @@ class TestPortfolioInvitations(TestCase): def tearDown(self): super().tearDown() + UserPortfolioPermission.objects.all().delete() Portfolio.objects.all().delete() PortfolioInvitation.objects.all().delete() User.objects.all().delete() @less_console_noise_decorator def test_retrieval(self): - self.assertFalse(self.user.portfolio) + portfolio_role_exists = UserPortfolioPermission.objects.filter( + user=self.user, portfolio=self.portfolio + ).exists() + self.assertFalse(portfolio_role_exists) self.invitation.retrieve() self.user.refresh_from_db() - self.assertEqual(self.user.portfolio.organization_name, "Hotel California") - self.assertEqual(self.user.portfolio_roles, [self.portfolio_role_base, self.portfolio_role_admin]) + created_role = UserPortfolioPermission.objects.get(user=self.user, portfolio=self.portfolio) + self.assertEqual(created_role.portfolio.organization_name, "Hotel California") + self.assertEqual(created_role.roles, [self.portfolio_role_base, self.portfolio_role_admin]) self.assertEqual( - self.user.portfolio_additional_permissions, [self.portfolio_permission_1, self.portfolio_permission_2] + created_role.additional_permissions, [self.portfolio_permission_1, self.portfolio_permission_2] ) self.assertEqual(self.invitation.status, PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED) @@ -1167,16 +1173,129 @@ class TestPortfolioInvitations(TestCase): @less_console_noise_decorator def test_retrieve_user_already_member_error(self): - self.assertFalse(self.user.portfolio) - portfolio2, _ = Portfolio.objects.get_or_create(creator=self.user2, organization_name="Tokyo Hotel") - self.user.portfolio = portfolio2 - self.assertEqual(self.user.portfolio.organization_name, "Tokyo Hotel") - self.user.save() + portfolio_role_exists = UserPortfolioPermission.objects.filter( + user=self.user, portfolio=self.portfolio + ).exists() + self.assertFalse(portfolio_role_exists) + portfolio_role, _ = UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio) + self.assertEqual(portfolio_role.portfolio.organization_name, "Hotel California") self.user.check_portfolio_invitations_on_login() self.user.refresh_from_db() - self.assertEqual(self.user.portfolio.organization_name, "Tokyo Hotel") + + roles = UserPortfolioPermission.objects.filter(user=self.user) + self.assertEqual(len(roles), 1) self.assertEqual(self.invitation.status, PortfolioInvitation.PortfolioInvitationStatus.INVITED) + @less_console_noise_decorator + def test_retrieve_user_multiple_invitations(self): + """Retrieve user portfolio invitations when there are multiple and multiple_options flag true.""" + # create a 2nd portfolio and a 2nd portfolio invitation to self.user + portfolio2, _ = Portfolio.objects.get_or_create(creator=self.user2, organization_name="Take It Easy") + PortfolioInvitation.objects.get_or_create( + email=self.email, + portfolio=portfolio2, + portfolio_roles=[self.portfolio_role_base, self.portfolio_role_admin], + portfolio_additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2], + ) + with override_flag("multiple_portfolios", active=True): + self.user.check_portfolio_invitations_on_login() + self.user.refresh_from_db() + roles = UserPortfolioPermission.objects.filter(user=self.user) + self.assertEqual(len(roles), 2) + updated_invitation1, _ = PortfolioInvitation.objects.get_or_create( + email=self.email, portfolio=self.portfolio + ) + self.assertEqual(updated_invitation1.status, PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED) + updated_invitation2, _ = PortfolioInvitation.objects.get_or_create(email=self.email, portfolio=portfolio2) + self.assertEqual(updated_invitation2.status, PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED) + + @less_console_noise_decorator + def test_retrieve_user_multiple_invitations_when_multiple_portfolios_inactive(self): + """Attempt to retrieve user portfolio invitations when there are multiple + but multiple_portfolios flag set to False""" + # create a 2nd portfolio and a 2nd portfolio invitation to self.user + portfolio2, _ = Portfolio.objects.get_or_create(creator=self.user2, organization_name="Take It Easy") + PortfolioInvitation.objects.get_or_create( + email=self.email, + portfolio=portfolio2, + portfolio_roles=[self.portfolio_role_base, self.portfolio_role_admin], + portfolio_additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2], + ) + self.user.check_portfolio_invitations_on_login() + self.user.refresh_from_db() + roles = UserPortfolioPermission.objects.filter(user=self.user) + self.assertEqual(len(roles), 1) + updated_invitation1, _ = PortfolioInvitation.objects.get_or_create(email=self.email, portfolio=self.portfolio) + self.assertEqual(updated_invitation1.status, PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED) + updated_invitation2, _ = PortfolioInvitation.objects.get_or_create(email=self.email, portfolio=portfolio2) + self.assertEqual(updated_invitation2.status, PortfolioInvitation.PortfolioInvitationStatus.INVITED) + + +class TestUserPortfolioPermission(TestCase): + @less_console_noise_decorator + def setUp(self): + self.user, _ = User.objects.get_or_create(email="mayor@igorville.gov") + super().setUp() + + def tearDown(self): + super().tearDown() + Domain.objects.all().delete() + DomainInformation.objects.all().delete() + DomainRequest.objects.all().delete() + UserPortfolioPermission.objects.all().delete() + Portfolio.objects.all().delete() + User.objects.all().delete() + UserDomainRole.objects.all().delete() + + @less_console_noise_decorator + @override_flag("multiple_portfolios", active=True) + def test_clean_on_multiple_portfolios_when_flag_active(self): + """Ensures that a user can create multiple portfolio permission objects when the flag is enabled""" + # Create an instance of User with a portfolio but no roles or additional permissions + portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California") + portfolio_2, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Motel California") + portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( + portfolio=portfolio, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + portfolio_permission_2 = UserPortfolioPermission( + portfolio=portfolio_2, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + + # Clean should pass on both of these objects + try: + portfolio_permission.clean() + portfolio_permission_2.clean() + except ValidationError as error: + self.fail(f"Raised ValidationError unexpectedly: {error}") + + @less_console_noise_decorator + @override_flag("multiple_portfolios", active=False) + def test_clean_on_creates_multiple_portfolios(self): + """Ensures that a user cannot create multiple portfolio permission objects when the flag is disabled""" + # Create an instance of User with a portfolio but no roles or additional permissions + portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California") + portfolio_2, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Motel California") + portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( + portfolio=portfolio, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + portfolio_permission_2 = UserPortfolioPermission( + portfolio=portfolio_2, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + + # This should work as intended + portfolio_permission.clean() + + # Test if the ValidationError is raised with the correct message + with self.assertRaises(ValidationError) as cm: + portfolio_permission_2.clean() + + portfolio_permission_2, _ = UserPortfolioPermission.objects.get_or_create(portfolio=portfolio, user=self.user) + + self.assertEqual( + cm.exception.message, + "Only one portfolio permission is allowed per user when multiple portfolios are disabled.", + ) + class TestUser(TestCase): """Test actions that occur on user login, @@ -1188,6 +1307,7 @@ class TestUser(TestCase): self.domain_name = "igorvilleInTransition.gov" self.domain, _ = Domain.objects.get_or_create(name="igorville.gov") self.user, _ = User.objects.get_or_create(email=self.email) + self.factory = RequestFactory() def tearDown(self): super().tearDown() @@ -1197,6 +1317,7 @@ class TestUser(TestCase): DomainRequest.objects.all().delete() DraftDomain.objects.all().delete() TransitionDomain.objects.all().delete() + UserPortfolioPermission.objects.all().delete() Portfolio.objects.all().delete() User.objects.all().delete() UserDomainRole.objects.all().delete() @@ -1359,44 +1480,41 @@ class TestUser(TestCase): Note: This tests _get_portfolio_permissions as a side effect """ + portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California") - self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS] - self.user.save() - self.user.refresh_from_db() - - user_can_view_all_domains = self.user.has_domains_portfolio_permission() - user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission() + user_can_view_all_domains = self.user.has_domains_portfolio_permission(portfolio) + user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission(portfolio) self.assertFalse(user_can_view_all_domains) self.assertFalse(user_can_view_all_requests) - self.user.portfolio = portfolio - self.user.save() - self.user.refresh_from_db() + portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( + portfolio=portfolio, + user=self.user, + additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS], + ) - user_can_view_all_domains = self.user.has_domains_portfolio_permission() - user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission() + user_can_view_all_domains = self.user.has_domains_portfolio_permission(portfolio) + user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission(portfolio) self.assertTrue(user_can_view_all_domains) self.assertFalse(user_can_view_all_requests) - self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN] - self.user.save() - self.user.refresh_from_db() + portfolio_permission.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + portfolio_permission.save() + portfolio_permission.refresh_from_db() - user_can_view_all_domains = self.user.has_domains_portfolio_permission() - user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission() + user_can_view_all_domains = self.user.has_domains_portfolio_permission(portfolio) + user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission(portfolio) self.assertTrue(user_can_view_all_domains) self.assertTrue(user_can_view_all_requests) - UserDomainRole.objects.all().get_or_create( - user=self.user, domain=self.domain, role=UserDomainRole.Roles.MANAGER - ) + UserDomainRole.objects.get_or_create(user=self.user, domain=self.domain, role=UserDomainRole.Roles.MANAGER) - user_can_view_all_domains = self.user.has_domains_portfolio_permission() - user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission() + user_can_view_all_domains = self.user.has_domains_portfolio_permission(portfolio) + user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission(portfolio) self.assertTrue(user_can_view_all_domains) self.assertTrue(user_can_view_all_requests) @@ -1407,13 +1525,15 @@ class TestUser(TestCase): def test_user_with_portfolio_but_no_roles(self): # Create an instance of User with a portfolio but no roles or additional permissions portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California") + portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(portfolio=portfolio, user=self.user) - self.user.portfolio = portfolio - self.user.portfolio_roles = [] + # Try to remove the role + portfolio_permission.portfolio = portfolio + portfolio_permission.roles = [] # Test if the ValidationError is raised with the correct message with self.assertRaises(ValidationError) as cm: - self.user.clean() + portfolio_permission.clean() self.assertEqual( cm.exception.message, "When portfolio is assigned, portfolio roles or additional permissions are required." @@ -1422,13 +1542,18 @@ class TestUser(TestCase): @less_console_noise_decorator def test_user_with_portfolio_roles_but_no_portfolio(self): - # Create an instance of User with a portfolio role but no portfolio - self.user.portfolio = None - self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California") + portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( + portfolio=portfolio, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + + # Try to remove the portfolio + portfolio_permission.portfolio = None + portfolio_permission.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN] # Test if the ValidationError is raised with the correct message with self.assertRaises(ValidationError) as cm: - self.user.clean() + portfolio_permission.clean() self.assertEqual( cm.exception.message, "When portfolio roles or additional permissions are assigned, portfolio is required." diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py index 52aaa8c38..ed2b75791 100644 --- a/src/registrar/tests/test_reports.py +++ b/src/registrar/tests/test_reports.py @@ -7,6 +7,7 @@ from registrar.models import ( UserDomainRole, ) from registrar.models import Portfolio +from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices from registrar.utility.csv_export import ( DomainDataFull, @@ -33,7 +34,14 @@ import boto3_mocking from registrar.utility.s3_bucket import S3ClientError, S3ClientErrorCodes # type: ignore from django.utils import timezone from api.tests.common import less_console_noise_decorator -from .common import MockDbForSharedTests, MockDbForIndividualTests, MockEppLib, less_console_noise, get_time_aware_date +from .common import ( + MockDbForSharedTests, + MockDbForIndividualTests, + MockEppLib, + get_wsgi_request_object, + less_console_noise, + get_time_aware_date, +) from waffle.testutils import override_flag @@ -281,10 +289,8 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # Create a user and associate it with some domains UserDomainRole.objects.create(user=self.user, domain=self.domain_2) - # Create a request object - factory = RequestFactory() - request = factory.get("/") - request.user = self.user + # Make a GET request using self.client to get a request object + request = get_wsgi_request_object(client=self.client, user=self.user) # Create a CSV file in memory csv_file = StringIO() @@ -321,8 +327,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # Create a portfolio and assign it to the user portfolio = Portfolio.objects.create(creator=self.user, organization_name="Test Portfolio") - self.user.portfolio = portfolio - self.user.save() + portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(portfolio=portfolio, user=self.user) UserDomainRole.objects.create(user=self.user, domain=self.domain_2) UserDomainRole.objects.filter(user=self.user, domain=self.domain_1).delete() @@ -336,14 +341,12 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): self.domain_3.domain_info.save() # Set up user permissions - self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN] - self.user.save() - self.user.refresh_from_db() + portfolio_permission.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + portfolio_permission.save() + portfolio_permission.refresh_from_db() - # Create a request object - factory = RequestFactory() - request = factory.get("/") - request.user = self.user + # Make a GET request using self.client to get a request object + request = get_wsgi_request_object(client=self.client, user=self.user) # Get the csv content csv_content = self._run_domain_data_type_user_export(request) @@ -354,19 +357,22 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): self.assertNotIn(self.domain_2.name, csv_content) # Test the output for readonly admin - self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY] - self.user.save() + portfolio_permission.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY] + portfolio_permission.save() + portfolio_permission.refresh_from_db() + # Get the csv content + csv_content = self._run_domain_data_type_user_export(request) self.assertIn(self.domain_1.name, csv_content) self.assertIn(self.domain_3.name, csv_content) self.assertNotIn(self.domain_2.name, csv_content) + portfolio_permission.roles = [UserPortfolioRoleChoices.ORGANIZATION_MEMBER] + portfolio_permission.save() + portfolio_permission.refresh_from_db() + # Get the csv content - self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_MEMBER] - self.user.save() - csv_content = self._run_domain_data_type_user_export(request) - self.assertNotIn(self.domain_1.name, csv_content) self.assertNotIn(self.domain_3.name, csv_content) self.assertIn(self.domain_2.name, csv_content) @@ -762,7 +768,7 @@ class HelperFunctions(MockDbForSharedTests): with less_console_noise(): filter_condition = { "status": DomainRequest.DomainRequestStatus.SUBMITTED, - "submission_date__lte": self.end_date, + "last_submitted_date__lte": self.end_date, } submitted_requests_sliced_at_end_date = DomainRequestExport.get_sliced_requests(filter_condition) expected_content = [3, 2, 0, 0, 0, 0, 1, 0, 0, 1] diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py index fc99adae9..f5c803ac2 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -6,7 +6,7 @@ from django.urls import reverse from django.contrib.auth import get_user_model from waffle.testutils import override_flag from api.tests.common import less_console_noise_decorator -from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices +from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices from .common import MockEppLib, MockSESClient, create_user # type: ignore from django_webtest import WebTest # type: ignore import boto3_mocking # type: ignore @@ -36,6 +36,7 @@ from registrar.models import ( FederalAgency, Portfolio, Suborganization, + UserPortfolioPermission, ) from datetime import date, datetime, timedelta from django.utils import timezone @@ -316,6 +317,7 @@ class TestDomainDetail(TestDomainOverview): self.assertContains(detail_page, "Domain missing domain information") @less_console_noise_decorator + @override_flag("organization_feature", active=True) def test_domain_readonly_on_detail_page(self): """Test that a domain, which is part of a portfolio, but for which the user is not a domain manager, properly displays read only""" @@ -328,11 +330,14 @@ class TestDomainDetail(TestDomainOverview): email="bogus@example.gov", phone="8003111234", title="test title", - portfolio=portfolio, - portfolio_roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], ) domain, _ = Domain.objects.get_or_create(name="bogusdomain.gov") DomainInformation.objects.get_or_create(creator=user, domain=domain, portfolio=portfolio) + + UserPortfolioPermission.objects.get_or_create( + user=user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + user.refresh_from_db() self.client.force_login(user) detail_page = self.client.get(f"/domain/{domain.id}") # Check that alert message displays properly @@ -1477,10 +1482,9 @@ class TestDomainSuborganization(TestDomainOverview): self.domain_information.refresh_from_db() # Add portfolio perms to the user object - self.user.portfolio = portfolio - self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN] - self.user.save() - self.user.refresh_from_db() + portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( + user=self.user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) self.assertEqual(self.domain_information.sub_organization, suborg) @@ -1536,10 +1540,9 @@ class TestDomainSuborganization(TestDomainOverview): self.domain_information.refresh_from_db() # Add portfolio perms to the user object - self.user.portfolio = portfolio - self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY] - self.user.save() - self.user.refresh_from_db() + portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( + user=self.user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY] + ) self.assertEqual(self.domain_information.sub_organization, suborg) @@ -1577,9 +1580,9 @@ class TestDomainSuborganization(TestDomainOverview): self.domain_information.refresh_from_db() # Add portfolio perms to the user object - self.user.portfolio = portfolio - self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO] - self.user.save() + UserPortfolioPermission.objects.get_or_create( + user=self.user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) self.user.refresh_from_db() # Navigate to the domain overview page diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index 38f6bf4fa..c5d1a9830 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -10,9 +10,11 @@ from registrar.models import ( UserDomainRole, User, ) +from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from .common import create_test_user from waffle.testutils import override_flag +from django.contrib.sessions.middleware import SessionMiddleware import logging @@ -30,6 +32,7 @@ class TestPortfolio(WebTest): ) def tearDown(self): + UserPortfolioPermission.objects.all().delete() Portfolio.objects.all().delete() UserDomainRole.objects.all().delete() DomainRequest.objects.all().delete() @@ -52,10 +55,11 @@ class TestPortfolio(WebTest): self.portfolio.save() self.portfolio.refresh_from_db() - self.user.portfolio = self.portfolio - self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO] - self.user.save() - self.user.refresh_from_db() + portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + additional_permissions=[UserPortfolioPermissionChoices.VIEW_PORTFOLIO], + ) so_portfolio_page = self.app.get(reverse("senior-official")) # Assert that we're on the right page @@ -72,6 +76,9 @@ class TestPortfolio(WebTest): def test_middleware_does_not_redirect_if_no_permission(self): """Test that user with no portfolio permission is not redirected when attempting to access home""" self.app.set_user(self.user.username) + portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( + user=self.user, portfolio=self.portfolio, additional_permissions=[] + ) self.user.portfolio = self.portfolio self.user.save() self.user.refresh_from_db() @@ -86,9 +93,6 @@ class TestPortfolio(WebTest): def test_middleware_does_not_redirect_if_no_portfolio(self): """Test that user with no assigned portfolio is not redirected when attempting to access home""" self.app.set_user(self.user.username) - self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO] - self.user.save() - self.user.refresh_from_db() with override_flag("organization_feature", active=True): # This will redirect the user to the portfolio page. # Follow implicity checks if our redirect is working. @@ -100,10 +104,11 @@ class TestPortfolio(WebTest): def test_middleware_redirects_to_portfolio_no_domains_page(self): """Test that user with a portfolio and VIEW_PORTFOLIO is redirected to the no domains page""" self.app.set_user(self.user.username) - self.user.portfolio = self.portfolio - self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO] - self.user.save() - self.user.refresh_from_db() + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + additional_permissions=[UserPortfolioPermissionChoices.VIEW_PORTFOLIO], + ) with override_flag("organization_feature", active=True): # This will redirect the user to the portfolio page. # Follow implicity checks if our redirect is working. @@ -118,13 +123,14 @@ class TestPortfolio(WebTest): """Test that user with a portfolio, VIEW_PORTFOLIO, VIEW_ALL_DOMAINS is redirected to portfolio domains page""" self.app.set_user(self.user.username) - self.user.portfolio = self.portfolio - self.user.portfolio_additional_permissions = [ - UserPortfolioPermissionChoices.VIEW_PORTFOLIO, - UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, - ] - self.user.save() - self.user.refresh_from_db() + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, + ], + ) with override_flag("organization_feature", active=True): # This will redirect the user to the portfolio page. # Follow implicity checks if our redirect is working. @@ -138,9 +144,9 @@ class TestPortfolio(WebTest): def test_portfolio_domains_page_403_when_user_not_have_permission(self): """Test that user without proper permission is denied access to portfolio domain view""" self.app.set_user(self.user.username) - self.user.portfolio = self.portfolio - self.user.save() - self.user.refresh_from_db() + UserPortfolioPermission.objects.get_or_create( + user=self.user, portfolio=self.portfolio, additional_permissions=[] + ) with override_flag("organization_feature", active=True): # This will redirect the user to the portfolio page. # Follow implicity checks if our redirect is working. @@ -152,9 +158,9 @@ class TestPortfolio(WebTest): def test_portfolio_domain_requests_page_403_when_user_not_have_permission(self): """Test that user without proper permission is denied access to portfolio domain view""" self.app.set_user(self.user.username) - self.user.portfolio = self.portfolio - self.user.save() - self.user.refresh_from_db() + UserPortfolioPermission.objects.get_or_create( + user=self.user, portfolio=self.portfolio, additional_permissions=[] + ) with override_flag("organization_feature", active=True): # This will redirect the user to the portfolio page. # Follow implicity checks if our redirect is working. @@ -166,9 +172,9 @@ class TestPortfolio(WebTest): def test_portfolio_organization_page_403_when_user_not_have_permission(self): """Test that user without proper permission is not allowed access to portfolio organization page""" self.app.set_user(self.user.username) - self.user.portfolio = self.portfolio - self.user.save() - self.user.refresh_from_db() + portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( + user=self.user, portfolio=self.portfolio, additional_permissions=[] + ) with override_flag("organization_feature", active=True): # This will redirect the user to the portfolio page. # Follow implicity checks if our redirect is working. @@ -180,12 +186,13 @@ class TestPortfolio(WebTest): def test_portfolio_organization_page_read_only(self): """Test that user with a portfolio can access the portfolio organization page, read only""" self.app.set_user(self.user.username) - self.user.portfolio = self.portfolio + portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + additional_permissions=[UserPortfolioPermissionChoices.VIEW_PORTFOLIO], + ) self.portfolio.city = "Los Angeles" - self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO] self.portfolio.save() - self.user.save() - self.user.refresh_from_db() with override_flag("organization_feature", active=True): response = self.app.get(reverse("organization")) # Assert the response is a 200 @@ -201,15 +208,16 @@ class TestPortfolio(WebTest): def test_portfolio_organization_page_edit_access(self): """Test that user with a portfolio can access the portfolio organization page, read only""" self.app.set_user(self.user.username) - self.user.portfolio = self.portfolio - self.user.portfolio_additional_permissions = [ - UserPortfolioPermissionChoices.VIEW_PORTFOLIO, - UserPortfolioPermissionChoices.EDIT_PORTFOLIO, - ] + portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + UserPortfolioPermissionChoices.EDIT_PORTFOLIO, + ], + ) self.portfolio.city = "Los Angeles" self.portfolio.save() - self.user.save() - self.user.refresh_from_db() with override_flag("organization_feature", active=True): response = self.app.get(reverse("organization")) # Assert the response is a 200 @@ -225,14 +233,14 @@ class TestPortfolio(WebTest): def test_accessible_pages_when_user_does_not_have_permission(self): """Tests which pages are accessible when user does not have portfolio permissions""" self.app.set_user(self.user.username) - self.user.portfolio = self.portfolio - self.user.portfolio_additional_permissions = [ + portfolio_additional_permissions = [ UserPortfolioPermissionChoices.VIEW_PORTFOLIO, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, ] - self.user.save() - self.user.refresh_from_db() + portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( + user=self.user, portfolio=self.portfolio, additional_permissions=portfolio_additional_permissions + ) with override_flag("organization_feature", active=True): # This will redirect the user to the portfolio page. # Follow implicity checks if our redirect is working. @@ -246,9 +254,9 @@ class TestPortfolio(WebTest): # removing non-basic portfolio perms, which should remove domains # and domain requests from nav - self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO] - self.user.save() - self.user.refresh_from_db() + portfolio_permission.additional_permissions = [UserPortfolioPermissionChoices.VIEW_PORTFOLIO] + portfolio_permission.save() + portfolio_permission.refresh_from_db() # Members should be redirected to the readonly domains page portfolio_page = self.app.get(reverse("home")).follow() @@ -275,10 +283,10 @@ class TestPortfolio(WebTest): def test_accessible_pages_when_user_does_not_have_role(self): """Test that admin / memmber roles are associated with the right access""" self.app.set_user(self.user.username) - self.user.portfolio = self.portfolio - self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN] - self.user.save() - self.user.refresh_from_db() + portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( + user=self.user, portfolio=self.portfolio, roles=portfolio_roles + ) with override_flag("organization_feature", active=True): # This will redirect the user to the portfolio page. # Follow implicity checks if our redirect is working. @@ -292,9 +300,9 @@ class TestPortfolio(WebTest): # removing non-basic portfolio role, which should remove domains # and domain requests from nav - self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_MEMBER] - self.user.save() - self.user.refresh_from_db() + portfolio_permission.roles = [UserPortfolioRoleChoices.ORGANIZATION_MEMBER] + portfolio_permission.save() + portfolio_permission.refresh_from_db() # Members should be redirected to the readonly domains page portfolio_page = self.app.get(reverse("home")).follow() @@ -322,14 +330,13 @@ class TestPortfolio(WebTest): """Can load portfolio's org name page.""" with override_flag("organization_feature", active=True): self.app.set_user(self.user.username) - self.user.portfolio = self.portfolio - self.user.portfolio_additional_permissions = [ + portfolio_additional_permissions = [ UserPortfolioPermissionChoices.VIEW_PORTFOLIO, UserPortfolioPermissionChoices.EDIT_PORTFOLIO, ] - self.user.save() - self.user.refresh_from_db() - + portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( + user=self.user, portfolio=self.portfolio, additional_permissions=portfolio_additional_permissions + ) page = self.app.get(reverse("organization")) self.assertContains( page, "The name of your federal agency will be publicly listed as the domain registrant." @@ -340,13 +347,13 @@ class TestPortfolio(WebTest): """Org name and address information appears on the page.""" with override_flag("organization_feature", active=True): self.app.set_user(self.user.username) - self.user.portfolio = self.portfolio - self.user.portfolio_additional_permissions = [ + portfolio_additional_permissions = [ UserPortfolioPermissionChoices.VIEW_PORTFOLIO, UserPortfolioPermissionChoices.EDIT_PORTFOLIO, ] - self.user.save() - self.user.refresh_from_db() + portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( + user=self.user, portfolio=self.portfolio, additional_permissions=portfolio_additional_permissions + ) self.portfolio.organization_name = "Hotel California" self.portfolio.save() @@ -360,13 +367,13 @@ class TestPortfolio(WebTest): """Submitting changes works on the org name address page.""" with override_flag("organization_feature", active=True): self.app.set_user(self.user.username) - self.user.portfolio = self.portfolio - self.user.portfolio_additional_permissions = [ + portfolio_additional_permissions = [ UserPortfolioPermissionChoices.VIEW_PORTFOLIO, UserPortfolioPermissionChoices.EDIT_PORTFOLIO, ] - self.user.save() - self.user.refresh_from_db() + portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( + user=self.user, portfolio=self.portfolio, additional_permissions=portfolio_additional_permissions + ) self.portfolio.address_line1 = "1600 Penn Ave" self.portfolio.save() @@ -383,6 +390,103 @@ class TestPortfolio(WebTest): self.assertContains(success_result_page, "6 Downing st") self.assertContains(success_result_page, "London") + @less_console_noise_decorator + def test_portfolio_in_session_when_organization_feature_active(self): + """When organization_feature flag is true and user has a portfolio, + the portfolio should be set in session.""" + self.client.force_login(self.user) + portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles) + with override_flag("organization_feature", active=True): + response = self.client.get(reverse("home")) + # Ensure that middleware processes the session + session_middleware = SessionMiddleware(lambda request: None) + session_middleware.process_request(response.wsgi_request) + response.wsgi_request.session.save() + # Access the session via the request + session = response.wsgi_request.session + # Check if the 'portfolio' session variable exists + self.assertIn("portfolio", session, "Portfolio session variable should exist.") + # Check the value of the 'portfolio' session variable + self.assertEqual(session["portfolio"], self.portfolio, "Portfolio session variable has the wrong value.") + + @less_console_noise_decorator + def test_portfolio_in_session_is_none_when_organization_feature_inactive(self): + """When organization_feature flag is false and user has a portfolio, + the portfolio should be set to None in session. + This test also satisfies the condition when multiple_portfolios flag + is false and user has a portfolio, so won't add a redundant test for that.""" + self.client.force_login(self.user) + portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles) + response = self.client.get(reverse("home")) + # Ensure that middleware processes the session + session_middleware = SessionMiddleware(lambda request: None) + session_middleware.process_request(response.wsgi_request) + response.wsgi_request.session.save() + # Access the session via the request + session = response.wsgi_request.session + # Check if the 'portfolio' session variable exists + self.assertIn("portfolio", session, "Portfolio session variable should exist.") + # Check the value of the 'portfolio' session variable + self.assertIsNone(session["portfolio"]) + + @less_console_noise_decorator + def test_portfolio_in_session_is_none_when_organization_feature_active_and_no_portfolio(self): + """When organization_feature flag is true and user does not have a portfolio, + the portfolio should be set to None in session.""" + self.client.force_login(self.user) + with override_flag("organization_feature", active=True): + response = self.client.get(reverse("home")) + # Ensure that middleware processes the session + session_middleware = SessionMiddleware(lambda request: None) + session_middleware.process_request(response.wsgi_request) + response.wsgi_request.session.save() + # Access the session via the request + session = response.wsgi_request.session + # Check if the 'portfolio' session variable exists + self.assertIn("portfolio", session, "Portfolio session variable should exist.") + # Check the value of the 'portfolio' session variable + self.assertIsNone(session["portfolio"]) + + @less_console_noise_decorator + def test_portfolio_in_session_when_multiple_portfolios_active(self): + """When multiple_portfolios flag is true and user has a portfolio, + the portfolio should be set in session.""" + self.client.force_login(self.user) + portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles) + with override_flag("organization_feature", active=True), override_flag("multiple_portfolios", active=True): + response = self.client.get(reverse("home")) + # Ensure that middleware processes the session + session_middleware = SessionMiddleware(lambda request: None) + session_middleware.process_request(response.wsgi_request) + response.wsgi_request.session.save() + # Access the session via the request + session = response.wsgi_request.session + # Check if the 'portfolio' session variable exists + self.assertIn("portfolio", session, "Portfolio session variable should exist.") + # Check the value of the 'portfolio' session variable + self.assertEqual(session["portfolio"], self.portfolio, "Portfolio session variable has the wrong value.") + + @less_console_noise_decorator + def test_portfolio_in_session_is_none_when_multiple_portfolios_active_and_no_portfolio(self): + """When multiple_portfolios flag is true and user does not have a portfolio, + the portfolio should be set to None in session.""" + self.client.force_login(self.user) + with override_flag("multiple_portfolios", active=True): + response = self.client.get(reverse("home")) + # Ensure that middleware processes the session + session_middleware = SessionMiddleware(lambda request: None) + session_middleware.process_request(response.wsgi_request) + response.wsgi_request.session.save() + # Access the session via the request + session = response.wsgi_request.session + # Check if the 'portfolio' session variable exists + self.assertIn("portfolio", session, "Portfolio session variable should exist.") + # Check the value of the 'portfolio' session variable + self.assertIsNone(session["portfolio"]) + @less_console_noise_decorator @override_flag("organization_feature", active=True) def test_org_member_can_only_see_domains_with_appropriate_permissions(self): @@ -390,43 +494,41 @@ class TestPortfolio(WebTest): if they do not have the right permissions. """ + permission, _ = UserPortfolioPermission.objects.get_or_create( + user=self.user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER] + ) + # A default organization member should not be able to see any domains - self.app.set_user(self.user.username) - self.user.portfolio = self.portfolio - self.user.portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_MEMBER] - self.user.save() - self.user.refresh_from_db() + self.client.force_login(self.user) + response = self.client.get(reverse("home"), follow=True) - self.assertFalse(self.user.has_domains_portfolio_permission()) - - response = self.app.get(reverse("no-portfolio-domains")) + self.assertFalse(self.user.has_domains_portfolio_permission(response.wsgi_request.session.get("portfolio"))) self.assertEqual(response.status_code, 200) - self.assertContains(response, "You aren’t managing any domains.") + self.assertContains(response, "You aren") # Test the domains page - this user should not have access - response = self.app.get(reverse("domains"), expect_errors=True) + response = self.client.get(reverse("domains")) self.assertEqual(response.status_code, 403) # Ensure that this user can see domains with the right permissions - self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS] - self.user.save() - self.user.refresh_from_db() - - self.assertTrue(self.user.has_domains_portfolio_permission()) + permission.additional_permissions = [UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS] + permission.save() + permission.refresh_from_db() # Test the domains page - this user should have access - response = self.app.get(reverse("domains")) + response = self.client.get(reverse("domains")) + self.assertTrue(self.user.has_domains_portfolio_permission(response.wsgi_request.session.get("portfolio"))) self.assertEqual(response.status_code, 200) self.assertContains(response, "Domain name") # Test the managed domains permission - self.user.portfolio_additional_permissions = [UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS] - self.user.save() - self.user.refresh_from_db() - - self.assertTrue(self.user.has_domains_portfolio_permission()) + permission.additional_permissions = [UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS] + permission.save() + permission.refresh_from_db() # Test the domains page - this user should have access - response = self.app.get(reverse("domains")) + response = self.client.get(reverse("domains")) + self.assertTrue(self.user.has_domains_portfolio_permission(response.wsgi_request.session.get("portfolio"))) self.assertEqual(response.status_code, 200) self.assertContains(response, "Domain name") + permission.delete() diff --git a/src/registrar/tests/test_views_requests_json.py b/src/registrar/tests/test_views_requests_json.py index 7bdc922cf..20a4069f7 100644 --- a/src/registrar/tests/test_views_requests_json.py +++ b/src/registrar/tests/test_views_requests_json.py @@ -25,91 +25,91 @@ class GetRequestsJsonTest(TestWithUser, WebTest): DomainRequest.objects.create( creator=cls.user, requested_domain=lamb_chops, - submission_date="2024-01-01", + last_submitted_date="2024-01-01", status=DomainRequest.DomainRequestStatus.STARTED, created_at="2024-01-01", ), DomainRequest.objects.create( creator=cls.user, requested_domain=short_ribs, - submission_date="2024-02-01", + last_submitted_date="2024-02-01", status=DomainRequest.DomainRequestStatus.WITHDRAWN, created_at="2024-02-01", ), DomainRequest.objects.create( creator=cls.user, requested_domain=beef_chuck, - submission_date="2024-03-01", + last_submitted_date="2024-03-01", status=DomainRequest.DomainRequestStatus.REJECTED, created_at="2024-03-01", ), DomainRequest.objects.create( creator=cls.user, requested_domain=stew_beef, - submission_date="2024-04-01", + last_submitted_date="2024-04-01", status=DomainRequest.DomainRequestStatus.STARTED, created_at="2024-04-01", ), DomainRequest.objects.create( creator=cls.user, requested_domain=None, - submission_date="2024-05-01", + last_submitted_date="2024-05-01", status=DomainRequest.DomainRequestStatus.STARTED, created_at="2024-05-01", ), DomainRequest.objects.create( creator=cls.user, requested_domain=None, - submission_date="2024-06-01", + last_submitted_date="2024-06-01", status=DomainRequest.DomainRequestStatus.WITHDRAWN, created_at="2024-06-01", ), DomainRequest.objects.create( creator=cls.user, requested_domain=None, - submission_date="2024-07-01", + last_submitted_date="2024-07-01", status=DomainRequest.DomainRequestStatus.REJECTED, created_at="2024-07-01", ), DomainRequest.objects.create( creator=cls.user, requested_domain=None, - submission_date="2024-08-01", + last_submitted_date="2024-08-01", status=DomainRequest.DomainRequestStatus.STARTED, created_at="2024-08-01", ), DomainRequest.objects.create( creator=cls.user, requested_domain=None, - submission_date="2024-09-01", + last_submitted_date="2024-09-01", status=DomainRequest.DomainRequestStatus.STARTED, created_at="2024-09-01", ), DomainRequest.objects.create( creator=cls.user, requested_domain=None, - submission_date="2024-10-01", + last_submitted_date="2024-10-01", status=DomainRequest.DomainRequestStatus.WITHDRAWN, created_at="2024-10-01", ), DomainRequest.objects.create( creator=cls.user, requested_domain=None, - submission_date="2024-11-01", + last_submitted_date="2024-11-01", status=DomainRequest.DomainRequestStatus.REJECTED, created_at="2024-11-01", ), DomainRequest.objects.create( creator=cls.user, requested_domain=None, - submission_date="2024-11-02", + last_submitted_date="2024-11-02", status=DomainRequest.DomainRequestStatus.WITHDRAWN, created_at="2024-11-02", ), DomainRequest.objects.create( creator=cls.user, requested_domain=None, - submission_date="2024-12-01", + last_submitted_date="2024-12-01", status=DomainRequest.DomainRequestStatus.APPROVED, created_at="2024-12-01", ), @@ -138,7 +138,7 @@ class GetRequestsJsonTest(TestWithUser, WebTest): # Extract fields from response requested_domains = [request["requested_domain"] for request in data["domain_requests"]] - submission_dates = [request["submission_date"] for request in data["domain_requests"]] + last_submitted_dates = [request["last_submitted_date"] for request in data["domain_requests"]] statuses = [request["status"] for request in data["domain_requests"]] created_ats = [request["created_at"] for request in data["domain_requests"]] ids = [request["id"] for request in data["domain_requests"]] @@ -154,7 +154,7 @@ class GetRequestsJsonTest(TestWithUser, WebTest): self.domain_requests[i].requested_domain.name if self.domain_requests[i].requested_domain else None, requested_domains[i], ) - self.assertEqual(self.domain_requests[i].submission_date, submission_dates[i]) + self.assertEqual(self.domain_requests[i].last_submitted_date, last_submitted_dates[i]) self.assertEqual(self.domain_requests[i].get_status_display(), statuses[i]) self.assertEqual( parse_datetime(self.domain_requests[i].created_at.isoformat()), parse_datetime(created_ats[i]) @@ -287,26 +287,30 @@ class GetRequestsJsonTest(TestWithUser, WebTest): def test_sorting(self): """test that sorting works properly on the result set""" - response = self.app.get(reverse("get_domain_requests_json"), {"sort_by": "submission_date", "order": "desc"}) + response = self.app.get( + reverse("get_domain_requests_json"), {"sort_by": "last_submitted_date", "order": "desc"} + ) self.assertEqual(response.status_code, 200) data = response.json - # Check if sorted by submission_date in descending order - submission_dates = [req["submission_date"] for req in data["domain_requests"]] - self.assertEqual(submission_dates, sorted(submission_dates, reverse=True)) + # Check if sorted by last_submitted_date in descending order + last_submitted_dates = [req["last_submitted_date"] for req in data["domain_requests"]] + self.assertEqual(last_submitted_dates, sorted(last_submitted_dates, reverse=True)) - response = self.app.get(reverse("get_domain_requests_json"), {"sort_by": "submission_date", "order": "asc"}) + response = self.app.get(reverse("get_domain_requests_json"), {"sort_by": "last_submitted_date", "order": "asc"}) self.assertEqual(response.status_code, 200) data = response.json - # Check if sorted by submission_date in ascending order - submission_dates = [req["submission_date"] for req in data["domain_requests"]] - self.assertEqual(submission_dates, sorted(submission_dates)) + # Check if sorted by last_submitted_date in ascending order + last_submitted_dates = [req["last_submitted_date"] for req in data["domain_requests"]] + self.assertEqual(last_submitted_dates, sorted(last_submitted_dates)) def test_filter_approved_excluded(self): """test that approved requests are excluded from result set.""" # sort in reverse chronological order of submission date, since most recent request is approved - response = self.app.get(reverse("get_domain_requests_json"), {"sort_by": "submission_date", "order": "desc"}) + response = self.app.get( + reverse("get_domain_requests_json"), {"sort_by": "last_submitted_date", "order": "desc"} + ) self.assertEqual(response.status_code, 200) data = response.json diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index db961a36d..7ca3b7e97 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -1235,7 +1235,9 @@ class DomainRequestExport(BaseExport): "State/territory": model.get("state_territory"), "Request purpose": model.get("purpose"), "CISA regional representative": model.get("cisa_representative_email"), - "Submitted at": model.get("submission_date"), + "Last submitted date": model.get("last_submitted_date"), + "First submitted date": model.get("first_submitted_date"), + "Last status update": model.get("last_status_update"), } row = [FIELDS.get(column, "") for column in columns] @@ -1279,8 +1281,8 @@ class DomainRequestGrowth(DomainRequestExport): end_date_formatted = format_end_date(end_date) return Q( status=DomainRequest.DomainRequestStatus.SUBMITTED, - submission_date__lte=end_date_formatted, - submission_date__gte=start_date_formatted, + last_submitted_date__lte=end_date_formatted, + last_submitted_date__gte=start_date_formatted, ) @classmethod @@ -1304,7 +1306,9 @@ class DomainRequestDataFull(DomainRequestExport): """ return [ "Domain request", - "Submitted at", + "Last submitted date", + "First submitted date", + "Last status update", "Status", "Domain type", "Federal type", diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 72f2fd27e..003f8dd0d 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -174,10 +174,11 @@ class DomainView(DomainBaseView): """Most views should not allow permission to portfolio users. If particular views allow permissions, they will need to override this function.""" - if self.request.user.has_domains_portfolio_permission(): + portfolio = self.request.session.get("portfolio") + if self.request.user.has_domains_portfolio_permission(portfolio): if Domain.objects.filter(id=pk).exists(): domain = Domain.objects.get(id=pk) - if domain.domain_info.portfolio == self.request.user.portfolio: + if domain.domain_info.portfolio == portfolio: return True return False @@ -236,7 +237,8 @@ class DomainOrgNameAddressView(DomainFormBaseView): # Org users shouldn't have access to this page is_org_user = self.request.user.is_org_user(self.request) - if self.request.user.portfolio and is_org_user: + portfolio = self.request.session.get("portfolio") + if portfolio and is_org_user: return False else: return super().has_permission() @@ -255,7 +257,8 @@ class DomainSubOrganizationView(DomainFormBaseView): # non-org users shouldn't have access to this page is_org_user = self.request.user.is_org_user(self.request) - if self.request.user.portfolio and is_org_user: + portfolio = self.request.session.get("portfolio") + if portfolio and is_org_user: return super().has_permission() else: return False @@ -335,7 +338,8 @@ class DomainSeniorOfficialView(DomainFormBaseView): # Org users shouldn't have access to this page is_org_user = self.request.user.is_org_user(self.request) - if self.request.user.portfolio and is_org_user: + portfolio = self.request.session.get("portfolio") + if portfolio and is_org_user: return False else: return super().has_permission() diff --git a/src/registrar/views/domain_requests_json.py b/src/registrar/views/domain_requests_json.py index 2e58c8e48..6b0b346f8 100644 --- a/src/registrar/views/domain_requests_json.py +++ b/src/registrar/views/domain_requests_json.py @@ -46,7 +46,7 @@ def get_domain_requests_json(request): domain_requests_data = [ { "requested_domain": domain_request.requested_domain.name if domain_request.requested_domain else None, - "submission_date": domain_request.submission_date, + "last_submitted_date": domain_request.last_submitted_date, "status": domain_request.get_status_display(), "created_at": format(domain_request.created_at, "c"), # Serialize to ISO 8601 "id": domain_request.id, diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index 5869a19e2..0232b50d7 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -5,6 +5,7 @@ from django.urls import reverse from django.contrib import messages from registrar.forms.portfolio import PortfolioOrgAddressForm, PortfolioSeniorOfficialForm from registrar.models import Portfolio, User +from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices from registrar.views.utility.permission_views import ( PortfolioDomainRequestsPermissionView, @@ -55,14 +56,17 @@ class PortfolioNoDomainsView(NoPortfolioDomainsPermissionView, View): """Add additional context data to the template.""" # We can override the base class. This view only needs this item. context = {} - portfolio = self.request.user.portfolio if self.request and self.request.user else None + portfolio = self.request.session.get("portfolio") if portfolio: - context["portfolio_administrators"] = User.objects.filter( + admin_ids = UserPortfolioPermission.objects.filter( portfolio=portfolio, - portfolio_roles__overlap=[ + roles__overlap=[ UserPortfolioRoleChoices.ORGANIZATION_ADMIN, ], - ) + ).values_list("user__id", flat=True) + + admin_users = User.objects.filter(id__in=admin_ids) + context["portfolio_administrators"] = admin_users return context @@ -79,12 +83,13 @@ class PortfolioOrganizationView(PortfolioBasePermissionView, FormMixin): def get_context_data(self, **kwargs): """Add additional context data to the template.""" context = super().get_context_data(**kwargs) - context["has_edit_org_portfolio_permission"] = self.request.user.has_edit_org_portfolio_permission() + portfolio = self.request.session.get("portfolio") + context["has_edit_org_portfolio_permission"] = self.request.user.has_edit_org_portfolio_permission(portfolio) return context def get_object(self, queryset=None): - """Get the portfolio object based on the request user.""" - portfolio = self.request.user.portfolio + """Get the portfolio object based on the session.""" + portfolio = self.request.session.get("portfolio") if portfolio is None: raise Http404("No organization found for this user") return portfolio @@ -139,8 +144,8 @@ class PortfolioSeniorOfficialView(PortfolioBasePermissionView, FormMixin): context_object_name = "portfolio" def get_object(self, queryset=None): - """Get the portfolio object based on the request user.""" - portfolio = self.request.user.portfolio + """Get the portfolio object based on the session.""" + portfolio = self.request.session.get("portfolio") if portfolio is None: raise Http404("No organization found for this user") return portfolio diff --git a/src/registrar/views/report_views.py b/src/registrar/views/report_views.py index 428298b52..abdbd37c9 100644 --- a/src/registrar/views/report_views.py +++ b/src/registrar/views/report_views.py @@ -26,7 +26,7 @@ class AnalyticsView(View): created_at__gt=thirty_days_ago, status=models.DomainRequest.DomainRequestStatus.APPROVED ) avg_approval_time = last_30_days_approved_applications.annotate( - approval_time=F("approved_domain__created_at") - F("submission_date") + approval_time=F("approved_domain__created_at") - F("last_submitted_date") ).aggregate(Avg("approval_time"))["approval_time__avg"] # Format the timedelta to display only days if avg_approval_time is not None: @@ -104,11 +104,11 @@ class AnalyticsView(View): filter_submitted_requests_start_date = { "status": models.DomainRequest.DomainRequestStatus.SUBMITTED, - "submission_date__lte": start_date_formatted, + "last_submitted_date__lte": start_date_formatted, } filter_submitted_requests_end_date = { "status": models.DomainRequest.DomainRequestStatus.SUBMITTED, - "submission_date__lte": end_date_formatted, + "last_submitted_date__lte": end_date_formatted, } submitted_requests_sliced_at_start_date = csv_export.DomainRequestExport.get_sliced_requests( filter_submitted_requests_start_date diff --git a/src/registrar/views/utility/api_views.py b/src/registrar/views/utility/api_views.py index 1e87e12e5..97eb7e86c 100644 --- a/src/registrar/views/utility/api_views.py +++ b/src/registrar/views/utility/api_views.py @@ -5,6 +5,9 @@ from registrar.models import FederalAgency, SeniorOfficial from django.contrib.admin.views.decorators import staff_member_required from django.contrib.auth.decorators import login_required +from registrar.models.portfolio import Portfolio +from registrar.utility.constants import BranchChoices + logger = logging.getLogger(__name__) @@ -33,4 +36,35 @@ def get_senior_official_from_federal_agency_json(request): return JsonResponse(so_dict) else: - return JsonResponse({"error": "Senior official not found"}, status=404) + return JsonResponse({"error": "Senior Official not found"}, status=404) + + +@login_required +@staff_member_required +def get_federal_and_portfolio_types_from_federal_agency_json(request): + """Returns specific portfolio information as a JSON. Request must have + both agency_name and organization_type.""" + + # This API is only accessible to admins and analysts + superuser_perm = request.user.has_perm("registrar.full_access_permission") + analyst_perm = request.user.has_perm("registrar.analyst_access_permission") + if not request.user.is_authenticated or not any([analyst_perm, superuser_perm]): + return JsonResponse({"error": "You do not have access to this resource"}, status=403) + + federal_type = None + portfolio_type = None + + agency_name = request.GET.get("agency_name") + organization_type = request.GET.get("organization_type") + agency = FederalAgency.objects.filter(agency=agency_name).first() + if agency: + federal_type = Portfolio.get_federal_type(agency) + portfolio_type = Portfolio.get_portfolio_type(organization_type, federal_type) + federal_type = BranchChoices.get_branch_label(federal_type) if federal_type else "-" + + response_data = { + "portfolio_type": portfolio_type, + "federal_type": federal_type, + } + + return JsonResponse(response_data) diff --git a/src/registrar/views/utility/mixins.py b/src/registrar/views/utility/mixins.py index 16a896100..6f0745f41 100644 --- a/src/registrar/views/utility/mixins.py +++ b/src/registrar/views/utility/mixins.py @@ -419,7 +419,7 @@ class PortfolioBasePermission(PermissionsLoginMixin): if not self.request.user.is_authenticated: return False - return self.request.user.has_base_portfolio_permission() + return self.request.user.is_org_user(self.request) class PortfolioDomainsPermission(PortfolioBasePermission): @@ -432,9 +432,11 @@ class PortfolioDomainsPermission(PortfolioBasePermission): The user is in self.request.user and the portfolio can be looked up from the portfolio's primary key in self.kwargs["pk"]""" - if not self.request.user.is_authenticated: + portfolio = self.request.session.get("portfolio") + if not self.request.user.has_domains_portfolio_permission(portfolio): return False - return self.request.user.has_domains_portfolio_permission() + + return super().has_permission() class PortfolioDomainRequestsPermission(PortfolioBasePermission): @@ -447,6 +449,8 @@ class PortfolioDomainRequestsPermission(PortfolioBasePermission): The user is in self.request.user and the portfolio can be looked up from the portfolio's primary key in self.kwargs["pk"]""" - if not self.request.user.is_authenticated: + portfolio = self.request.session.get("portfolio") + if not self.request.user.has_domain_requests_portfolio_permission(portfolio): return False - return self.request.user.has_domain_requests_portfolio_permission() + + return super().has_permission()