Merge branch 'main' into es/2162-delete-submitter-v2

This commit is contained in:
Rebecca H. 2024-09-12 14:32:44 -07:00 committed by GitHub
commit 5c32a1922b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 1694 additions and 273 deletions

View file

@ -860,3 +860,55 @@ Example: `cf ssh getgov-za`
### Running locally ### Running locally
```docker-compose exec app ./manage.py populate_domain_request_dates``` ```docker-compose exec app ./manage.py populate_domain_request_dates```
## Create federal portfolio
This script takes the name of a `FederalAgency` (like 'AMTRAK') and does the following:
1. Creates the portfolio record based off of data on the federal agency object itself.
2. Creates suborganizations from existing DomainInformation records.
3. Associates the SeniorOfficial record (if it exists).
4. Adds this portfolio to DomainInformation / DomainRequests or both.
Errors:
1. ValueError: Federal agency not found in database.
2. Logged Warning: No senior official found for portfolio
3. Logged Error: No suborganizations found for portfolio.
4. Logged Warning: No new suborganizations to add.
5. Logged Warning: No valid DomainRequest records to update.
6. Logged Warning: No valid DomainInformation records to update.
### 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: Upload your csv to the desired sandbox
[Follow these steps](#use-scp-to-transfer-data-to-sandboxes) to upload the federal_cio csv to a sandbox of your choice.
#### Step 5: Running the script
```./manage.py create_federal_portfolio "{federal_agency_name}" --both```
Example (only requests): `./manage.py create_federal_portfolio "AMTRAK" --parse_requests`
### Running locally
#### Step 1: Running the script
```docker-compose exec app ./manage.py create_federal_portfolio "{federal_agency_name}" --both```
##### Parameters
| | Parameter | Description |
|:-:|:-------------------------- |:-------------------------------------------------------------------------------------------|
| 1 | **federal_agency_name** | Name of the FederalAgency record surrounded by quotes. For instance,"AMTRAK". |
| 2 | **both** | If True, runs parse_requests and parse_domains. |
| 3 | **parse_requests** | If True, then the created portfolio is added to all related DomainRequests. |
| 4 | **parse_domains** | If True, then the created portfolio is added to all related Domains. |
Note: Regarding parameters #2-#3, you cannot use `--both` while using these. You must specify either `--parse_requests` or `--parse_domains` seperately. While all of these parameters are optional in that you do not need to specify all of them,
you must specify at least one to run this script.

View file

@ -1,7 +1,7 @@
import os import os
import tempfile import tempfile
from django.conf import settings from django.conf import settings # type: ignore
class Cert: class Cert:
@ -12,7 +12,7 @@ class Cert:
variable but Python's ssl library requires a file. variable but Python's ssl library requires a file.
""" """
def __init__(self, data=settings.SECRET_REGISTRY_CERT) -> None: def __init__(self, data=settings.SECRET_REGISTRY_CERT) -> None: # type: ignore
self.filename = self._write(data) self.filename = self._write(data)
def __del__(self): def __del__(self):
@ -31,4 +31,4 @@ class Key(Cert):
"""Location of private key as written to disk.""" """Location of private key as written to disk."""
def __init__(self) -> None: def __init__(self) -> None:
super().__init__(data=settings.SECRET_REGISTRY_KEY) super().__init__(data=settings.SECRET_REGISTRY_KEY) # type: ignore

View file

@ -962,7 +962,9 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
domain_ids = user_domain_roles.values_list("domain_id", flat=True) domain_ids = user_domain_roles.values_list("domain_id", flat=True)
domains = Domain.objects.filter(id__in=domain_ids).exclude(state=Domain.State.DELETED) domains = Domain.objects.filter(id__in=domain_ids).exclude(state=Domain.State.DELETED)
extra_context = {"domain_requests": domain_requests, "domains": domains} portfolio_ids = obj.get_portfolios().values_list("portfolio", flat=True)
portfolios = models.Portfolio.objects.filter(id__in=portfolio_ids)
extra_context = {"domain_requests": domain_requests, "domains": domains, "portfolios": portfolios}
return super().change_view(request, object_id, form_url, extra_context) return super().change_view(request, object_id, form_url, extra_context)

View file

@ -748,7 +748,10 @@ function initializeWidgetOnList(list, parentId) {
//------ Requested Domains //------ Requested Domains
const requestedDomainElement = document.getElementById('id_requested_domain'); const requestedDomainElement = document.getElementById('id_requested_domain');
const requestedDomain = requestedDomainElement.options[requestedDomainElement.selectedIndex].text; // We have to account for different superuser and analyst markups
const requestedDomain = requestedDomainElement.options
? requestedDomainElement.options[requestedDomainElement.selectedIndex].text
: requestedDomainElement.text;
//------ Submitter //------ Submitter
// Function to extract text by ID and handle missing elements // Function to extract text by ID and handle missing elements
@ -762,7 +765,10 @@ function initializeWidgetOnList(list, parentId) {
// Extract the submitter name, title, email, and phone number // Extract the submitter name, title, email, and phone number
const submitterDiv = document.querySelector('.form-row.field-submitter'); const submitterDiv = document.querySelector('.form-row.field-submitter');
const submitterNameElement = document.getElementById('id_submitter'); const submitterNameElement = document.getElementById('id_submitter');
const submitterName = submitterNameElement.options[submitterNameElement.selectedIndex].text; // We have to account for different superuser and analyst markups
const submitterName = submitterNameElement
? submitterNameElement.options[submitterNameElement.selectedIndex].text
: submitterDiv.querySelector('a').text;
const submitterTitle = extractTextById('contact_info_title', submitterDiv); const submitterTitle = extractTextById('contact_info_title', submitterDiv);
const submitterEmail = extractTextById('contact_info_email', submitterDiv); const submitterEmail = extractTextById('contact_info_email', submitterDiv);
const submitterPhone = extractTextById('contact_info_phone', submitterDiv); const submitterPhone = extractTextById('contact_info_phone', submitterDiv);

View file

@ -1168,7 +1168,6 @@ document.addEventListener('DOMContentLoaded', function() {
const statusCheckboxes = document.querySelectorAll('input[name="filter-status"]'); const statusCheckboxes = document.querySelectorAll('input[name="filter-status"]');
const statusIndicator = document.querySelector('.domain__filter-indicator'); const statusIndicator = document.querySelector('.domain__filter-indicator');
const statusToggle = document.querySelector('.usa-button--filter'); const statusToggle = document.querySelector('.usa-button--filter');
const noPortfolioFlag = document.getElementById('no-portfolio-js-flag');
const portfolioElement = document.getElementById('portfolio-js-value'); const portfolioElement = document.getElementById('portfolio-js-value');
const portfolioValue = portfolioElement ? portfolioElement.getAttribute('data-portfolio') : null; const portfolioValue = portfolioElement ? portfolioElement.getAttribute('data-portfolio') : null;
@ -1226,7 +1225,7 @@ document.addEventListener('DOMContentLoaded', function() {
let markupForSuborganizationRow = ''; let markupForSuborganizationRow = '';
if (!noPortfolioFlag) { if (portfolioValue) {
markupForSuborganizationRow = ` markupForSuborganizationRow = `
<td> <td>
<span class="text-wrap" aria-label="${domain.suborganization ? suborganization : 'No suborganization'}">${suborganization}</span> <span class="text-wrap" aria-label="${domain.suborganization ? suborganization : 'No suborganization'}">${suborganization}</span>
@ -1427,9 +1426,9 @@ document.addEventListener('DOMContentLoaded', function() {
// NOTE: We may need to evolve this as we add more filters. // NOTE: We may need to evolve this as we add more filters.
document.addEventListener('focusin', function(event) { document.addEventListener('focusin', function(event) {
const accordion = document.querySelector('.usa-accordion--select'); const accordion = document.querySelector('.usa-accordion--select');
const accordionIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]'); const accordionThatIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]');
if (accordionIsOpen && !accordion.contains(event.target)) { if (accordionThatIsOpen && !accordion.contains(event.target)) {
closeFilters(); closeFilters();
} }
}); });
@ -1438,9 +1437,9 @@ document.addEventListener('DOMContentLoaded', function() {
// NOTE: We may need to evolve this as we add more filters. // NOTE: We may need to evolve this as we add more filters.
document.addEventListener('click', function(event) { document.addEventListener('click', function(event) {
const accordion = document.querySelector('.usa-accordion--select'); const accordion = document.querySelector('.usa-accordion--select');
const accordionIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]'); const accordionThatIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]');
if (accordionIsOpen && !accordion.contains(event.target)) { if (accordionThatIsOpen && !accordion.contains(event.target)) {
closeFilters(); closeFilters();
} }
}); });
@ -1485,6 +1484,8 @@ document.addEventListener('DOMContentLoaded', function() {
const tableHeaders = document.querySelectorAll('.domain-requests__table th[data-sortable]'); const tableHeaders = document.querySelectorAll('.domain-requests__table th[data-sortable]');
const tableAnnouncementRegion = document.querySelector('.domain-requests__table-wrapper .usa-table__announcement-region'); const tableAnnouncementRegion = document.querySelector('.domain-requests__table-wrapper .usa-table__announcement-region');
const resetSearchButton = document.querySelector('.domain-requests__reset-search'); const resetSearchButton = document.querySelector('.domain-requests__reset-search');
const portfolioElement = document.getElementById('portfolio-js-value');
const portfolioValue = portfolioElement ? portfolioElement.getAttribute('data-portfolio') : null;
/** /**
* Delete is actually a POST API that requires a csrf token. The token will be waiting for us in the template as a hidden input. * Delete is actually a POST API that requires a csrf token. The token will be waiting for us in the template as a hidden input.
@ -1533,7 +1534,7 @@ document.addEventListener('DOMContentLoaded', function() {
* @param {*} scroll - control for the scrollToElement functionality * @param {*} scroll - control for the scrollToElement functionality
* @param {*} searchTerm - the search term * @param {*} searchTerm - the search term
*/ */
function loadDomainRequests(page, sortBy = currentSortBy, order = currentOrder, scroll = scrollToTable, searchTerm = currentSearchTerm) { function loadDomainRequests(page, sortBy = currentSortBy, order = currentOrder, scroll = scrollToTable, searchTerm = currentSearchTerm, portfolio = portfolioValue) {
// fetch json of page of domain requests, given params // fetch json of page of domain requests, given params
let baseUrl = document.getElementById("get_domain_requests_json_url"); let baseUrl = document.getElementById("get_domain_requests_json_url");
if (!baseUrl) { if (!baseUrl) {
@ -1545,7 +1546,12 @@ document.addEventListener('DOMContentLoaded', function() {
return; return;
} }
fetch(`${baseUrlValue}?page=${page}&sort_by=${sortBy}&order=${order}&search_term=${searchTerm}`) // fetch json of page of requests, given params
let url = `${baseUrlValue}?page=${page}&sort_by=${sortBy}&order=${order}&search_term=${searchTerm}`
if (portfolio)
url += `&portfolio=${portfolio}`
fetch(url)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.error) { if (data.error) {
@ -1601,10 +1607,21 @@ document.addEventListener('DOMContentLoaded', function() {
const actionLabel = request.action_label; const actionLabel = request.action_label;
const submissionDate = request.last_submitted_date ? new Date(request.last_submitted_date).toLocaleDateString('en-US', options) : `<span class="text-base">Not submitted</span>`; const submissionDate = request.last_submitted_date ? new Date(request.last_submitted_date).toLocaleDateString('en-US', options) : `<span class="text-base">Not submitted</span>`;
// Even if the request is not deletable, we may need this empty string for the td if the deletable column is displayed // The markup for the delete function either be a simple trigger or a 3 dots menu with a hidden trigger (in the case of portfolio requests page)
// Even if the request is not deletable, we may need these empty strings for the td if the deletable column is displayed
let modalTrigger = ''; let modalTrigger = '';
// If the request is deletable, create modal body and insert it let markupCreatorRow = '';
if (portfolioValue) {
markupCreatorRow = `
<td>
<span class="text-wrap break-word">${request.creator ? request.creator : ''}</span>
</td>
`
}
// If the request is deletable, create modal body and insert it. This is true for both requests and portfolio requests pages
if (request.is_deletable) { if (request.is_deletable) {
let modalHeading = ''; let modalHeading = '';
let modalDescription = ''; let modalDescription = '';
@ -1627,7 +1644,7 @@ document.addEventListener('DOMContentLoaded', function() {
role="button" role="button"
id="button-toggle-delete-domain-alert-${request.id}" id="button-toggle-delete-domain-alert-${request.id}"
href="#toggle-delete-domain-alert-${request.id}" href="#toggle-delete-domain-alert-${request.id}"
class="usa-button--unstyled text-no-underline late-loading-modal-trigger" class="usa-button text-secondary usa-button--unstyled text-no-underline late-loading-modal-trigger line-height-sans-5"
aria-controls="toggle-delete-domain-alert-${request.id}" aria-controls="toggle-delete-domain-alert-${request.id}"
data-open-modal data-open-modal
> >
@ -1692,8 +1709,57 @@ document.addEventListener('DOMContentLoaded', function() {
` `
domainRequestsSectionWrapper.appendChild(modal); domainRequestsSectionWrapper.appendChild(modal);
// Request is deletable, modal and modalTrigger are built. Now check if we are on the portfolio requests page (by seeing if there is a portfolio value) and enhance the modalTrigger accordingly
if (portfolioValue) {
modalTrigger = `
<a
role="button"
id="button-toggle-delete-domain-alert-${request.id}"
href="#toggle-delete-domain-alert-${request.id}"
class="usa-button text-secondary usa-button--unstyled text-no-underline late-loading-modal-trigger margin-top-2 visible-mobile-flex line-height-sans-5"
aria-controls="toggle-delete-domain-alert-${request.id}"
data-open-modal
>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#delete"></use>
</svg> Delete <span class="usa-sr-only">${domainName}</span>
</a>
<div class="usa-accordion usa-accordion--more-actions margin-right-2 hidden-mobile-flex">
<div class="usa-accordion__heading">
<button
type="button"
class="usa-button usa-button--unstyled usa-button--with-icon usa-accordion__button usa-button--more-actions"
aria-expanded="false"
aria-controls="more-actions-${request.id}"
>
<svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#more_vert"></use>
</svg>
</button>
</div>
<div id="more-actions-${request.id}" class="usa-accordion__content usa-prose shadow-1 left-auto right-0" hidden>
<h2>More options</h2>
<a
role="button"
id="button-toggle-delete-domain-alert-${request.id}"
href="#toggle-delete-domain-alert-${request.id}"
class="usa-button text-secondary usa-button--unstyled text-no-underline late-loading-modal-trigger margin-top-2 line-height-sans-5"
aria-controls="toggle-delete-domain-alert-${request.id}"
data-open-modal
>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#delete"></use>
</svg> Delete <span class="usa-sr-only">${domainName}</span>
</a>
</div>
</div>
`
}
} }
const row = document.createElement('tr'); const row = document.createElement('tr');
row.innerHTML = ` row.innerHTML = `
<th scope="row" role="rowheader" data-label="Domain name"> <th scope="row" role="rowheader" data-label="Domain name">
@ -1702,6 +1768,7 @@ document.addEventListener('DOMContentLoaded', function() {
<td data-sort-value="${new Date(request.last_submitted_date).getTime()}" data-label="Date submitted"> <td data-sort-value="${new Date(request.last_submitted_date).getTime()}" data-label="Date submitted">
${submissionDate} ${submissionDate}
</td> </td>
${markupCreatorRow}
<td data-label="Status"> <td data-label="Status">
${request.status} ${request.status}
</td> </td>
@ -1817,6 +1884,32 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
} }
function closeMoreActionMenu(accordionThatIsOpen) {
if (accordionThatIsOpen.getAttribute("aria-expanded") === "true") {
accordionThatIsOpen.click();
}
}
document.addEventListener('focusin', function(event) {
closeOpenAccordions(event);
});
document.addEventListener('click', function(event) {
closeOpenAccordions(event);
});
function closeOpenAccordions(event) {
const openAccordions = document.querySelectorAll('.usa-button--more-actions[aria-expanded="true"]');
openAccordions.forEach((openAccordionButton) => {
// Find the corresponding accordion
const accordion = openAccordionButton.closest('.usa-accordion--more-actions');
if (accordion && !accordion.contains(event.target)) {
// Close the accordion if the click is outside
closeMoreActionMenu(openAccordionButton);
}
});
}
// Initial load // Initial load
loadDomainRequests(1); loadDomainRequests(1);
} }

View file

@ -1,6 +1,7 @@
@use "uswds-core" as *; @use "uswds-core" as *;
.usa-accordion--select { .usa-accordion--select,
.usa-accordion--more-actions {
display: inline-block; display: inline-block;
width: auto; width: auto;
position: relative; position: relative;
@ -14,7 +15,6 @@
// Note, width is determined by a custom width class on one of the children // Note, width is determined by a custom width class on one of the children
position: absolute; position: absolute;
z-index: 1; z-index: 1;
top: 33.88px;
left: 0; left: 0;
border-radius: 4px; border-radius: 4px;
border: solid 1px color('base-lighter'); border: solid 1px color('base-lighter');
@ -31,3 +31,17 @@
margin-top: 0 !important; margin-top: 0 !important;
} }
} }
.usa-accordion--select .usa-accordion__content {
top: 33.88px;
}
.usa-accordion--more-actions .usa-accordion__content {
top: 30px;
}
tr:last-child .usa-accordion--more-actions .usa-accordion__content {
top: auto;
bottom: -10px;
right: 30px;
}

View file

@ -159,6 +159,23 @@ abbr[title] {
} }
} }
.hidden-mobile-flex {
display: none!important;
}
.visible-mobile-flex {
display: flex!important;
}
@include at-media(tablet) {
.hidden-mobile-flex {
display: flex!important;
}
.visible-mobile-flex {
display: none!important;
}
}
.flex-end { .flex-end {
align-items: flex-end; align-items: flex-end;
} }
@ -200,6 +217,11 @@ abbr[title] {
} }
} }
.margin-right-neg-4px { // Boost this USWDS utility class for the accordions in the portfolio requests table
margin-right: -4px; .left-auto {
left: auto!important;
}
.break-word {
word-break: break-word;
} }

View file

@ -211,14 +211,7 @@ a.usa-button--unstyled:visited {
align-items: center; align-items: center;
} }
.dotgov-table a
.dotgov-table a,
.usa-link--icon {
&:visited {
color: color('primary');
}
}
a .usa-icon, a .usa-icon,
.usa-button--with-icon .usa-icon { .usa-button--with-icon .usa-icon {
height: 1.3em; height: 1.3em;
@ -230,3 +223,9 @@ a .usa-icon,
height: 1.5em; height: 1.5em;
width: 1.5em; width: 1.5em;
} }
button.text-secondary,
button.text-secondary:hover,
.dotgov-table a.text-secondary {
color: $theme-color-error;
}

View file

@ -89,16 +89,24 @@
.usa-nav__primary { .usa-nav__primary {
.usa-nav-link, .usa-nav-link,
.usa-nav-link:hover, .usa-nav-link:hover,
.usa-nav-link:active { .usa-nav-link:active,
button {
color: color('primary'); color: color('primary');
font-weight: font-weight('normal'); font-weight: font-weight('normal');
font-size: 16px; font-size: 16px;
} }
.usa-current, .usa-current,
.usa-current:hover, .usa-current:hover,
.usa-current:active { .usa-current:active,
button.usa-current {
font-weight: font-weight('bold'); font-weight: font-weight('bold');
} }
button[aria-expanded="true"] {
color: color('white');
}
button:not(.usa-current):hover::after {
display: none!important;
}
} }
.usa-nav__secondary { .usa-nav__secondary {
// I don't know why USWDS has this at 2 rem, which puts it out of alignment // I don't know why USWDS has this at 2 rem, which puts it out of alignment

View file

@ -23,6 +23,9 @@ from cfenv import AppEnv # type: ignore
from pathlib import Path from pathlib import Path
from typing import Final from typing import Final
from botocore.config import Config from botocore.config import Config
import json
import logging
from django.utils.log import ServerFormatter
# # # ### # # # ###
# Setup code goes here # # Setup code goes here #
@ -57,7 +60,7 @@ env_db_url = env.dj_db_url("DATABASE_URL")
env_debug = env.bool("DJANGO_DEBUG", default=False) env_debug = env.bool("DJANGO_DEBUG", default=False)
env_is_production = env.bool("IS_PRODUCTION", default=False) env_is_production = env.bool("IS_PRODUCTION", default=False)
env_log_level = env.str("DJANGO_LOG_LEVEL", "DEBUG") env_log_level = env.str("DJANGO_LOG_LEVEL", "DEBUG")
env_base_url = env.str("DJANGO_BASE_URL") env_base_url: str = env.str("DJANGO_BASE_URL")
env_getgov_public_site_url = env.str("GETGOV_PUBLIC_SITE_URL", "") env_getgov_public_site_url = env.str("GETGOV_PUBLIC_SITE_URL", "")
env_oidc_active_provider = env.str("OIDC_ACTIVE_PROVIDER", "identity sandbox") env_oidc_active_provider = env.str("OIDC_ACTIVE_PROVIDER", "identity sandbox")
@ -192,7 +195,7 @@ MIDDLEWARE = [
"registrar.registrar_middleware.CheckPortfolioMiddleware", "registrar.registrar_middleware.CheckPortfolioMiddleware",
] ]
# application object used by Djangos built-in servers (e.g. `runserver`) # application object used by Django's built-in servers (e.g. `runserver`)
WSGI_APPLICATION = "registrar.config.wsgi.application" WSGI_APPLICATION = "registrar.config.wsgi.application"
# endregion # endregion
@ -415,7 +418,7 @@ LANGUAGE_COOKIE_SECURE = True
# and to interpret datetimes entered in forms # and to interpret datetimes entered in forms
TIME_ZONE = "UTC" TIME_ZONE = "UTC"
# enable Djangos translation system # enable Django's translation system
USE_I18N = True USE_I18N = True
# enable localized formatting of numbers and dates # enable localized formatting of numbers and dates
@ -450,6 +453,40 @@ PHONENUMBER_DEFAULT_REGION = "US"
# logger.error("Can't do this important task. Something is very wrong.") # logger.error("Can't do this important task. Something is very wrong.")
# logger.critical("Going to crash now.") # logger.critical("Going to crash now.")
class JsonFormatter(logging.Formatter):
"""Formats logs into JSON for better parsing"""
def __init__(self):
super().__init__(datefmt="%d/%b/%Y %H:%M:%S")
def format(self, record):
log_record = {
"timestamp": self.formatTime(record, self.datefmt),
"level": record.levelname,
"name": record.name,
"lineno": record.lineno,
"message": record.getMessage(),
}
return json.dumps(log_record)
class JsonServerFormatter(ServerFormatter):
"""Formats server logs into JSON for better parsing"""
def format(self, record):
formatted_record = super().format(record)
log_entry = {"server_time": record.server_time, "level": record.levelname, "message": formatted_record}
return json.dumps(log_entry)
# default to json formatted logs
server_formatter, console_formatter = "json.server", "json"
# don't use json format locally, it makes logs hard to read in console
if "localhost" in env_base_url:
server_formatter, console_formatter = "django.server", "verbose"
LOGGING = { LOGGING = {
"version": 1, "version": 1,
# Don't import Django's existing loggers # Don't import Django's existing loggers
@ -469,6 +506,12 @@ LOGGING = {
"format": "[{server_time}] {message}", "format": "[{server_time}] {message}",
"style": "{", "style": "{",
}, },
"json.server": {
"()": JsonServerFormatter,
},
"json": {
"()": JsonFormatter,
},
}, },
# define where log messages will be sent; # define where log messages will be sent;
# each logger can have one or more handlers # each logger can have one or more handlers
@ -476,12 +519,12 @@ LOGGING = {
"console": { "console": {
"level": env_log_level, "level": env_log_level,
"class": "logging.StreamHandler", "class": "logging.StreamHandler",
"formatter": "verbose", "formatter": console_formatter,
}, },
"django.server": { "django.server": {
"level": "INFO", "level": "INFO",
"class": "logging.StreamHandler", "class": "logging.StreamHandler",
"formatter": "django.server", "formatter": server_formatter,
}, },
# No file logger is configured, # No file logger is configured,
# because containerized apps # because containerized apps

View file

@ -78,6 +78,11 @@ urlpatterns = [
views.PortfolioDomainRequestsView.as_view(), views.PortfolioDomainRequestsView.as_view(),
name="domain-requests", name="domain-requests",
), ),
path(
"no-organization-requests/",
views.PortfolioNoDomainRequestsView.as_view(),
name="no-portfolio-requests",
),
path( path(
"organization/", "organization/",
views.PortfolioOrganizationView.as_view(), views.PortfolioOrganizationView.as_view(),

View file

@ -60,35 +60,42 @@ def add_has_profile_feature_flag_to_context(request):
def portfolio_permissions(request): def portfolio_permissions(request):
"""Make portfolio permissions for the request user available in global context""" """Make portfolio permissions for the request user available in global context"""
context = { portfolio_context = {
"has_base_portfolio_permission": False, "has_base_portfolio_permission": False,
"has_domains_portfolio_permission": False, "has_any_domains_portfolio_permission": False,
"has_domain_requests_portfolio_permission": False, "has_any_requests_portfolio_permission": False,
"has_edit_request_portfolio_permission": False,
"has_view_suborganization_portfolio_permission": False,
"has_edit_suborganization_portfolio_permission": False,
"has_view_members_portfolio_permission": False, "has_view_members_portfolio_permission": False,
"has_edit_members_portfolio_permission": False, "has_edit_members_portfolio_permission": False,
"has_view_suborganization": False,
"has_edit_suborganization": False,
"portfolio": None, "portfolio": None,
"has_organization_feature_flag": False, "has_organization_feature_flag": False,
"has_organization_requests_flag": False,
"has_organization_members_flag": False,
} }
try: try:
portfolio = request.session.get("portfolio") portfolio = request.session.get("portfolio")
# Linting: line too long
view_suborg = request.user.has_view_suborganization_portfolio_permission(portfolio)
edit_suborg = request.user.has_edit_suborganization_portfolio_permission(portfolio)
if portfolio: if portfolio:
return { return {
"has_base_portfolio_permission": request.user.has_base_portfolio_permission(portfolio), "has_base_portfolio_permission": request.user.has_base_portfolio_permission(portfolio),
"has_domains_portfolio_permission": request.user.has_domains_portfolio_permission(portfolio), "has_edit_request_portfolio_permission": request.user.has_edit_request_portfolio_permission(portfolio),
"has_domain_requests_portfolio_permission": request.user.has_domain_requests_portfolio_permission( "has_view_suborganization_portfolio_permission": view_suborg,
portfolio "has_edit_suborganization_portfolio_permission": edit_suborg,
), "has_any_domains_portfolio_permission": request.user.has_any_domains_portfolio_permission(portfolio),
"has_any_requests_portfolio_permission": request.user.has_any_requests_portfolio_permission(portfolio),
"has_view_members_portfolio_permission": request.user.has_view_members_portfolio_permission(portfolio), "has_view_members_portfolio_permission": request.user.has_view_members_portfolio_permission(portfolio),
"has_edit_members_portfolio_permission": request.user.has_edit_members_portfolio_permission(portfolio), "has_edit_members_portfolio_permission": request.user.has_edit_members_portfolio_permission(portfolio),
"has_view_suborganization": request.user.has_view_suborganization(portfolio),
"has_edit_suborganization": request.user.has_edit_suborganization(portfolio),
"portfolio": portfolio, "portfolio": portfolio,
"has_organization_feature_flag": True, "has_organization_feature_flag": True,
"has_organization_requests_flag": request.user.has_organization_requests_flag(),
"has_organization_members_flag": request.user.has_organization_members_flag(),
} }
return context return portfolio_context
except AttributeError: except AttributeError:
# Handles cases where request.user might not exist # Handles cases where request.user might not exist
return context return portfolio_context

View file

@ -0,0 +1,255 @@
"""Loads files from /tmp into our sandboxes"""
import argparse
import logging
from django.core.management import BaseCommand, CommandError
from registrar.management.commands.utility.terminal_helper import TerminalColors, TerminalHelper
from registrar.models import DomainInformation, DomainRequest, FederalAgency, Suborganization, Portfolio, User
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Creates a federal portfolio given a FederalAgency name"
def add_arguments(self, parser):
"""Add three arguments:
1. agency_name => the value of FederalAgency.agency
2. --parse_requests => if true, adds the given portfolio to each related DomainRequest
3. --parse_domains => if true, adds the given portfolio to each related DomainInformation
"""
parser.add_argument(
"agency_name",
help="The name of the FederalAgency to add",
)
parser.add_argument(
"--parse_requests",
action=argparse.BooleanOptionalAction,
help="Adds portfolio to DomainRequests",
)
parser.add_argument(
"--parse_domains",
action=argparse.BooleanOptionalAction,
help="Adds portfolio to DomainInformation",
)
parser.add_argument(
"--both",
action=argparse.BooleanOptionalAction,
help="Adds portfolio to both requests and domains",
)
def handle(self, agency_name, **options):
parse_requests = options.get("parse_requests")
parse_domains = options.get("parse_domains")
both = options.get("both")
if not both:
if not parse_requests and not parse_domains:
raise CommandError("You must specify at least one of --parse_requests or --parse_domains.")
else:
if parse_requests or parse_domains:
raise CommandError("You cannot pass --parse_requests or --parse_domains when passing --both.")
federal_agency = FederalAgency.objects.filter(agency__iexact=agency_name).first()
if not federal_agency:
raise ValueError(
f"Cannot find the federal agency '{agency_name}' in our database. "
"The value you enter for `agency_name` must be "
"prepopulated in the FederalAgency table before proceeding."
)
portfolio = self.create_or_modify_portfolio(federal_agency)
self.create_suborganizations(portfolio, federal_agency)
if parse_requests or both:
self.handle_portfolio_requests(portfolio, federal_agency)
if parse_domains or both:
self.handle_portfolio_domains(portfolio, federal_agency)
def create_or_modify_portfolio(self, federal_agency):
"""Creates or modifies a portfolio record based on a federal agency."""
portfolio_args = {
"federal_agency": federal_agency,
"organization_name": federal_agency.agency,
"organization_type": DomainRequest.OrganizationChoices.FEDERAL,
"creator": User.get_default_user(),
"notes": "Auto-generated record",
}
if federal_agency.so_federal_agency.exists():
portfolio_args["senior_official"] = federal_agency.so_federal_agency.first()
portfolio, created = Portfolio.objects.get_or_create(
organization_name=portfolio_args.get("organization_name"), defaults=portfolio_args
)
if created:
message = f"Created portfolio '{portfolio}'"
TerminalHelper.colorful_logger(logger.info, TerminalColors.OKGREEN, message)
if portfolio_args.get("senior_official"):
message = f"Added senior official '{portfolio_args['senior_official']}'"
TerminalHelper.colorful_logger(logger.info, TerminalColors.OKGREEN, message)
else:
message = (
f"No senior official added to portfolio '{portfolio}'. "
"None was returned for the reverse relation `FederalAgency.so_federal_agency.first()`"
)
TerminalHelper.colorful_logger(logger.info, TerminalColors.YELLOW, message)
else:
proceed = TerminalHelper.prompt_for_execution(
system_exit_on_terminate=False,
prompt_message=f"""
The given portfolio '{federal_agency.agency}' already exists in our DB.
If you cancel, the rest of the script will still execute but this record will not update.
""",
prompt_title="Do you wish to modify this record?",
)
if proceed:
# Don't override the creator and notes fields
if portfolio.creator:
portfolio_args.pop("creator")
if portfolio.notes:
portfolio_args.pop("notes")
# Update everything else
for key, value in portfolio_args.items():
setattr(portfolio, key, value)
portfolio.save()
message = f"Modified portfolio '{portfolio}'"
TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message)
if portfolio_args.get("senior_official"):
message = f"Added/modified senior official '{portfolio_args['senior_official']}'"
TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message)
return portfolio
def create_suborganizations(self, portfolio: Portfolio, federal_agency: FederalAgency):
"""Create Suborganizations tied to the given portfolio based on DomainInformation objects"""
valid_agencies = DomainInformation.objects.filter(
federal_agency=federal_agency, organization_name__isnull=False
)
org_names = set(valid_agencies.values_list("organization_name", flat=True))
if not org_names:
message = (
"Could not add any suborganizations."
f"\nNo suborganizations were found for '{federal_agency}' when filtering on this name, "
"and excluding null organization_name records."
)
TerminalHelper.colorful_logger(logger.warning, TerminalColors.FAIL, message)
return
# Check if we need to update any existing suborgs first. This step is optional.
existing_suborgs = Suborganization.objects.filter(name__in=org_names)
if existing_suborgs.exists():
self._update_existing_suborganizations(portfolio, existing_suborgs)
# Create new suborgs, as long as they don't exist in the db already
new_suborgs = []
for name in org_names - set(existing_suborgs.values_list("name", flat=True)):
# Stored in variables due to linter wanting type information here.
portfolio_name: str = portfolio.organization_name if portfolio.organization_name is not None else ""
if name is not None and name.lower() == portfolio_name.lower():
# You can use this to populate location information, when this occurs.
# However, this isn't needed for now so we can skip it.
message = (
f"Skipping suborganization create on record '{name}'. "
"The federal agency name is the same as the portfolio name."
)
TerminalHelper.colorful_logger(logger.warning, TerminalColors.YELLOW, message)
else:
new_suborgs.append(Suborganization(name=name, portfolio=portfolio)) # type: ignore
if new_suborgs:
Suborganization.objects.bulk_create(new_suborgs)
TerminalHelper.colorful_logger(
logger.info, TerminalColors.OKGREEN, f"Added {len(new_suborgs)} suborganizations"
)
else:
TerminalHelper.colorful_logger(logger.warning, TerminalColors.YELLOW, "No suborganizations added")
def _update_existing_suborganizations(self, portfolio, orgs_to_update):
"""
Update existing suborganizations with new portfolio.
Prompts for user confirmation before proceeding.
"""
proceed = TerminalHelper.prompt_for_execution(
system_exit_on_terminate=False,
prompt_message=f"""Some suborganizations already exist in our DB.
If you cancel, the rest of the script will still execute but these records will not update.
==Proposed Changes==
The following suborgs will be updated: {[org.name for org in orgs_to_update]}
""",
prompt_title="Do you wish to modify existing suborganizations?",
)
if proceed:
for org in orgs_to_update:
org.portfolio = portfolio
Suborganization.objects.bulk_update(orgs_to_update, ["portfolio"])
message = f"Updated {len(orgs_to_update)} suborganizations."
TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message)
def handle_portfolio_requests(self, portfolio: Portfolio, federal_agency: FederalAgency):
"""
Associate portfolio with domain requests for a federal agency.
Updates all relevant domain request records.
"""
invalid_states = [
DomainRequest.DomainRequestStatus.STARTED,
DomainRequest.DomainRequestStatus.INELIGIBLE,
DomainRequest.DomainRequestStatus.REJECTED,
]
domain_requests = DomainRequest.objects.filter(federal_agency=federal_agency).exclude(status__in=invalid_states)
if not domain_requests.exists():
message = f"""
Portfolios not added to domain requests: no valid records found.
This means that a filter on DomainInformation for the federal_agency '{federal_agency}' returned no results.
Excluded statuses: STARTED, INELIGIBLE, REJECTED.
"""
TerminalHelper.colorful_logger(logger.info, TerminalColors.YELLOW, message)
return None
# Get all suborg information and store it in a dict to avoid doing a db call
suborgs = Suborganization.objects.filter(portfolio=portfolio).in_bulk(field_name="name")
for domain_request in domain_requests:
domain_request.portfolio = portfolio
if domain_request.organization_name in suborgs:
domain_request.sub_organization = suborgs.get(domain_request.organization_name)
DomainRequest.objects.bulk_update(domain_requests, ["portfolio", "sub_organization"])
message = f"Added portfolio '{portfolio}' to {len(domain_requests)} domain requests."
TerminalHelper.colorful_logger(logger.info, TerminalColors.OKGREEN, message)
def handle_portfolio_domains(self, portfolio: Portfolio, federal_agency: FederalAgency):
"""
Associate portfolio with domains for a federal agency.
Updates all relevant domain information records.
"""
domain_infos = DomainInformation.objects.filter(federal_agency=federal_agency)
if not domain_infos.exists():
message = f"""
Portfolios not added to domains: no valid records found.
This means that a filter on DomainInformation for the federal_agency '{federal_agency}' returned no results.
"""
TerminalHelper.colorful_logger(logger.info, TerminalColors.YELLOW, message)
return None
# Get all suborg information and store it in a dict to avoid doing a db call
suborgs = Suborganization.objects.filter(portfolio=portfolio).in_bulk(field_name="name")
for domain_info in domain_infos:
domain_info.portfolio = portfolio
if domain_info.organization_name in suborgs:
domain_info.sub_organization = suborgs.get(domain_info.organization_name)
DomainInformation.objects.bulk_update(domain_infos, ["portfolio", "sub_organization"])
message = f"Added portfolio '{portfolio}' to {len(domain_infos)} domains"
TerminalHelper.colorful_logger(logger.info, TerminalColors.OKGREEN, message)

View file

@ -423,7 +423,7 @@ class Command(BaseCommand):
valid_fed_type = fed_type in fed_choices valid_fed_type = fed_type in fed_choices
valid_fed_agency = fed_agency in agency_choices valid_fed_agency = fed_agency in agency_choices
default_creator, _ = User.objects.get_or_create(username="System") default_creator = User.get_default_user()
new_domain_info_data = { new_domain_info_data = {
"domain": domain, "domain": domain,

View file

@ -0,0 +1,64 @@
# Generated by Django 4.2.10 on 2024-09-09 14:48
import django.contrib.postgres.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0123_alter_portfolioinvitation_portfolio_additional_permissions_and_more"),
]
operations = [
migrations.AlterField(
model_name="portfolioinvitation",
name="portfolio_additional_permissions",
field=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_members", "View members"),
("edit_members", "Create and edit members"),
("view_all_requests", "View all 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,
),
),
migrations.AlterField(
model_name="userportfoliopermission",
name="additional_permissions",
field=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_members", "View members"),
("edit_members", "Create and edit members"),
("view_all_requests", "View all 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,
),
),
]

View file

@ -131,6 +131,12 @@ class User(AbstractUser):
else: else:
return self.username return self.username
@classmethod
def get_default_user(cls):
"""Returns the default "system" user"""
default_creator, _ = User.objects.get_or_create(username="System")
return default_creator
def restrict_user(self): def restrict_user(self):
self.status = self.RESTRICTED self.status = self.RESTRICTED
self.save() self.save()
@ -192,31 +198,25 @@ class User(AbstractUser):
def has_edit_org_portfolio_permission(self, portfolio): def has_edit_org_portfolio_permission(self, portfolio):
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_PORTFOLIO) return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_PORTFOLIO)
def has_domains_portfolio_permission(self, portfolio): def has_any_domains_portfolio_permission(self, portfolio):
return self._has_portfolio_permission( return self._has_portfolio_permission(
portfolio, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS portfolio, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS
) or self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS) ) or self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS)
def has_domain_requests_portfolio_permission(self, portfolio): def has_organization_requests_flag(self):
# BEGIN
# Note code below is to add organization_request feature
request = HttpRequest() request = HttpRequest()
request.user = self request.user = self
has_organization_requests_flag = flag_is_active(request, "organization_requests") return flag_is_active(request, "organization_requests")
if not has_organization_requests_flag:
return False def has_organization_members_flag(self):
# END request = HttpRequest()
return self._has_portfolio_permission( request.user = self
portfolio, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS return flag_is_active(request, "organization_members")
) or self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS)
def has_view_members_portfolio_permission(self, portfolio): def has_view_members_portfolio_permission(self, portfolio):
# BEGIN # BEGIN
# Note code below is to add organization_request feature # Note code below is to add organization_request feature
request = HttpRequest() if not self.has_organization_members_flag():
request.user = self
has_organization_members_flag = flag_is_active(request, "organization_members")
if not has_organization_members_flag:
return False return False
# END # END
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_MEMBERS) return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_MEMBERS)
@ -224,23 +224,37 @@ class User(AbstractUser):
def has_edit_members_portfolio_permission(self, portfolio): def has_edit_members_portfolio_permission(self, portfolio):
# BEGIN # BEGIN
# Note code below is to add organization_request feature # Note code below is to add organization_request feature
request = HttpRequest() if not self.has_organization_members_flag():
request.user = self
has_organization_members_flag = flag_is_active(request, "organization_members")
if not has_organization_members_flag:
return False return False
# END # END
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_MEMBERS) return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_MEMBERS)
def has_view_all_domains_permission(self, portfolio): def has_view_all_domains_portfolio_permission(self, portfolio):
"""Determines if the current user can view all available domains in a given portfolio""" """Determines if the current user can view all available domains in a given portfolio"""
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS) return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)
def has_any_requests_portfolio_permission(self, portfolio):
# BEGIN
# Note code below is to add organization_request feature
if not self.has_organization_requests_flag():
return False
# END
return self._has_portfolio_permission(
portfolio, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS
) or self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_REQUESTS)
def has_view_all_requests_portfolio_permission(self, portfolio):
"""Determines if the current user can view all available domain requests in a given portfolio"""
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)
def has_edit_request_portfolio_permission(self, portfolio):
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_REQUESTS)
# Field specific permission checks # Field specific permission checks
def has_view_suborganization(self, portfolio): def has_view_suborganization_portfolio_permission(self, portfolio):
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION) return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION)
def has_edit_suborganization(self, portfolio): def has_edit_suborganization_portfolio_permission(self, portfolio):
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION) return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION)
def get_first_portfolio(self): def get_first_portfolio(self):
@ -249,36 +263,36 @@ class User(AbstractUser):
return permission.portfolio return permission.portfolio
return None return None
def has_edit_requests(self, portfolio):
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_REQUESTS)
def portfolio_role_summary(self, portfolio): def portfolio_role_summary(self, portfolio):
"""Returns a list of roles based on the user's permissions.""" """Returns a list of roles based on the user's permissions."""
roles = [] roles = []
# Define the conditions and their corresponding roles # Define the conditions and their corresponding roles
conditions_roles = [ conditions_roles = [
(self.has_edit_suborganization(portfolio), ["Admin"]), (self.has_edit_suborganization_portfolio_permission(portfolio), ["Admin"]),
( (
self.has_view_all_domains_permission(portfolio) self.has_view_all_domains_portfolio_permission(portfolio)
and self.has_domain_requests_portfolio_permission(portfolio) and self.has_any_requests_portfolio_permission(portfolio)
and self.has_edit_requests(portfolio), and self.has_edit_request_portfolio_permission(portfolio),
["View-only admin", "Domain requestor"], ["View-only admin", "Domain requestor"],
), ),
( (
self.has_view_all_domains_permission(portfolio) self.has_view_all_domains_portfolio_permission(portfolio)
and self.has_domain_requests_portfolio_permission(portfolio), and self.has_any_requests_portfolio_permission(portfolio),
["View-only admin"], ["View-only admin"],
), ),
( (
self.has_base_portfolio_permission(portfolio) self.has_base_portfolio_permission(portfolio)
and self.has_edit_requests(portfolio) and self.has_edit_request_portfolio_permission(portfolio)
and self.has_domains_portfolio_permission(portfolio), and self.has_any_domains_portfolio_permission(portfolio),
["Domain requestor", "Domain manager"], ["Domain requestor", "Domain manager"],
), ),
(self.has_base_portfolio_permission(portfolio) and self.has_edit_requests(portfolio), ["Domain requestor"]),
( (
self.has_base_portfolio_permission(portfolio) and self.has_domains_portfolio_permission(portfolio), self.has_base_portfolio_permission(portfolio) and self.has_edit_request_portfolio_permission(portfolio),
["Domain requestor"],
),
(
self.has_base_portfolio_permission(portfolio) and self.has_any_domains_portfolio_permission(portfolio),
["Domain manager"], ["Domain manager"],
), ),
(self.has_base_portfolio_permission(portfolio), ["Member"]), (self.has_base_portfolio_permission(portfolio), ["Member"]),
@ -292,6 +306,9 @@ class User(AbstractUser):
return roles return roles
def get_portfolios(self):
return self.portfolio_permissions.all()
@classmethod @classmethod
def needs_identity_verification(cls, email, uuid): def needs_identity_verification(cls, email, uuid):
"""A method used by our oidc classes to test whether a user needs email/uuid verification """A method used by our oidc classes to test whether a user needs email/uuid verification
@ -437,8 +454,6 @@ class User(AbstractUser):
self.check_domain_invitations_on_login() self.check_domain_invitations_on_login()
self.check_portfolio_invitations_on_login() self.check_portfolio_invitations_on_login()
# NOTE TO DAVE: I'd simply suggest that we move these functions outside of the user object,
# and move them to some sort of utility file. That way we aren't calling request inside here.
def is_org_user(self, request): def is_org_user(self, request):
has_organization_feature_flag = flag_is_active(request, "organization_feature") has_organization_feature_flag = flag_is_active(request, "organization_feature")
portfolio = request.session.get("portfolio") portfolio = request.session.get("portfolio")
@ -447,7 +462,7 @@ class User(AbstractUser):
def get_user_domain_ids(self, request): def get_user_domain_ids(self, request):
"""Returns either the domains ids associated with this user on UserDomainRole or Portfolio""" """Returns either the domains ids associated with this user on UserDomainRole or Portfolio"""
portfolio = request.session.get("portfolio") portfolio = request.session.get("portfolio")
if self.is_org_user(request) and self.has_view_all_domains_permission(portfolio): if self.is_org_user(request) and self.has_view_all_domains_portfolio_permission(portfolio):
return DomainInformation.objects.filter(portfolio=portfolio).values_list("domain_id", flat=True) return DomainInformation.objects.filter(portfolio=portfolio).values_list("domain_id", flat=True)
else: else:
return UserDomainRole.objects.filter(user=self).values_list("domain_id", flat=True) return UserDomainRole.objects.filter(user=self).values_list("domain_id", flat=True)

View file

@ -21,7 +21,6 @@ class UserPortfolioPermissionChoices(models.TextChoices):
EDIT_MEMBERS = "edit_members", "Create and edit members" EDIT_MEMBERS = "edit_members", "Create and edit members"
VIEW_ALL_REQUESTS = "view_all_requests", "View all requests" VIEW_ALL_REQUESTS = "view_all_requests", "View all requests"
VIEW_CREATED_REQUESTS = "view_created_requests", "View created requests"
EDIT_REQUESTS = "edit_requests", "Create and edit requests" EDIT_REQUESTS = "edit_requests", "Create and edit requests"
VIEW_PORTFOLIO = "view_portfolio", "View organization" VIEW_PORTFOLIO = "view_portfolio", "View organization"

View file

@ -6,7 +6,7 @@ import logging
from urllib.parse import parse_qs from urllib.parse import parse_qs
from django.urls import reverse from django.urls import reverse
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from registrar.models.user import User from registrar.models import User
from waffle.decorators import flag_is_active from waffle.decorators import flag_is_active
from registrar.models.utility.generic_helper import replace_url_queryparams from registrar.models.utility.generic_helper import replace_url_queryparams
@ -144,25 +144,30 @@ class CheckPortfolioMiddleware:
if not request.user.is_authenticated: if not request.user.is_authenticated:
return None return None
# set the portfolio in the session if it is not set # if multiple portfolios are allowed for this user
if "portfolio" not in request.session or request.session["portfolio"] is None: if flag_is_active(request, "organization_feature"):
# if multiple portfolios are allowed for this user self.set_portfolio_in_session(request)
if flag_is_active(request, "multiple_portfolios"): elif request.session.get("portfolio"):
# NOTE: we will want to change later to have a workflow for selecting # Edge case: User disables flag while already logged in
# portfolio and another for switching portfolio; for now, select first request.session["portfolio"] = None
request.session["portfolio"] = request.user.get_first_portfolio() elif "portfolio" not in request.session:
elif flag_is_active(request, "organization_feature"): # Set the portfolio in the session if its not already in it
request.session["portfolio"] = request.user.get_first_portfolio() request.session["portfolio"] = None
else:
request.session["portfolio"] = None
if request.session["portfolio"] is not None and current_path == self.home: if request.user.is_org_user(request):
if request.user.is_org_user(request): if current_path == self.home:
if request.user.has_domains_portfolio_permission(request.session["portfolio"]): if request.user.has_any_domains_portfolio_permission(request.session["portfolio"]):
portfolio_redirect = reverse("domains") portfolio_redirect = reverse("domains")
else: else:
portfolio_redirect = reverse("no-portfolio-domains") portfolio_redirect = reverse("no-portfolio-domains")
return HttpResponseRedirect(portfolio_redirect) return HttpResponseRedirect(portfolio_redirect)
return None return None
def set_portfolio_in_session(self, request):
# NOTE: we will want to change later to have a workflow for selecting
# portfolio and another for switching portfolio; for now, select first
if flag_is_active(request, "multiple_portfolios"):
request.session["portfolio"] = request.user.get_first_portfolio()
else:
request.session["portfolio"] = request.user.get_first_portfolio()

View file

@ -107,7 +107,7 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% endif %} {% endif %}
{% elif field.field.name == "requested_domain" %} {% elif field.field.name == "requested_domain" %}
{% with current_path=request.get_full_path %} {% with current_path=request.get_full_path %}
<a class="margin-top-05 padding-top-05" href="{% url 'admin:registrar_draftdomain_change' original.requested_domain.id %}?{{ 'return_path='|add:current_path }}">{{ original.requested_domain }}</a> <a class="margin-top-05 padding-top-05" id="id_requested_domain" href="{% url 'admin:registrar_draftdomain_change' original.requested_domain.id %}?{{ 'return_path='|add:current_path }}">{{ original.requested_domain }}</a>
{% endwith%} {% endwith%}
{% elif field.field.name == "current_websites" %} {% elif field.field.name == "current_websites" %}
{% comment %} {% comment %}

View file

@ -17,6 +17,26 @@
{% endblock %} {% endblock %}
{% block after_related_objects %} {% block after_related_objects %}
{% if portfolios %}
<div class="module aligned padding-3">
<h2>Portfolio information</h2>
<div class="grid-row grid-gap mobile:padding-x-1 desktop:padding-x-4">
<div class="mobile:grid-col-12 tablet:grid-col-6 desktop:grid-col-4">
<h3>Portfolios</h3>
<ul class="margin-0 padding-0">
{% for portfolio in portfolios %}
<li>
<a href="{% url 'admin:registrar_portfolio_change' portfolio.pk %}">
{{ portfolio }}
</a>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% endif %}
<div class="module aligned padding-3"> <div class="module aligned padding-3">
<h2>Associated requests and domains</h2> <h2>Associated requests and domains</h2>
<div class="grid-row grid-gap mobile:padding-x-1 desktop:padding-x-4"> <div class="grid-row grid-gap mobile:padding-x-1 desktop:padding-x-4">

View file

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

View file

@ -63,7 +63,7 @@
<div class="grid-row margin-top-1"> <div class="grid-row margin-top-1">
<div class="grid-col"> <div class="grid-col">
<button type="button" class="usa-button usa-button--unstyled usa-button--with-icon float-right-tablet delete-record"> <button type="button" class="usa-button usa-button--unstyled usa-button--with-icon float-right-tablet delete-record text-secondary line-height-sans-5">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use> <use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
</svg>Delete </svg>Delete

View file

@ -52,7 +52,7 @@
{% endwith %} {% endwith %}
</div> </div>
<div class="tablet:grid-col-2"> <div class="tablet:grid-col-2">
<button type="button" class="usa-button usa-button--unstyled usa-button--with-icon delete-record margin-bottom-075"> <button type="button" class="usa-button usa-button--unstyled usa-button--with-icon delete-record margin-bottom-075 text-secondary line-height-sans-5">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use> <use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
</svg>Delete </svg>Delete

View file

@ -16,6 +16,26 @@
<use xlink:href="{%static 'img/sprite.svg'%}#arrow_back"></use> <use xlink:href="{%static 'img/sprite.svg'%}#arrow_back"></use>
</svg><span class="margin-left-05">Previous step</span> </svg><span class="margin-left-05">Previous step</span>
</a> </a>
{% comment %}
TODO: uncomment in #2596
{% else %}
{% if portfolio %}
{% url 'domain-requests' as url_2 %}
<nav class="usa-breadcrumb padding-top-0" aria-label="Domain request breadcrumb">
<ol class="usa-breadcrumb__list">
<li class="usa-breadcrumb__list-item">
<a href="{{ url_2 }}" class="usa-breadcrumb__link"><span>Domain requests</span></a>
</li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
{% if requested_domain__name %}
<span>{{ requested_domain__name }}</span>
{% else %}
<span>Start a new domain request</span>
{% endif %}
</li>
</ol>
</nav>
{% endif %} {% endcomment %}
{% endif %} {% endif %}
{% block form_messages %} {% block form_messages %}

View file

@ -40,7 +40,7 @@
<h2 class="margin-top-1">Organization contact {{ forloop.counter }}</h2> <h2 class="margin-top-1">Organization contact {{ forloop.counter }}</h2>
</legend> </legend>
<button type="button" class="usa-button usa-button--unstyled display-block float-right-tablet delete-record margin-bottom-2"> <button type="button" class="usa-button usa-button--unstyled display-block float-right-tablet delete-record margin-bottom-2 text-secondary line-height-sans-5">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use> <use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
</svg><span class="margin-left-05">Delete</span> </svg><span class="margin-left-05">Delete</span>

View file

@ -8,15 +8,33 @@
{% block content %} {% block content %}
<main id="main-content" class="grid-container"> <main id="main-content" class="grid-container">
<div class="grid-col desktop:grid-offset-2 desktop:grid-col-8"> <div class="grid-col desktop:grid-offset-2 desktop:grid-col-8">
<a href="{% url 'home' %}" class="breadcrumb__back"> {% comment %}
TODO: Uncomment in #2596
{% if portfolio %}
{% url 'domain-requests' as url %}
<nav class="usa-breadcrumb padding-top-0" aria-label="Domain request breadcrumb">
<ol class="usa-breadcrumb__list">
<li class="usa-breadcrumb__list-item">
<a href="{{ url }}" class="usa-breadcrumb__link"><span>Domain requests</span></a>
</li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>{{ DomainRequest.requested_domain.name }}</span
>
</li>
</ol>
</nav>
{% else %}{% endcomment %}
{% url 'home' as url %}
<a href="{{ url }}" class="breadcrumb__back">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{% static 'img/sprite.svg' %}#arrow_back"></use> <use xlink:href="{% static 'img/sprite.svg' %}#arrow_back"></use>
</svg> </svg>
<p class="margin-left-05 margin-top-0 margin-bottom-0 line-height-sans-1"> <p class="margin-left-05 margin-top-0 margin-bottom-0 line-height-sans-1">
Back to manage your domains Back to manage your domains
</p> </p>
</a> </a>
{% comment %} {% endif %}{% endcomment %}
<h1>Domain request for {{ DomainRequest.requested_domain.name }}</h1> <h1>Domain request for {{ DomainRequest.requested_domain.name }}</h1>
<div <div
class="usa-summary-box dotgov-status-box margin-top-3 padding-left-2" class="usa-summary-box dotgov-status-box margin-top-3 padding-left-2"

View file

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

View file

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

View file

@ -5,10 +5,13 @@
<span id="get_domain_requests_json_url" class="display-none">{{url}}</span> <span id="get_domain_requests_json_url" class="display-none">{{url}}</span>
<section class="section-outlined domain-requests{% if portfolio %} section-outlined--border-base-light{% endif %}" id="domain-requests"> <section class="section-outlined domain-requests{% if portfolio %} section-outlined--border-base-light{% endif %}" id="domain-requests">
<div class="grid-row"> <div class="grid-row">
{% if not has_domain_requests_portfolio_permission %} {% if not portfolio %}
<div class="mobile:grid-col-12 desktop:grid-col-6"> <div class="mobile:grid-col-12 desktop:grid-col-6">
<h2 id="domain-requests-header" class="flex-6">Domain requests</h2> <h2 id="domain-requests-header" class="flex-6">Domain requests</h2>
</div> </div>
{% else %}
<!-- Embedding the portfolio value in a data attribute -->
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>
{% endif %} {% endif %}
<div class="mobile:grid-col-12 desktop:grid-col-6"> <div class="mobile:grid-col-12 desktop:grid-col-6">
<section aria-label="Domain requests search component" class="flex-6 margin-y-2"> <section aria-label="Domain requests search component" class="flex-6 margin-y-2">
@ -45,7 +48,10 @@
<thead> <thead>
<tr> <tr>
<th data-sortable="requested_domain__name" scope="col" role="columnheader">Domain name</th> <th data-sortable="requested_domain__name" scope="col" role="columnheader">Domain name</th>
<th data-sortable="last_submitted_date" scope="col" role="columnheader">Date submitted</th> <th data-sortable="last_submitted_date" scope="col" role="columnheader">Submitted</th>
{% if portfolio %}
<th data-sortable="creator" scope="col" role="columnheader">Created by</th>
{% endif %}
<th data-sortable="status" scope="col" role="columnheader">Status</th> <th data-sortable="status" scope="col" role="columnheader">Status</th>
<th scope="col" role="columnheader"><span class="usa-sr-only">Action</span></th> <th scope="col" role="columnheader"><span class="usa-sr-only">Action</span></th>
<!-- AJAX will conditionally add a th for delete actions --> <!-- AJAX will conditionally add a th for delete actions -->

View file

@ -9,7 +9,6 @@
<div class="section-outlined__header margin-bottom-3 {% if not portfolio %} section-outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}"> <div class="section-outlined__header margin-bottom-3 {% if not portfolio %} section-outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}">
{% if not portfolio %} {% if not portfolio %}
<h2 id="domains-header" class="display-inline-block">Domains</h2> <h2 id="domains-header" class="display-inline-block">Domains</h2>
<span class="display-none" id="no-portfolio-js-flag"></span>
{% else %} {% else %}
<!-- Embedding the portfolio value in a data attribute --> <!-- Embedding the portfolio value in a data attribute -->
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span> <span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>
@ -157,7 +156,7 @@
<th data-sortable="name" scope="col" role="columnheader">Domain name</th> <th data-sortable="name" scope="col" role="columnheader">Domain name</th>
<th data-sortable="expiration_date" scope="col" role="columnheader">Expires</th> <th data-sortable="expiration_date" scope="col" role="columnheader">Expires</th>
<th data-sortable="state_display" scope="col" role="columnheader">Status</th> <th data-sortable="state_display" scope="col" role="columnheader">Status</th>
{% if portfolio and has_view_suborganization %} {% if portfolio and has_view_suborganization_portfolio_permission %}
<th data-sortable="domain_info__sub_organization" scope="col" role="columnheader">Suborganization</th> <th data-sortable="domain_info__sub_organization" scope="col" role="columnheader">Suborganization</th>
{% endif %} {% endif %}
<th <th

View file

@ -37,9 +37,9 @@
</div> </div>
<ul class="usa-nav__primary usa-accordion"> <ul class="usa-nav__primary usa-accordion">
<li class="usa-nav__primary-item"> <li class="usa-nav__primary-item">
{% if has_domains_portfolio_permission %} {% if has_any_domains_portfolio_permission %}
{% url 'domains' as url %} {% url 'domains' as url %}
{%else %} {% else %}
{% url 'no-portfolio-domains' as url %} {% url 'no-portfolio-domains' as url %}
{% endif %} {% endif %}
<a href="{{ url }}" class="usa-nav-link{% if 'domain'|in_path:request.path %} usa-current{% endif %}"> <a href="{{ url }}" class="usa-nav-link{% if 'domain'|in_path:request.path %} usa-current{% endif %}">
@ -51,22 +51,56 @@
Domain groups Domain groups
</a> </a>
</li> --> </li> -->
{% if has_domain_requests_portfolio_permission %} {% if has_organization_requests_flag %}
<li class="usa-nav__primary-item"> <li class="usa-nav__primary-item">
<!-- user has one of the view permissions plus the edit permission, show the dropdown -->
{% if has_edit_request_portfolio_permission %}
{% url 'domain-requests' as url %} {% url 'domain-requests' as url %}
<button
type="button"
class="usa-accordion__button usa-nav__link{% if 'request'|in_path:request.path %} usa-current{% endif %}"
aria-expanded="false"
aria-controls="basic-nav-section-two"
>
<span>Domain requests</span>
</button>
<ul id="basic-nav-section-two" class="usa-nav__submenu">
<li class="usa-nav__submenu-item">
<a href="{{ url }}"
><span>Domain requests</span></a
>
</li>
<li class="usa-nav__submenu-item">
<a href="{% url 'domain-request:' %}"
><span>Start a new domain request</span></a
>
</li>
</ul>
<!-- user has view but no edit permissions -->
{% elif has_any_requests_portfolio_permission %}
{% url 'domain-requests' as url %}
<a href="{{ url }}" class="usa-nav-link{% if 'request'|in_path:request.path %} usa-current{% endif %}">
Domain requests
</a>
<!-- user does not have permissions -->
{% else %}
{% url 'no-portfolio-requests' as url %}
<a href="{{ url }}" class="usa-nav-link{% if 'request'|in_path:request.path %} usa-current{% endif %}"> <a href="{{ url }}" class="usa-nav-link{% if 'request'|in_path:request.path %} usa-current{% endif %}">
Domain requests Domain requests
</a> </a>
</li> {% endif %}
</li>
{% endif %} {% endif %}
{% if has_view_members_portfolio_permission %}
{% if has_organization_members_flag %}
<li class="usa-nav__primary-item"> <li class="usa-nav__primary-item">
<a href="#" class="usa-nav-link"> <a href="#" class="usa-nav-link">
Members Members
</a> </a>
</li> </li>
{% endif %} {% endif %}
<li class="usa-nav__primary-item"> <li class="usa-nav__primary-item">
{% url 'organization' as url %} {% url 'organization' as url %}
<!-- Move the padding from the a to the span so that the descenders do not get cut off --> <!-- Move the padding from the a to the span so that the descenders do not get cut off -->

View file

@ -0,0 +1,30 @@
{% extends 'portfolio_base.html' %}
{% load static %}
{% block title %} Domain Requests | {% endblock %}
{% block portfolio_content %}
<h1 id="domains-header">Current domain requests</h1>
<section class="section-outlined">
<div class="section-outlined__header margin-bottom-3">
<h2 id="domains-header" class="display-inline-block">You dont have access to domain requests.</h2>
{% if portfolio_administrators %}
<p>If you believe you should have access to a request, reach out to your organizations administrators.</p>
<p>Your organizations administrators:</p>
<ul class="margin-top-0">
{% for administrator in portfolio_administrators %}
{% if administrator.email %}
<li>{{ administrator.email }}</li>
{% else %}
<li>{{ administrator }}</li>
{% endif %}
{% endfor %}
</ul>
{% else %}
<p><strong>No administrators were found on your organization.</strong></p>
<p>If you believe you should have access to a request, email <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.</p>
{% endif %}
</div>
</section>
{% endblock %}

View file

@ -11,18 +11,27 @@
{% block portfolio_content %} {% block portfolio_content %}
<div id="main-content"> <div id="main-content">
<h1 id="domain-requests-header">Domain requests</h1> <h1 id="domain-requests-header">Domain requests</h1>
<div class="grid-row grid-gap">
{% comment %} <div class="mobile:grid-col-12 tablet:grid-col-6">
IMPORTANT: <p class="margin-y-0">Domain requests can only be modified by the person who created the request.</p>
If this button is added on any other page, make sure to update the </div>
relevant view to reset request.session["new_request"] = True {% if has_edit_request_portfolio_permission %}
{% endcomment %} <div class="mobile:grid-col-12 tablet:grid-col-6">
<p class="margin-top-4"> {% comment %}
<a href="{% url 'domain-request:' %}" class="usa-button" IMPORTANT:
> If this button is added on any other page, make sure to update the
Start a new domain request relevant view to reset request.session["new_request"] = True
</a> {% endcomment %}
</p> <p class="float-right-tablet tablet:margin-y-0">
<a href="{% url 'domain-request:' %}" class="usa-button"
>
Start a new domain request
</a>
</p>
</div>
{% endif %}
</div>
{% include "includes/domain_requests_table.html" with portfolio=portfolio %} {% include "includes/domain_requests_table.html" with portfolio=portfolio %}
</div> </div>

View file

@ -908,6 +908,7 @@ def completed_domain_request( # noqa
federal_type=None, federal_type=None,
action_needed_reason=None, action_needed_reason=None,
portfolio=None, portfolio=None,
organization_name=None,
): ):
"""A completed domain request.""" """A completed domain request."""
if not user: if not user:
@ -943,7 +944,7 @@ def completed_domain_request( # noqa
federal_type="executive", federal_type="executive",
purpose="Purpose of the site", purpose="Purpose of the site",
is_policy_acknowledged=True, is_policy_acknowledged=True,
organization_name="Testorg", organization_name=organization_name if organization_name else "Testorg",
address_line1="address 1", address_line1="address 1",
address_line2="address 2", address_line2="address 2",
state_territory="NY", state_territory="NY",

View file

@ -2,6 +2,7 @@ from datetime import datetime
from django.utils import timezone from django.utils import timezone
from django.test import TestCase, RequestFactory, Client from django.test import TestCase, RequestFactory, Client
from django.contrib.admin.sites import AdminSite from django.contrib.admin.sites import AdminSite
from django_webtest import WebTest # type: ignore
from api.tests.common import less_console_noise_decorator from api.tests.common import less_console_noise_decorator
from django.urls import reverse from django.urls import reverse
from registrar.admin import ( from registrar.admin import (
@ -41,13 +42,12 @@ from registrar.models import (
TransitionDomain, TransitionDomain,
Portfolio, Portfolio,
Suborganization, Suborganization,
UserPortfolioPermission,
UserDomainRole,
SeniorOfficial,
PortfolioInvitation,
VerifiedByStaff,
) )
from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.senior_official import SeniorOfficial
from registrar.models.user_domain_role import UserDomainRole
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.models.verified_by_staff import VerifiedByStaff
from .common import ( from .common import (
MockDbForSharedTests, MockDbForSharedTests,
AuditedAdminMockData, AuditedAdminMockData,
@ -60,11 +60,12 @@ from .common import (
multiple_unalphabetical_domain_objects, multiple_unalphabetical_domain_objects,
GenericTestHelper, GenericTestHelper,
) )
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from django.contrib.sessions.backends.db import SessionStore from django.contrib.sessions.backends.db import SessionStore
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from unittest.mock import ANY, patch, Mock from unittest.mock import ANY, patch, Mock
from django_webtest import WebTest # type: ignore
import logging import logging
@ -963,7 +964,7 @@ class TestListHeaderAdmin(TestCase):
) )
class TestMyUserAdmin(MockDbForSharedTests): class TestMyUserAdmin(MockDbForSharedTests, WebTest):
"""Tests for the MyUserAdmin class as super or staff user """Tests for the MyUserAdmin class as super or staff user
Notes: Notes:
@ -983,6 +984,7 @@ class TestMyUserAdmin(MockDbForSharedTests):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.app.set_user(self.superuser.username)
self.client = Client(HTTP_HOST="localhost:8080") self.client = Client(HTTP_HOST="localhost:8080")
def tearDown(self): def tearDown(self):
@ -1217,6 +1219,20 @@ class TestMyUserAdmin(MockDbForSharedTests):
self.assertNotContains(response, "Portfolio roles:") self.assertNotContains(response, "Portfolio roles:")
self.assertNotContains(response, "Portfolio additional permissions:") self.assertNotContains(response, "Portfolio additional permissions:")
@less_console_noise_decorator
def test_user_can_see_related_portfolios(self):
"""Tests if a user can see the portfolios they are associated with on the user page"""
portfolio, _ = Portfolio.objects.get_or_create(organization_name="test", creator=self.superuser)
permission, _ = UserPortfolioPermission.objects.get_or_create(
user=self.superuser, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
response = self.app.get(reverse("admin:registrar_user_change", args=[self.superuser.pk]))
expected_href = reverse("admin:registrar_portfolio_change", args=[portfolio.pk])
self.assertContains(response, expected_href)
self.assertContains(response, str(portfolio))
permission.delete()
portfolio.delete()
class AuditedAdminTest(TestCase): class AuditedAdminTest(TestCase):

View file

@ -1,4 +1,5 @@
import copy import copy
import boto3_mocking # type: ignore
from datetime import date, datetime, time from datetime import date, datetime, time
from django.core.management import call_command from django.core.management import call_command
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
@ -8,6 +9,7 @@ from django.utils import timezone
from django.utils.module_loading import import_string from django.utils.module_loading import import_string
import logging import logging
import pyzipper import pyzipper
from django.core.management.base import CommandError
from registrar.management.commands.clean_tables import Command as CleanTablesCommand from registrar.management.commands.clean_tables import Command as CleanTablesCommand
from registrar.management.commands.export_tables import Command as ExportTablesCommand from registrar.management.commands.export_tables import Command as ExportTablesCommand
from registrar.models import ( from registrar.models import (
@ -23,14 +25,17 @@ from registrar.models import (
VerifiedByStaff, VerifiedByStaff,
PublicContact, PublicContact,
FederalAgency, FederalAgency,
Portfolio,
Suborganization,
) )
import tablib import tablib
from unittest.mock import patch, call, MagicMock, mock_open from unittest.mock import patch, call, MagicMock, mock_open
from epplibwrapper import commands, common from epplibwrapper import commands, common
from .common import MockEppLib, less_console_noise, completed_domain_request from .common import MockEppLib, less_console_noise, completed_domain_request, MockSESClient
from api.tests.common import less_console_noise_decorator from api.tests.common import less_console_noise_decorator
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -1408,3 +1413,137 @@ class TestPopulateFederalAgencyInitialsAndFceb(TestCase):
missing_agency.refresh_from_db() missing_agency.refresh_from_db()
self.assertIsNone(missing_agency.initials) self.assertIsNone(missing_agency.initials)
self.assertIsNone(missing_agency.is_fceb) self.assertIsNone(missing_agency.is_fceb)
class TestCreateFederalPortfolio(TestCase):
@less_console_noise_decorator
def setUp(self):
self.mock_client = MockSESClient()
self.user = User.objects.create(username="testuser")
self.federal_agency = FederalAgency.objects.create(agency="Test Federal Agency")
self.senior_official = SeniorOfficial.objects.create(
first_name="first", last_name="last", email="testuser@igorville.gov", federal_agency=self.federal_agency
)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
self.domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
generic_org_type=DomainRequest.OrganizationChoices.CITY,
federal_agency=self.federal_agency,
user=self.user,
)
self.domain_request.approve()
self.domain_info = DomainInformation.objects.filter(domain_request=self.domain_request).get()
self.domain_request_2 = completed_domain_request(
name="sock@igorville.org",
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
generic_org_type=DomainRequest.OrganizationChoices.CITY,
federal_agency=self.federal_agency,
user=self.user,
organization_name="Test Federal Agency",
)
self.domain_request_2.approve()
self.domain_info_2 = DomainInformation.objects.filter(domain_request=self.domain_request_2).get()
def tearDown(self):
DomainInformation.objects.all().delete()
DomainRequest.objects.all().delete()
Suborganization.objects.all().delete()
Portfolio.objects.all().delete()
SeniorOfficial.objects.all().delete()
FederalAgency.objects.all().delete()
User.objects.all().delete()
@less_console_noise_decorator
def run_create_federal_portfolio(self, agency_name, parse_requests=False, parse_domains=False):
with patch(
"registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit",
return_value=True,
):
call_command(
"create_federal_portfolio", agency_name, parse_requests=parse_requests, parse_domains=parse_domains
)
def test_create_or_modify_portfolio(self):
"""Test portfolio creation and modification with suborg and senior official."""
self.run_create_federal_portfolio("Test Federal Agency", parse_requests=True)
portfolio = Portfolio.objects.get(federal_agency=self.federal_agency)
self.assertEqual(portfolio.organization_name, self.federal_agency.agency)
self.assertEqual(portfolio.organization_type, DomainRequest.OrganizationChoices.FEDERAL)
self.assertEqual(portfolio.creator, User.get_default_user())
self.assertEqual(portfolio.notes, "Auto-generated record")
# Test the suborgs
suborganizations = Suborganization.objects.filter(portfolio__federal_agency=self.federal_agency)
self.assertEqual(suborganizations.count(), 1)
self.assertEqual(suborganizations.first().name, "Testorg")
# Test the senior official
self.assertEqual(portfolio.senior_official, self.senior_official)
def test_handle_portfolio_requests(self):
"""Verify portfolio association with domain requests."""
self.run_create_federal_portfolio("Test Federal Agency", parse_requests=True)
self.domain_request.refresh_from_db()
self.assertIsNotNone(self.domain_request.portfolio)
self.assertEqual(self.domain_request.portfolio.federal_agency, self.federal_agency)
self.assertEqual(self.domain_request.sub_organization.name, "Testorg")
def test_handle_portfolio_domains(self):
"""Check portfolio association with domain information."""
self.run_create_federal_portfolio("Test Federal Agency", parse_domains=True)
self.domain_info.refresh_from_db()
self.assertIsNotNone(self.domain_info.portfolio)
self.assertEqual(self.domain_info.portfolio.federal_agency, self.federal_agency)
self.assertEqual(self.domain_info.sub_organization.name, "Testorg")
def test_handle_parse_both(self):
"""Ensure correct parsing of both requests and domains."""
self.run_create_federal_portfolio("Test Federal Agency", parse_requests=True, parse_domains=True)
self.domain_request.refresh_from_db()
self.domain_info.refresh_from_db()
self.assertIsNotNone(self.domain_request.portfolio)
self.assertIsNotNone(self.domain_info.portfolio)
self.assertEqual(self.domain_request.portfolio, self.domain_info.portfolio)
def test_command_error_no_parse_options(self):
"""Verify error when no parse options are provided."""
with self.assertRaisesRegex(
CommandError, "You must specify at least one of --parse_requests or --parse_domains."
):
self.run_create_federal_portfolio("Test Federal Agency")
def test_command_error_agency_not_found(self):
"""Check error handling for non-existent agency."""
expected_message = (
"Cannot find the federal agency 'Non-existent Agency' in our database. "
"The value you enter for `agency_name` must be prepopulated in the FederalAgency table before proceeding."
)
with self.assertRaisesRegex(ValueError, expected_message):
self.run_create_federal_portfolio("Non-existent Agency", parse_requests=True)
def test_update_existing_portfolio(self):
"""Test updating an existing portfolio."""
# Create an existing portfolio
existing_portfolio = Portfolio.objects.create(
federal_agency=self.federal_agency,
organization_name="Test Federal Agency",
organization_type=DomainRequest.OrganizationChoices.CITY,
creator=self.user,
notes="Old notes",
)
self.run_create_federal_portfolio("Test Federal Agency", parse_requests=True)
existing_portfolio.refresh_from_db()
self.assertEqual(existing_portfolio.organization_name, self.federal_agency.agency)
self.assertEqual(existing_portfolio.organization_type, DomainRequest.OrganizationChoices.FEDERAL)
# Notes and creator should be untouched
self.assertEqual(existing_portfolio.notes, "Old notes")
self.assertEqual(existing_portfolio.creator, self.user)

View file

@ -1133,7 +1133,7 @@ class TestPortfolioInvitations(TestCase):
self.portfolio, _ = Portfolio.objects.get_or_create(creator=self.user2, organization_name="Hotel California") self.portfolio, _ = Portfolio.objects.get_or_create(creator=self.user2, organization_name="Hotel California")
self.portfolio_role_base = UserPortfolioRoleChoices.ORGANIZATION_MEMBER self.portfolio_role_base = UserPortfolioRoleChoices.ORGANIZATION_MEMBER
self.portfolio_role_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN self.portfolio_role_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN
self.portfolio_permission_1 = UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS self.portfolio_permission_1 = UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS
self.portfolio_permission_2 = UserPortfolioPermissionChoices.EDIT_REQUESTS self.portfolio_permission_2 = UserPortfolioPermissionChoices.EDIT_REQUESTS
self.invitation, _ = PortfolioInvitation.objects.get_or_create( self.invitation, _ = PortfolioInvitation.objects.get_or_create(
email=self.email, email=self.email,
@ -1324,16 +1324,16 @@ class TestUser(TestCase):
User.objects.all().delete() User.objects.all().delete()
UserDomainRole.objects.all().delete() UserDomainRole.objects.all().delete()
@patch.object(User, "has_edit_suborganization", return_value=True) @patch.object(User, "has_edit_suborganization_portfolio_permission", return_value=True)
def test_portfolio_role_summary_admin(self, mock_edit_suborganization): def test_portfolio_role_summary_admin(self, mock_edit_suborganization):
# Test if the user is recognized as an Admin # Test if the user is recognized as an Admin
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Admin"]) self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Admin"])
@patch.multiple( @patch.multiple(
User, User,
has_view_all_domains_permission=lambda self, portfolio: True, has_view_all_domains_portfolio_permission=lambda self, portfolio: True,
has_domain_requests_portfolio_permission=lambda self, portfolio: True, has_any_requests_portfolio_permission=lambda self, portfolio: True,
has_edit_requests=lambda self, portfolio: True, has_edit_request_portfolio_permission=lambda self, portfolio: True,
) )
def test_portfolio_role_summary_view_only_admin_and_domain_requestor(self): def test_portfolio_role_summary_view_only_admin_and_domain_requestor(self):
# Test if the user has both 'View-only admin' and 'Domain requestor' roles # Test if the user has both 'View-only admin' and 'Domain requestor' roles
@ -1341,8 +1341,8 @@ class TestUser(TestCase):
@patch.multiple( @patch.multiple(
User, User,
has_view_all_domains_permission=lambda self, portfolio: True, has_view_all_domains_portfolio_permission=lambda self, portfolio: True,
has_domain_requests_portfolio_permission=lambda self, portfolio: True, has_any_requests_portfolio_permission=lambda self, portfolio: True,
) )
def test_portfolio_role_summary_view_only_admin(self): def test_portfolio_role_summary_view_only_admin(self):
# Test if the user is recognized as a View-only admin # Test if the user is recognized as a View-only admin
@ -1351,15 +1351,17 @@ class TestUser(TestCase):
@patch.multiple( @patch.multiple(
User, User,
has_base_portfolio_permission=lambda self, portfolio: True, has_base_portfolio_permission=lambda self, portfolio: True,
has_edit_requests=lambda self, portfolio: True, has_edit_request_portfolio_permission=lambda self, portfolio: True,
has_domains_portfolio_permission=lambda self, portfolio: True, has_any_domains_portfolio_permission=lambda self, portfolio: True,
) )
def test_portfolio_role_summary_member_domain_requestor_domain_manager(self): def test_portfolio_role_summary_member_domain_requestor_domain_manager(self):
# Test if the user has 'Member', 'Domain requestor', and 'Domain manager' roles # Test if the user has 'Member', 'Domain requestor', and 'Domain manager' roles
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Domain requestor", "Domain manager"]) self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Domain requestor", "Domain manager"])
@patch.multiple( @patch.multiple(
User, has_base_portfolio_permission=lambda self, portfolio: True, has_edit_requests=lambda self, portfolio: True User,
has_base_portfolio_permission=lambda self, portfolio: True,
has_edit_request_portfolio_permission=lambda self, portfolio: True,
) )
def test_portfolio_role_summary_member_domain_requestor(self): def test_portfolio_role_summary_member_domain_requestor(self):
# Test if the user has 'Member' and 'Domain requestor' roles # Test if the user has 'Member' and 'Domain requestor' roles
@ -1368,7 +1370,7 @@ class TestUser(TestCase):
@patch.multiple( @patch.multiple(
User, User,
has_base_portfolio_permission=lambda self, portfolio: True, has_base_portfolio_permission=lambda self, portfolio: True,
has_domains_portfolio_permission=lambda self, portfolio: True, has_any_domains_portfolio_permission=lambda self, portfolio: True,
) )
def test_portfolio_role_summary_member_domain_manager(self): def test_portfolio_role_summary_member_domain_manager(self):
# Test if the user has 'Member' and 'Domain manager' roles # Test if the user has 'Member' and 'Domain manager' roles
@ -1383,6 +1385,74 @@ class TestUser(TestCase):
# Test if the user has no roles # Test if the user has no roles
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), []) self.assertEqual(self.user.portfolio_role_summary(self.portfolio), [])
@patch("registrar.models.User._has_portfolio_permission")
def test_has_base_portfolio_permission(self, mock_has_permission):
mock_has_permission.return_value = True
self.assertTrue(self.user.has_base_portfolio_permission(self.portfolio))
mock_has_permission.assert_called_once_with(self.portfolio, UserPortfolioPermissionChoices.VIEW_PORTFOLIO)
@patch("registrar.models.User._has_portfolio_permission")
def test_has_edit_org_portfolio_permission(self, mock_has_permission):
mock_has_permission.return_value = True
self.assertTrue(self.user.has_edit_org_portfolio_permission(self.portfolio))
mock_has_permission.assert_called_once_with(self.portfolio, UserPortfolioPermissionChoices.EDIT_PORTFOLIO)
@patch("registrar.models.User._has_portfolio_permission")
def test_has_any_domains_portfolio_permission(self, mock_has_permission):
mock_has_permission.side_effect = [False, True] # First permission false, second permission true
self.assertTrue(self.user.has_any_domains_portfolio_permission(self.portfolio))
self.assertEqual(mock_has_permission.call_count, 2)
mock_has_permission.assert_any_call(self.portfolio, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)
mock_has_permission.assert_any_call(self.portfolio, UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS)
@patch("registrar.models.User._has_portfolio_permission")
def test_has_view_all_domains_portfolio_permission(self, mock_has_permission):
mock_has_permission.return_value = True
self.assertTrue(self.user.has_view_all_domains_portfolio_permission(self.portfolio))
mock_has_permission.assert_called_once_with(self.portfolio, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)
@patch("registrar.models.User._has_portfolio_permission")
@override_flag("organization_requests", active=True)
def test_has_any_requests_portfolio_permission(self, mock_has_permission):
mock_has_permission.side_effect = [False, True] # First permission false, second permission true
self.assertTrue(self.user.has_any_requests_portfolio_permission(self.portfolio))
self.assertEqual(mock_has_permission.call_count, 2)
mock_has_permission.assert_any_call(self.portfolio, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)
mock_has_permission.assert_any_call(self.portfolio, UserPortfolioPermissionChoices.EDIT_REQUESTS)
@patch("registrar.models.User._has_portfolio_permission")
def test_has_view_all_requests_portfolio_permission(self, mock_has_permission):
mock_has_permission.return_value = True
self.assertTrue(self.user.has_view_all_requests_portfolio_permission(self.portfolio))
mock_has_permission.assert_called_once_with(self.portfolio, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)
@patch("registrar.models.User._has_portfolio_permission")
def test_has_edit_request_portfolio_permission(self, mock_has_permission):
mock_has_permission.return_value = True
self.assertTrue(self.user.has_edit_request_portfolio_permission(self.portfolio))
mock_has_permission.assert_called_once_with(self.portfolio, UserPortfolioPermissionChoices.EDIT_REQUESTS)
@patch("registrar.models.User._has_portfolio_permission")
def test_has_view_suborganization_portfolio_permission(self, mock_has_permission):
mock_has_permission.return_value = True
self.assertTrue(self.user.has_view_suborganization_portfolio_permission(self.portfolio))
mock_has_permission.assert_called_once_with(self.portfolio, UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION)
@patch("registrar.models.User._has_portfolio_permission")
def test_has_edit_suborganization_portfolio_permission(self, mock_has_permission):
mock_has_permission.return_value = True
self.assertTrue(self.user.has_edit_suborganization_portfolio_permission(self.portfolio))
mock_has_permission.assert_called_once_with(self.portfolio, UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION)
@less_console_noise_decorator @less_console_noise_decorator
def test_check_transition_domains_without_domains_on_login(self): def test_check_transition_domains_without_domains_on_login(self):
"""A user's on_each_login callback does not check transition domains. """A user's on_each_login callback does not check transition domains.
@ -1545,8 +1615,8 @@ class TestUser(TestCase):
portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California") portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California")
user_can_view_all_domains = self.user.has_domains_portfolio_permission(portfolio) user_can_view_all_domains = self.user.has_any_domains_portfolio_permission(portfolio)
user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission(portfolio) user_can_view_all_requests = self.user.has_any_requests_portfolio_permission(portfolio)
self.assertFalse(user_can_view_all_domains) self.assertFalse(user_can_view_all_domains)
self.assertFalse(user_can_view_all_requests) self.assertFalse(user_can_view_all_requests)
@ -1560,8 +1630,8 @@ class TestUser(TestCase):
], ],
) )
user_can_view_all_domains = self.user.has_domains_portfolio_permission(portfolio) user_can_view_all_domains = self.user.has_any_domains_portfolio_permission(portfolio)
user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission(portfolio) user_can_view_all_requests = self.user.has_any_requests_portfolio_permission(portfolio)
self.assertTrue(user_can_view_all_domains) self.assertTrue(user_can_view_all_domains)
self.assertFalse(user_can_view_all_requests) self.assertFalse(user_can_view_all_requests)
@ -1570,16 +1640,16 @@ class TestUser(TestCase):
portfolio_permission.save() portfolio_permission.save()
portfolio_permission.refresh_from_db() portfolio_permission.refresh_from_db()
user_can_view_all_domains = self.user.has_domains_portfolio_permission(portfolio) user_can_view_all_domains = self.user.has_any_domains_portfolio_permission(portfolio)
user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission(portfolio) user_can_view_all_requests = self.user.has_any_requests_portfolio_permission(portfolio)
self.assertTrue(user_can_view_all_domains) self.assertTrue(user_can_view_all_domains)
self.assertTrue(user_can_view_all_requests) self.assertTrue(user_can_view_all_requests)
UserDomainRole.objects.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(portfolio) user_can_view_all_domains = self.user.has_any_domains_portfolio_permission(portfolio)
user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission(portfolio) user_can_view_all_requests = self.user.has_any_requests_portfolio_permission(portfolio)
self.assertTrue(user_can_view_all_domains) self.assertTrue(user_can_view_all_domains)
self.assertTrue(user_can_view_all_requests) self.assertTrue(user_can_view_all_requests)

View file

@ -12,10 +12,11 @@ from registrar.models import (
) )
from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from .common import create_test_user from .common import MockSESClient, completed_domain_request, create_test_user
from waffle.testutils import override_flag from waffle.testutils import override_flag
from django.contrib.sessions.middleware import SessionMiddleware from django.contrib.sessions.middleware import SessionMiddleware
import boto3_mocking # type: ignore
from django.test import Client
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -24,6 +25,7 @@ logger = logging.getLogger(__name__)
class TestPortfolio(WebTest): class TestPortfolio(WebTest):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.client = Client()
self.user = create_test_user() self.user = create_test_user()
self.domain, _ = Domain.objects.get_or_create(name="igorville.gov") self.domain, _ = Domain.objects.get_or_create(name="igorville.gov")
self.portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California") self.portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California")
@ -76,7 +78,7 @@ class TestPortfolio(WebTest):
def test_middleware_does_not_redirect_if_no_permission(self): def test_middleware_does_not_redirect_if_no_permission(self):
"""Test that user with no portfolio permission is not redirected when attempting to access home""" """Test that user with no portfolio permission is not redirected when attempting to access home"""
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( UserPortfolioPermission.objects.get_or_create(
user=self.user, portfolio=self.portfolio, additional_permissions=[] user=self.user, portfolio=self.portfolio, additional_permissions=[]
) )
self.user.portfolio = self.portfolio self.user.portfolio = self.portfolio
@ -504,7 +506,7 @@ class TestPortfolio(WebTest):
self.client.force_login(self.user) self.client.force_login(self.user)
response = self.client.get(reverse("home"), follow=True) response = self.client.get(reverse("home"), follow=True)
self.assertFalse(self.user.has_domains_portfolio_permission(response.wsgi_request.session.get("portfolio"))) self.assertFalse(self.user.has_any_domains_portfolio_permission(response.wsgi_request.session.get("portfolio")))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "You aren") self.assertContains(response, "You aren")
@ -519,7 +521,7 @@ class TestPortfolio(WebTest):
# Test the domains page - this user should have access # Test the domains page - this user should have access
response = self.client.get(reverse("domains")) response = self.client.get(reverse("domains"))
self.assertTrue(self.user.has_domains_portfolio_permission(response.wsgi_request.session.get("portfolio"))) self.assertTrue(self.user.has_any_domains_portfolio_permission(response.wsgi_request.session.get("portfolio")))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "Domain name") self.assertContains(response, "Domain name")
@ -530,7 +532,7 @@ class TestPortfolio(WebTest):
# Test the domains page - this user should have access # Test the domains page - this user should have access
response = self.client.get(reverse("domains")) response = self.client.get(reverse("domains"))
self.assertTrue(self.user.has_domains_portfolio_permission(response.wsgi_request.session.get("portfolio"))) self.assertTrue(self.user.has_any_domains_portfolio_permission(response.wsgi_request.session.get("portfolio")))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "Domain name") self.assertContains(response, "Domain name")
permission.delete() permission.delete()
@ -547,7 +549,7 @@ class TestPortfolio(WebTest):
portfolio=self.portfolio, portfolio=self.portfolio,
additional_permissions=[ additional_permissions=[
UserPortfolioPermissionChoices.VIEW_PORTFOLIO, UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS, UserPortfolioPermissionChoices.EDIT_REQUESTS,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.EDIT_REQUESTS, UserPortfolioPermissionChoices.EDIT_REQUESTS,
], ],
@ -573,7 +575,7 @@ class TestPortfolio(WebTest):
portfolio=self.portfolio, portfolio=self.portfolio,
additional_permissions=[ additional_permissions=[
UserPortfolioPermissionChoices.VIEW_PORTFOLIO, UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS, UserPortfolioPermissionChoices.EDIT_REQUESTS,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.EDIT_REQUESTS, UserPortfolioPermissionChoices.EDIT_REQUESTS,
], ],
@ -599,7 +601,7 @@ class TestPortfolio(WebTest):
portfolio=self.portfolio, portfolio=self.portfolio,
additional_permissions=[ additional_permissions=[
UserPortfolioPermissionChoices.VIEW_PORTFOLIO, UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS, UserPortfolioPermissionChoices.EDIT_REQUESTS,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.EDIT_REQUESTS, UserPortfolioPermissionChoices.EDIT_REQUESTS,
], ],
@ -630,3 +632,208 @@ class TestPortfolio(WebTest):
self.assertContains(home, "Hotel California") self.assertContains(home, "Hotel California")
self.assertContains(home, "Members") self.assertContains(home, "Members")
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
def test_portfolio_domain_requests_page_when_user_has_no_permissions(self):
"""Test the no requests page"""
UserPortfolioPermission.objects.get_or_create(
user=self.user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
)
self.client.force_login(self.user)
# create and submit a domain request
domain_request = completed_domain_request(user=self.user)
mock_client = MockSESClient()
with boto3_mocking.clients.handler_for("sesv2", mock_client):
domain_request.submit()
domain_request.save()
requests_page = self.client.get(reverse("no-portfolio-requests"), follow=True)
self.assertContains(requests_page, "You dont have access to domain requests.")
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
def test_main_nav_when_user_has_no_permissions(self):
"""Test the nav contains a link to the no requests page"""
UserPortfolioPermission.objects.get_or_create(
user=self.user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
)
self.client.force_login(self.user)
# create and submit a domain request
domain_request = completed_domain_request(user=self.user)
mock_client = MockSESClient()
with boto3_mocking.clients.handler_for("sesv2", mock_client):
domain_request.submit()
domain_request.save()
portfolio_landing_page = self.client.get(reverse("home"), follow=True)
# link to no requests
self.assertContains(portfolio_landing_page, "no-organization-requests/")
# dropdown
self.assertNotContains(portfolio_landing_page, "basic-nav-section-two")
# link to requests
self.assertNotContains(portfolio_landing_page, 'href="/requests/')
# link to create
self.assertNotContains(portfolio_landing_page, 'href="/request/')
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
def test_main_nav_when_user_has_all_permissions(self):
"""Test the nav contains a dropdown with a link to create and another link to view requests
Also test for the existence of the Create a new request btn on the requests page"""
UserPortfolioPermission.objects.get_or_create(
user=self.user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
self.client.force_login(self.user)
# create and submit a domain request
domain_request = completed_domain_request(user=self.user)
mock_client = MockSESClient()
with boto3_mocking.clients.handler_for("sesv2", mock_client):
domain_request.submit()
domain_request.save()
portfolio_landing_page = self.client.get(reverse("home"), follow=True)
# link to no requests
self.assertNotContains(portfolio_landing_page, "no-organization-requests/")
# dropdown
self.assertContains(portfolio_landing_page, "basic-nav-section-two")
# link to requests
self.assertContains(portfolio_landing_page, 'href="/requests/')
# link to create
self.assertContains(portfolio_landing_page, 'href="/request/')
requests_page = self.client.get(reverse("domain-requests"))
# create new request btn
self.assertContains(requests_page, "Start a new domain request")
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
def test_main_nav_when_user_has_view_but_not_edit_permissions(self):
"""Test the nav contains a simple link to view requests
Also test for the existence of the Create a new request btn on the requests page"""
UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
],
)
self.client.force_login(self.user)
# create and submit a domain request
domain_request = completed_domain_request(user=self.user)
mock_client = MockSESClient()
with boto3_mocking.clients.handler_for("sesv2", mock_client):
domain_request.submit()
domain_request.save()
portfolio_landing_page = self.client.get(reverse("home"), follow=True)
# link to no requests
self.assertNotContains(portfolio_landing_page, "no-organization-requests/")
# dropdown
self.assertNotContains(portfolio_landing_page, "basic-nav-section-two")
# link to requests
self.assertContains(portfolio_landing_page, 'href="/requests/')
# link to create
self.assertNotContains(portfolio_landing_page, 'href="/request/')
requests_page = self.client.get(reverse("domain-requests"))
# create new request btn
self.assertNotContains(requests_page, "Start a new domain request")
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
def test_organization_requests_additional_column(self):
"""The requests table has a column for created at"""
self.app.set_user(self.user.username)
UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.EDIT_REQUESTS,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.EDIT_REQUESTS,
],
)
home = self.app.get(reverse("home")).follow()
self.assertContains(home, "Hotel California")
self.assertContains(home, "Domain requests")
domain_requests = self.app.get(reverse("domain-requests"))
self.assertEqual(domain_requests.status_code, 200)
self.assertContains(domain_requests, "Created by")
@less_console_noise_decorator
def test_no_org_requests_no_additional_column(self):
"""The requests table does not have a column for created at"""
self.app.set_user(self.user.username)
home = self.app.get(reverse("home"))
self.assertContains(home, "Domain requests")
self.assertNotContains(home, "Created by")
@less_console_noise_decorator
def test_portfolio_cache_updates_when_modified(self):
"""Test that the portfolio in session updates when the portfolio is modified"""
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):
# Initial request to set the portfolio in session
response = self.client.get(reverse("home"), follow=True)
portfolio = self.client.session.get("portfolio")
self.assertEqual(portfolio.organization_name, "Hotel California")
self.assertContains(response, "Hotel California")
# Modify the portfolio
self.portfolio.organization_name = "Updated Hotel California"
self.portfolio.save()
# Make another request
response = self.client.get(reverse("home"), follow=True)
# Check if the updated portfolio name is in the response
self.assertContains(response, "Updated Hotel California")
# Verify that the session contains the updated portfolio
portfolio = self.client.session.get("portfolio")
self.assertEqual(portfolio.organization_name, "Updated Hotel California")
@less_console_noise_decorator
def test_portfolio_cache_updates_when_flag_disabled_while_logged_in(self):
"""Test that the portfolio in session is set to None when the organization_feature flag is disabled"""
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):
# Initial request to set the portfolio in session
response = self.client.get(reverse("home"), follow=True)
portfolio = self.client.session.get("portfolio")
self.assertEqual(portfolio.organization_name, "Hotel California")
self.assertContains(response, "Hotel California")
# Disable the organization_feature flag
with override_flag("organization_feature", active=False):
# Make another request
response = self.client.get(reverse("home"))
self.assertIsNone(self.client.session.get("portfolio"))
self.assertNotContains(response, "Hotel California")

View file

@ -18,12 +18,14 @@ from registrar.models import (
User, User,
Website, Website,
FederalAgency, FederalAgency,
Portfolio,
UserPortfolioPermission,
) )
from registrar.views.domain_request import DomainRequestWizard, Step from registrar.views.domain_request import DomainRequestWizard, Step
from .common import less_console_noise from .common import less_console_noise
from .test_views import TestWithUser from .test_views import TestWithUser
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -2771,6 +2773,39 @@ class DomainRequestTestDifferentStatuses(TestWithUser, WebTest):
response = self.client.get("/get-domain-requests-json/") response = self.client.get("/get-domain-requests-json/")
self.assertContains(response, "Withdrawn") self.assertContains(response, "Withdrawn")
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
def test_domain_request_withdraw_portfolio_redirects_correctly(self):
"""Tests that the withdraw button on portfolio redirects to the portfolio domain requests page"""
portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Test Portfolio")
UserPortfolioPermission.objects.get_or_create(
user=self.user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.SUBMITTED, user=self.user)
domain_request.save()
detail_page = self.app.get(f"/domain-request/{domain_request.id}")
self.assertContains(detail_page, "city.gov")
self.assertContains(detail_page, "city1.gov")
self.assertContains(detail_page, "Chief Tester")
self.assertContains(detail_page, "testy@town.com")
self.assertContains(detail_page, "Admin Tester")
self.assertContains(detail_page, "Status:")
# click the "Withdraw request" button
mock_client = MockSESClient()
with boto3_mocking.clients.handler_for("sesv2", mock_client):
with less_console_noise():
withdraw_page = detail_page.click("Withdraw request")
self.assertContains(withdraw_page, "Withdraw request for")
home_page = withdraw_page.click("Withdraw request")
# Assert that it redirects to the portfolio requests page and the status has been updated to withdrawn
self.assertEqual(home_page.status_code, 302)
self.assertEqual(home_page.location, reverse("domain-requests"))
response = self.client.get("/get-domain-requests-json/")
self.assertContains(response, "Withdrawn")
@less_console_noise_decorator @less_console_noise_decorator
def test_domain_request_withdraw_no_permissions(self): def test_domain_request_withdraw_no_permissions(self):
"""Can't withdraw domain requests as a restricted user.""" """Can't withdraw domain requests as a restricted user."""

View file

@ -2,9 +2,14 @@ from registrar.models import DomainRequest
from django.urls import reverse from django.urls import reverse
from registrar.models.draft_domain import DraftDomain from registrar.models.draft_domain import DraftDomain
from registrar.models.portfolio import Portfolio
from registrar.models.user import User
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from .test_views import TestWithUser from .test_views import TestWithUser
from django_webtest import WebTest # type: ignore from django_webtest import WebTest # type: ignore
from django.utils.dateparse import parse_datetime from django.utils.dateparse import parse_datetime
from waffle.testutils import override_flag
class GetRequestsJsonTest(TestWithUser, WebTest): class GetRequestsJsonTest(TestWithUser, WebTest):
@ -20,6 +25,19 @@ class GetRequestsJsonTest(TestWithUser, WebTest):
beef_chuck, _ = DraftDomain.objects.get_or_create(name="beef-chuck.gov") beef_chuck, _ = DraftDomain.objects.get_or_create(name="beef-chuck.gov")
stew_beef, _ = DraftDomain.objects.get_or_create(name="stew-beef.gov") stew_beef, _ = DraftDomain.objects.get_or_create(name="stew-beef.gov")
# Create Portfolio
cls.portfolio = Portfolio.objects.create(creator=cls.user, organization_name="Example org")
# create a second user to assign requests to
cls.user2 = User.objects.create(
username="test_user2",
first_name="Second",
last_name="last",
email="info2@example.com",
phone="8003111234",
title="title",
)
# Create domain requests for the user # Create domain requests for the user
cls.domain_requests = [ cls.domain_requests = [
DomainRequest.objects.create( DomainRequest.objects.create(
@ -28,6 +46,7 @@ class GetRequestsJsonTest(TestWithUser, WebTest):
last_submitted_date="2024-01-01", last_submitted_date="2024-01-01",
status=DomainRequest.DomainRequestStatus.STARTED, status=DomainRequest.DomainRequestStatus.STARTED,
created_at="2024-01-01", created_at="2024-01-01",
portfolio=cls.portfolio,
), ),
DomainRequest.objects.create( DomainRequest.objects.create(
creator=cls.user, creator=cls.user,
@ -42,6 +61,7 @@ class GetRequestsJsonTest(TestWithUser, WebTest):
last_submitted_date="2024-03-01", last_submitted_date="2024-03-01",
status=DomainRequest.DomainRequestStatus.REJECTED, status=DomainRequest.DomainRequestStatus.REJECTED,
created_at="2024-03-01", created_at="2024-03-01",
portfolio=cls.portfolio,
), ),
DomainRequest.objects.create( DomainRequest.objects.create(
creator=cls.user, creator=cls.user,
@ -113,6 +133,14 @@ class GetRequestsJsonTest(TestWithUser, WebTest):
status=DomainRequest.DomainRequestStatus.APPROVED, status=DomainRequest.DomainRequestStatus.APPROVED,
created_at="2024-12-01", created_at="2024-12-01",
), ),
DomainRequest.objects.create(
creator=cls.user2,
requested_domain=None,
last_submitted_date="2024-12-01",
status=DomainRequest.DomainRequestStatus.STARTED,
created_at="2024-12-01",
portfolio=cls.portfolio,
),
] ]
@classmethod @classmethod
@ -120,6 +148,7 @@ class GetRequestsJsonTest(TestWithUser, WebTest):
super().tearDownClass() super().tearDownClass()
DomainRequest.objects.all().delete() DomainRequest.objects.all().delete()
DraftDomain.objects.all().delete() DraftDomain.objects.all().delete()
Portfolio.objects.all().delete()
def test_get_domain_requests_json_authenticated(self): def test_get_domain_requests_json_authenticated(self):
"""Test that domain requests are returned properly for an authenticated user.""" """Test that domain requests are returned properly for an authenticated user."""
@ -262,6 +291,118 @@ class GetRequestsJsonTest(TestWithUser, WebTest):
for expected_value, actual_value in zip(expected_domain_values, requested_domains): for expected_value, actual_value in zip(expected_domain_values, requested_domains):
self.assertEqual(expected_value, actual_value) self.assertEqual(expected_value, actual_value)
@override_flag("organization_feature", active=True)
def test_get_domain_requests_json_with_portfolio_view_all_requests(self):
"""Test that an authenticated user gets the list of 3 requests for portfolio. The 3 requests
are the requests that are associated with the portfolio."""
UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS],
)
response = self.app.get(reverse("get_domain_requests_json"), {"portfolio": self.portfolio.id})
self.assertEqual(response.status_code, 200)
data = response.json
# Check pagination info
self.assertEqual(data["page"], 1)
self.assertFalse(data["has_next"])
self.assertFalse(data["has_previous"])
self.assertEqual(data["num_pages"], 1)
# Check the number of requests
self.assertEqual(len(data["domain_requests"]), 3)
# Expected domain requests
expected_domain_requests = [self.domain_requests[0], self.domain_requests[2], self.domain_requests[13]]
# Extract fields from response
domain_request_ids = [domain_request["id"] for domain_request in data["domain_requests"]]
requested_domain = [domain_request["requested_domain"] for domain_request in data["domain_requests"]]
creator = [domain_request["creator"] for domain_request in data["domain_requests"]]
status = [domain_request["status"] for domain_request in data["domain_requests"]]
action_urls = [domain_request["action_url"] for domain_request in data["domain_requests"]]
action_labels = [domain_request["action_label"] for domain_request in data["domain_requests"]]
svg_icons = [domain_request["svg_icon"] for domain_request in data["domain_requests"]]
# Check fields for each domain_request
for i, expected_domain_request in enumerate(expected_domain_requests):
self.assertEqual(expected_domain_request.id, domain_request_ids[i])
if expected_domain_request.requested_domain:
self.assertEqual(expected_domain_request.requested_domain.name, requested_domain[i])
else:
self.assertIsNone(requested_domain[i])
self.assertEqual(expected_domain_request.creator.email, creator[i])
# Check action url, action label and svg icon
# Example domain requests will test each of below three scenarios
if creator[i] != self.user.email:
# Test case where action is View
self.assertEqual("View", action_labels[i])
self.assertEqual("#", action_urls[i])
self.assertEqual("visibility", svg_icons[i])
elif status[i] in [
DomainRequest.DomainRequestStatus.STARTED.label,
DomainRequest.DomainRequestStatus.ACTION_NEEDED.label,
DomainRequest.DomainRequestStatus.WITHDRAWN.label,
]:
# Test case where action is Edit
self.assertEqual("Edit", action_labels[i])
self.assertEqual(
reverse("edit-domain-request", kwargs={"id": expected_domain_request.id}), action_urls[i]
)
self.assertEqual("edit", svg_icons[i])
else:
# Test case where action is Manage
self.assertEqual("Manage", action_labels[i])
self.assertEqual(
reverse("domain-request-status", kwargs={"pk": expected_domain_request.id}), action_urls[i]
)
self.assertEqual("settings", svg_icons[i])
@override_flag("organization_feature", active=True)
def test_get_domain_requests_json_with_portfolio_edit_requests(self):
"""Test that an authenticated user gets the list of 2 requests for portfolio. The 2 requests
are the requests that are associated with the portfolio and owned by self.user."""
UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[UserPortfolioPermissionChoices.EDIT_REQUESTS],
)
response = self.app.get(reverse("get_domain_requests_json"), {"portfolio": self.portfolio.id})
self.assertEqual(response.status_code, 200)
data = response.json
# Check pagination info
self.assertEqual(data["page"], 1)
self.assertFalse(data["has_next"])
self.assertFalse(data["has_previous"])
self.assertEqual(data["num_pages"], 1)
# Check the number of requests
self.assertEqual(len(data["domain_requests"]), 2)
# Expected domain requests
expected_domain_requests = [self.domain_requests[0], self.domain_requests[2]]
# Extract fields from response, since other tests test all fields, only ids and requested
# domains tested in this test
domain_request_ids = [domain_request["id"] for domain_request in data["domain_requests"]]
requested_domain = [domain_request["requested_domain"] for domain_request in data["domain_requests"]]
# Check fields for each domain_request
for i, expected_domain_request in enumerate(expected_domain_requests):
self.assertEqual(expected_domain_request.id, domain_request_ids[i])
if expected_domain_request.requested_domain:
self.assertEqual(expected_domain_request.requested_domain.name, requested_domain[i])
else:
self.assertIsNone(requested_domain[i])
def test_pagination(self): def test_pagination(self):
"""Test that pagination works properly. There are 11 total non-approved requests and """Test that pagination works properly. There are 11 total non-approved requests and
a page size of 10""" a page size of 10"""

View file

@ -173,7 +173,7 @@ class DomainView(DomainBaseView):
If particular views allow permissions, they will need to override If particular views allow permissions, they will need to override
this function.""" this function."""
portfolio = self.request.session.get("portfolio") portfolio = self.request.session.get("portfolio")
if self.request.user.has_domains_portfolio_permission(portfolio): if self.request.user.has_any_domains_portfolio_permission(portfolio):
if Domain.objects.filter(id=pk).exists(): if Domain.objects.filter(id=pk).exists():
domain = Domain.objects.get(id=pk) domain = Domain.objects.get(id=pk)
if domain.domain_info.portfolio == portfolio: if domain.domain_info.portfolio == portfolio:
@ -803,6 +803,23 @@ class DomainAddUserView(DomainFormBaseView):
) )
return None return None
# Check to see if an invite has already been sent
try:
invite = DomainInvitation.objects.get(email=email, domain=self.object)
# check if the invite has already been accepted
if invite.status == DomainInvitation.DomainInvitationStatus.RETRIEVED:
add_success = False
messages.warning(
self.request,
f"{email} is already a manager for this domain.",
)
else:
add_success = False
# else if it has been sent but not accepted
messages.warning(self.request, f"{email} has already been invited to this domain")
except Exception:
logger.error("An error occured")
try: try:
send_templated_email( send_templated_email(
"emails/domain_invitation.txt", "emails/domain_invitation.txt",
@ -828,24 +845,13 @@ class DomainAddUserView(DomainFormBaseView):
def _make_invitation(self, email_address: str, requestor: User): def _make_invitation(self, email_address: str, requestor: User):
"""Make a Domain invitation for this email and redirect with a message.""" """Make a Domain invitation for this email and redirect with a message."""
# Check to see if an invite has already been sent (NOTE: we do not want to create an invite just yet.)
try: try:
invite = DomainInvitation.objects.get(email=email_address, domain=self.object) self._send_domain_invitation_email(email=email_address, requestor=requestor)
# that invitation already existed except EmailSendingError:
if invite is not None: messages.warning(self.request, "Could not send email invitation.")
messages.warning( else:
self.request, # (NOTE: only create a domainInvitation if the e-mail sends correctly)
f"{email_address} has already been invited to this domain.", DomainInvitation.objects.get_or_create(email=email_address, domain=self.object)
)
except DomainInvitation.DoesNotExist:
# Try to send the invitation. If it succeeds, add it to the DomainInvitation table.
try:
self._send_domain_invitation_email(email=email_address, requestor=requestor)
except EmailSendingError:
messages.warning(self.request, "Could not send email invitation.")
else:
# (NOTE: only create a domainInvitation if the e-mail sends correctly)
DomainInvitation.objects.get_or_create(email=email_address, domain=self.object)
return redirect(self.get_success_url()) return redirect(self.get_success_url())
def form_valid(self, form): def form_valid(self, form):
@ -885,11 +891,9 @@ class DomainAddUserView(DomainFormBaseView):
role=UserDomainRole.Roles.MANAGER, role=UserDomainRole.Roles.MANAGER,
) )
except IntegrityError: except IntegrityError:
# User already has the desired role! Do nothing?? messages.warning(self.request, f"{requested_email} is already a manager for this domain")
pass else:
messages.success(self.request, f"Added user {requested_email}.")
messages.success(self.request, f"Added user {requested_email}.")
return redirect(self.get_success_url()) return redirect(self.get_success_url())

View file

@ -148,7 +148,14 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
except DomainRequest.DoesNotExist: except DomainRequest.DoesNotExist:
logger.debug("DomainRequest id %s did not have a DomainRequest" % id) logger.debug("DomainRequest id %s did not have a DomainRequest" % id)
self._domain_request = DomainRequest.objects.create(creator=self.request.user) # If a user is creating a request, we assume that perms are handled upstream
if self.request.user.is_org_user(self.request):
self._domain_request = DomainRequest.objects.create(
creator=self.request.user,
portfolio=self.request.session.get("portfolio"),
)
else:
self._domain_request = DomainRequest.objects.create(creator=self.request.user)
self.storage["domain_request_id"] = self._domain_request.id self.storage["domain_request_id"] = self._domain_request.id
return self._domain_request return self._domain_request
@ -390,6 +397,10 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
def get_context_data(self): def get_context_data(self):
"""Define context for access on all wizard pages.""" """Define context for access on all wizard pages."""
requested_domain_name = None
if self.domain_request.requested_domain is not None:
requested_domain_name = self.domain_request.requested_domain.name
context_stuff = {} context_stuff = {}
if DomainRequest._form_complete(self.domain_request, self.request): if DomainRequest._form_complete(self.domain_request, self.request):
modal_button = '<button type="submit" ' 'class="usa-button" ' ">Submit request</button>" modal_button = '<button type="submit" ' 'class="usa-button" ' ">Submit request</button>"
@ -406,6 +417,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
Youll only be able to withdraw your request.", Youll only be able to withdraw your request.",
"review_form_is_complete": True, "review_form_is_complete": True,
"user": self.request.user, "user": self.request.user,
"requested_domain__name": requested_domain_name,
} }
else: # form is not complete else: # form is not complete
modal_button = '<button type="button" class="usa-button" data-close-modal>Return to request</button>' modal_button = '<button type="button" class="usa-button" data-close-modal>Return to request</button>'
@ -421,6 +433,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
Return to the request and visit the steps that are marked as "incomplete."', Return to the request and visit the steps that are marked as "incomplete."',
"review_form_is_complete": False, "review_form_is_complete": False,
"user": self.request.user, "user": self.request.user,
"requested_domain__name": requested_domain_name,
} }
return context_stuff return context_stuff
@ -497,7 +510,11 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
# if user opted to save progress and return, # if user opted to save progress and return,
# return them to the home page # return them to the home page
if button == "save_and_return": if button == "save_and_return":
return HttpResponseRedirect(reverse("home")) if request.user.is_org_user(request):
return HttpResponseRedirect(reverse("domain-requests"))
else:
return HttpResponseRedirect(reverse("home"))
# otherwise, proceed as normal # otherwise, proceed as normal
return self.goto_next_step() return self.goto_next_step()
@ -757,7 +774,10 @@ class DomainRequestWithdrawn(DomainRequestPermissionWithdrawView):
domain_request = DomainRequest.objects.get(id=self.kwargs["pk"]) domain_request = DomainRequest.objects.get(id=self.kwargs["pk"])
domain_request.withdraw() domain_request.withdraw()
domain_request.save() domain_request.save()
return HttpResponseRedirect(reverse("home")) if self.request.user.is_org_user(self.request):
return HttpResponseRedirect(reverse("domain-requests"))
else:
return HttpResponseRedirect(reverse("home"))
class DomainRequestDeleteView(DomainRequestPermissionDeleteView): class DomainRequestDeleteView(DomainRequestPermissionDeleteView):

View file

@ -10,16 +10,59 @@ from django.db.models import Q
@login_required @login_required
def get_domain_requests_json(request): def get_domain_requests_json(request):
"""Given the current request, """Given the current request,
get all domain requests that are associated with the request user and exclude the APPROVED ones""" get all domain requests that are associated with the request user and exclude the APPROVED ones.
If we are on the portfolio requests page, limit the response to only those requests associated with
the given portfolio."""
domain_requests = DomainRequest.objects.filter(creator=request.user).exclude( domain_request_ids = get_domain_request_ids_from_request(request)
objects = DomainRequest.objects.filter(id__in=domain_request_ids)
unfiltered_total = objects.count()
objects = apply_search(objects, request)
objects = apply_sorting(objects, request)
paginator = Paginator(objects, 10)
page_number = request.GET.get("page", 1)
page_obj = paginator.get_page(page_number)
domain_requests = [
serialize_domain_request(domain_request, request.user) for domain_request in page_obj.object_list
]
return JsonResponse(
{
"domain_requests": domain_requests,
"has_next": page_obj.has_next(),
"has_previous": page_obj.has_previous(),
"page": page_obj.number,
"num_pages": paginator.num_pages,
"total": paginator.count,
"unfiltered_total": unfiltered_total,
}
)
def get_domain_request_ids_from_request(request):
"""Get domain request ids from request.
If portfolio specified, return domain request ids associated with portfolio.
Otherwise, return domain request ids associated with request.user.
"""
portfolio = request.GET.get("portfolio")
filter_condition = Q(creator=request.user)
if portfolio:
if request.user.is_org_user(request) and request.user.has_view_all_requests_portfolio_permission(portfolio):
filter_condition = Q(portfolio=portfolio)
else:
filter_condition = Q(portfolio=portfolio, creator=request.user)
domain_requests = DomainRequest.objects.filter(filter_condition).exclude(
status=DomainRequest.DomainRequestStatus.APPROVED status=DomainRequest.DomainRequestStatus.APPROVED
) )
unfiltered_total = domain_requests.count() return domain_requests.values_list("id", flat=True)
# Handle sorting
sort_by = request.GET.get("sort_by", "id") # Default to 'id' def apply_search(queryset, request):
order = request.GET.get("order", "asc") # Default to 'asc'
search_term = request.GET.get("search_term") search_term = request.GET.get("search_term")
if search_term: if search_term:
@ -30,70 +73,60 @@ def get_domain_requests_json(request):
# If yes, we should return domain requests that do not have a # If yes, we should return domain requests that do not have a
# requested_domain (those display as New domain request in the UI) # requested_domain (those display as New domain request in the UI)
if search_term_lower in new_domain_request_text: if search_term_lower in new_domain_request_text:
domain_requests = domain_requests.filter( queryset = queryset.filter(
Q(requested_domain__name__icontains=search_term) | Q(requested_domain__isnull=True) Q(requested_domain__name__icontains=search_term) | Q(requested_domain__isnull=True)
) )
else: else:
domain_requests = domain_requests.filter(Q(requested_domain__name__icontains=search_term)) queryset = queryset.filter(Q(requested_domain__name__icontains=search_term))
return queryset
def apply_sorting(queryset, request):
sort_by = request.GET.get("sort_by", "id") # Default to 'id'
order = request.GET.get("order", "asc") # Default to 'asc'
if order == "desc": if order == "desc":
sort_by = f"-{sort_by}" sort_by = f"-{sort_by}"
domain_requests = domain_requests.order_by(sort_by) return queryset.order_by(sort_by)
page_number = request.GET.get("page", 1)
paginator = Paginator(domain_requests, 10)
page_obj = paginator.get_page(page_number)
domain_requests_data = [
{ def serialize_domain_request(domain_request, user):
"requested_domain": domain_request.requested_domain.name if domain_request.requested_domain else None, # Determine if the request is deletable
"last_submitted_date": domain_request.last_submitted_date, is_deletable = domain_request.status in [
"status": domain_request.get_status_display(), DomainRequest.DomainRequestStatus.STARTED,
"created_at": format(domain_request.created_at, "c"), # Serialize to ISO 8601 DomainRequest.DomainRequestStatus.WITHDRAWN,
"id": domain_request.id,
"is_deletable": domain_request.status
in [DomainRequest.DomainRequestStatus.STARTED, DomainRequest.DomainRequestStatus.WITHDRAWN],
"action_url": (
reverse("edit-domain-request", kwargs={"id": domain_request.id})
if domain_request.status
in [
DomainRequest.DomainRequestStatus.STARTED,
DomainRequest.DomainRequestStatus.ACTION_NEEDED,
DomainRequest.DomainRequestStatus.WITHDRAWN,
]
else reverse("domain-request-status", kwargs={"pk": domain_request.id})
),
"action_label": (
"Edit"
if domain_request.status
in [
DomainRequest.DomainRequestStatus.STARTED,
DomainRequest.DomainRequestStatus.ACTION_NEEDED,
DomainRequest.DomainRequestStatus.WITHDRAWN,
]
else "Manage"
),
"svg_icon": (
"edit"
if domain_request.status
in [
DomainRequest.DomainRequestStatus.STARTED,
DomainRequest.DomainRequestStatus.ACTION_NEEDED,
DomainRequest.DomainRequestStatus.WITHDRAWN,
]
else "settings"
),
}
for domain_request in page_obj
] ]
return JsonResponse( # Determine action label based on user permissions and request status
{ editable_statuses = [
"domain_requests": domain_requests_data, DomainRequest.DomainRequestStatus.STARTED,
"has_next": page_obj.has_next(), DomainRequest.DomainRequestStatus.ACTION_NEEDED,
"has_previous": page_obj.has_previous(), DomainRequest.DomainRequestStatus.WITHDRAWN,
"page": page_obj.number, ]
"num_pages": paginator.num_pages,
"total": paginator.count, if user.has_edit_request_portfolio_permission and domain_request.creator == user:
"unfiltered_total": unfiltered_total, action_label = "Edit" if domain_request.status in editable_statuses else "Manage"
} else:
) action_label = "View"
# Map the action label to corresponding URLs and icons
action_url_map = {
"Edit": reverse("edit-domain-request", kwargs={"id": domain_request.id}),
"Manage": reverse("domain-request-status", kwargs={"pk": domain_request.id}),
"View": "#",
}
svg_icon_map = {"Edit": "edit", "Manage": "settings", "View": "visibility"}
return {
"requested_domain": domain_request.requested_domain.name if domain_request.requested_domain else None,
"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
"creator": domain_request.creator.email,
"id": domain_request.id,
"is_deletable": is_deletable,
"action_url": action_url_map.get(action_label),
"action_label": action_label,
"svg_icon": svg_icon_map.get(action_label),
}

View file

@ -1,7 +1,7 @@
import logging import logging
from django.http import JsonResponse from django.http import JsonResponse
from django.core.paginator import Paginator from django.core.paginator import Paginator
from registrar.models import UserDomainRole, Domain, DomainInformation from registrar.models import UserDomainRole, Domain, DomainInformation, User
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.urls import reverse from django.urls import reverse
from django.db.models import Q from django.db.models import Q
@ -50,7 +50,8 @@ def get_domain_ids_from_request(request):
""" """
portfolio = request.GET.get("portfolio") portfolio = request.GET.get("portfolio")
if portfolio: if portfolio:
if request.user.is_org_user(request) and request.user.has_view_all_domains_permission(portfolio): current_user: User = request.user
if current_user.is_org_user(request) and current_user.has_view_all_domains_portfolio_permission(portfolio):
domain_infos = DomainInformation.objects.filter(portfolio=portfolio) domain_infos = DomainInformation.objects.filter(portfolio=portfolio)
return domain_infos.values_list("domain_id", flat=True) return domain_infos.values_list("domain_id", flat=True)
else: else:

View file

@ -42,12 +42,41 @@ class PortfolioDomainRequestsView(PortfolioDomainRequestsPermissionView, View):
class PortfolioNoDomainsView(NoPortfolioDomainsPermissionView, View): class PortfolioNoDomainsView(NoPortfolioDomainsPermissionView, View):
"""Some users have access to the underlying portfolio, but not any domains. """Some users have access to the underlying portfolio, but not any domains.
This is a custom view which explains that to the user - and denotes who to contact. This is a custom view which explains that to the user - and denotes who to contact.
""" """
model = Portfolio model = Portfolio
template_name = "no_portfolio_domains.html" template_name = "portfolio_no_domains.html"
def get(self, request):
return render(request, self.template_name, context=self.get_context_data())
def get_context_data(self, **kwargs):
"""Add additional context data to the template."""
# We can override the base class. This view only needs this item.
context = {}
portfolio = self.request.session.get("portfolio")
if portfolio:
admin_ids = UserPortfolioPermission.objects.filter(
portfolio=portfolio,
roles__overlap=[
UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
],
).values_list("user__id", flat=True)
admin_users = User.objects.filter(id__in=admin_ids)
context["portfolio_administrators"] = admin_users
return context
class PortfolioNoDomainRequestsView(NoPortfolioDomainsPermissionView, View):
"""Some users have access to the underlying portfolio, but not any domain requests.
This is a custom view which explains that to the user - and denotes who to contact.
"""
model = Portfolio
template_name = "portfolio_no_requests.html"
def get(self, request): def get(self, request):
return render(request, self.template_name, context=self.get_context_data()) return render(request, self.template_name, context=self.get_context_data())

View file

@ -433,7 +433,7 @@ class PortfolioDomainsPermission(PortfolioBasePermission):
up from the portfolio's primary key in self.kwargs["pk"]""" up from the portfolio's primary key in self.kwargs["pk"]"""
portfolio = self.request.session.get("portfolio") portfolio = self.request.session.get("portfolio")
if not self.request.user.has_domains_portfolio_permission(portfolio): if not self.request.user.has_any_domains_portfolio_permission(portfolio):
return False return False
return super().has_permission() return super().has_permission()
@ -450,7 +450,7 @@ class PortfolioDomainRequestsPermission(PortfolioBasePermission):
up from the portfolio's primary key in self.kwargs["pk"]""" up from the portfolio's primary key in self.kwargs["pk"]"""
portfolio = self.request.session.get("portfolio") portfolio = self.request.session.get("portfolio")
if not self.request.user.has_domain_requests_portfolio_permission(portfolio): if not self.request.user.has_any_requests_portfolio_permission(portfolio):
return False return False
return super().has_permission() return super().has_permission()