resolved merge

This commit is contained in:
CocoByte 2024-11-26 13:37:30 -07:00
commit bf34df52dd
No known key found for this signature in database
GPG key ID: BBFAA2526384C97F
96 changed files with 4278 additions and 1324 deletions

View file

@ -1,4 +1,9 @@
name: Clone Staging Database
# This workflow runs at the top of every hour and can be manually run as needed
# The workflow will copy the database from stable (production) to our staging sandbox.
# This workflow may fail if changes to the database schema make objects in stable
# incompatible with staging. This should resolve once both schemas match again.
name: Clone Stable Database
on:
schedule:
@ -8,16 +13,19 @@ on:
workflow_dispatch:
env:
DESTINATION_ENVIRONMENT: ms
SOURCE_ENVIRONMENT: staging
# sandbox receiving the cloned db
DESTINATION_ENVIRONMENT: staging
# sandbox we are cloning
SOURCE_ENVIRONMENT: stable
jobs:
clone-database:
runs-on: ubuntu-24.04
env:
CF_USERNAME: ${{ secrets.CF_MS_USERNAME }}
CF_PASSWORD: ${{ secrets.CF_MS_PASSWORD }}
# must be the github secrets for the receiving sandbox
CF_USERNAME: ${{ secrets.CF_STAGING_USERNAME }}
CF_PASSWORD: ${{ secrets.CF_STAGING_PASSWORD }}
steps:
- name: Clone Database
run: |
@ -41,7 +49,17 @@ jobs:
# clone from source to destination
cf target -s $SOURCE_ENVIRONMENT
cg-manage-rds clone getgov-$DESTINATION_ENVIRONMENT-database getgov-$SOURCE_ENVIRONMENT-database
cg-manage-rds clone getgov-$SOURCE_ENVIRONMENT-database getgov-$DESTINATION_ENVIRONMENT-database
- name: Load Fixtures
uses: cloud-gov/cg-cli-tools@main
with:
cf_username: ${{ secrets.CF_STAGING_USERNAME }}
cf_password: ${{ secrets.CF_STAGING_PASSWORD }}
cf_org: cisa-dotgov
cf_space: ${{ env.DESTINATION_ENVIRONMENT }}
cf_command: "run-task getgov-staging --command 'python manage.py load' --name fixtures"
- name: Cleanup
if: always()
run: cf unshare-service getgov-$DESTINATION_ENVIRONMENT-database -s $SOURCE_ENVIRONMENT -f

View file

@ -71,6 +71,8 @@ jobs:
comment:
runs-on: ubuntu-latest
needs: [variables, deploy]
permissions:
pull-requests: write
steps:
- uses: actions/github-script@v6
env:

View file

@ -9,6 +9,8 @@ on:
jobs:
notify:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: jenschelkopf/issue-label-notification-action@1.3
with:

3
.gitignore vendored
View file

@ -171,6 +171,9 @@ node_modules
# Vim
*.swp
# VS Code
.vscode
# Compliance/trestle related
docs/compliance/.trestle/cache

View file

@ -16,6 +16,8 @@ The following set of rules should be followed while an incident is in progress.
- If downtime occurs outside of working hours, team members who are off for the day may still be pinged and called but are not required to join if unavailable to do so.
- Uncomment the [banner on get.gov](https://github.com/cisagov/get.gov/blob/0365d3d34b041cc9353497b2b5f81b6ab7fe75a9/_includes/header.html#L9), so it is transparent to users that we know about the issue on manage.get.gov.
- Designers or Developers should be able to make this change; if designers are online and can help with this task, that will allow developers to focus on fixing the bug.
- Uncomment the [banner on manage.get.gov's base template](https://github.com/cisagov/manage.get.gov/blob/main/src/registrar/templates/base.html#L78).
- Designers or Developers should be able to make this change; if designers are online and can help with this task, that will allow developers to focus on fixing the bug.
- If the issue persists for three hours or more, follow the [instructions for enabling/disabling a redirect to get.gov](https://docs.google.com/document/d/1PiWXpjBzbiKsSYqEo9Rkl72HMytMp7zTte9CI-vvwYw/edit).
## Post Incident

View file

@ -3,7 +3,7 @@
Secrets are read from the running environment.
Secrets were originally created with:
Secrets are originally created with:
```sh
cf cups getgov-credentials -p credentials-<ENVIRONMENT>.json
@ -38,6 +38,49 @@ cf restage getgov-stable --strategy rolling
Non-secret environment variables can be declared in `manifest-<ENVIRONMENT>.json` directly.
## Rotating login.gov credentials
The DJANGO_SECRET_KEY and DJANGO_SECRET_LOGIN_KEY are reset once a year for each sandbox, see their sections below for more information on them and how to manually generate these keys. To save time, complete the following steps to rotate these credentials using a script in non-production environments:
### Step 1 login
To run the script make sure you are logged on the cf cli and make sure you have access to the [Login Partner Dashboard](https://dashboard.int.identitysandbox.gov/service_providers/2640).
### Step 2 Run the script
Run the following where "ENV" refers to whichever sandbox you want to reset credentials on. Note, the below assumes you are in the root directory of our app.
```bash
ops/scripts/rotate_login_certs.sh ENV
```
### Step 3 Respond to the terminal prompts
Respond to the prompts from the script and, when it asks for the cert information, the below is an example of what you should enter. Note for "Common Name" you should put the name of the sandbox and for "Email Address" it should be the address of who owns that sandbox (such as the developer's email, if it's a develop sandbox, or whoever ran this action otherwise)
```bash
Country Name (2 letter code) [AU]:US
State or Province Name (full name) [Some-State]:DC
Locality Name (eg, city) []:DC
Organization Name (eg, company) [Internet Widgits Pty Ltd]:DHS
Organizational Unit Name (eg, section) []:CISA
Common Name (e.g. server FQDN or YOUR name) []:ENV
Email Address []: example@something.com
```
Note when this script is done it will have generated a .pem and a .crt file, as well as updated the cert info on the sandbox
### Step 4 Delete the old cert
Navigate to to the Login Partner Dashboard linked above and delete the old cert
### Step 5 add the new cert
In whichever directory you ran the script there should now be a .crt file named "public-ENV.crt", where ENV is the space name you used on Step 2. Upload this cert in the Login Partner Dashboard in the same section where you deleted the old one.
### Production only
This script should not be run in production. Instead, you will need to manually create the keys and then refrain from updating the sandbox. Once the cert is created you will upload it to the Login Partner Dashboard for our production system, and then open a ticket with them to update our existing Login.gov integration. Once they respond back saying it has been applied, you can then update the sandbox.
## DJANGO_SECRET_KEY
This is a standard Django secret key. See Django documentation for tips on generating a new one.
@ -46,6 +89,7 @@ This is a standard Django secret key. See Django documentation for tips on gener
This is the base64 encoded private key used in the OpenID Connect authentication flow with Login.gov. It is used to sign a token during user login; the signature is examined by Login.gov before their API grants access to user data.
### Manually creating creating the Login Key
Generate a new key using this command (or whatever is most recently [recommended by Login.gov](https://developers.login.gov/testing/#creating-a-public-certificate)):
```bash
@ -60,6 +104,8 @@ base64 private.pem
You also need to upload the `public.crt` key if recently created to the login.gov identity sandbox: https://dashboard.int.identitysandbox.gov/
## AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
To access the AWS Simple Email Service, we need credentials from the CISA AWS
@ -76,6 +122,8 @@ These are the client certificate and its private key used to identify the regist
The private key is protected by a passphrase for safer transport and storage.
Note this must be reset once a year.
These were generated with the following steps:
### Step 1: Generate an unencrypted private key with a named curve
@ -90,7 +138,7 @@ openssl ecparam -name prime256v1 -genkey -out client_unencrypted.key
openssl pkcs8 -topk8 -v2 aes-256-cbc -in client_unencrypted.key -out client.key
```
### Generate the certificate
### Step 3: Generate the certificate
```bash
openssl req -new -x509 -days 365 -key client.key -out client.crt -subj "/C=US/ST=DC/L=Washington/O=GSA/OU=18F/CN=GOV Prototype Registrar"
@ -112,7 +160,7 @@ base64 -i client.key
base64 -i client.crt
```
You'll need to give the new certificate to the registry vendor _before_ rotating it in production. Once it has been accepted by the vendor, make sure to update the kdbx file on Google Drive.
You'll need to give the new certificate to the registry vendor _before_ rotating it in production. Once it has been accepted by the vendor, make sure to update [the KBDX](https://docs.google.com/document/d/1_BbJmjYZNYLNh4jJPPnUEG9tFCzJrOc0nMrZrnSKKyw) file on Google Drive.
## REGISTRY_HOSTNAME

View file

@ -0,0 +1,51 @@
# This script rotates the login.gov credentials, DJANGO_SECRET_KEY and DJANGO_SECRET_LOGIN_KEY that allow for identity sandbox to work on sandboxes and local.
# The echo prints in this script should serve for documentation for running manually.
# Run this script once a year for each environment
# NOTE: This script was written for MacOS and to be run at the root directory.
if [ -z "$1" ]; then
echo 'Please specify a space to update (i.e. lmm)' >&2
exit 1
fi
echo "You need access to the Login partner dashboard, otherwise you will not be able to complete the steps in this script (https://dashboard.int.identitysandbox.gov/service_providers/2640)"
read -p " Do you have access to the partner dashboard mentioned above? (y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1
fi
if [ ! $(command -v jq) ] || [ ! $(command -v cf) ]; then
echo "jq, and cf packages must be installed. Please install via your preferred manager."
exit 1
fi
cf target -o cisa-dotgov
read -p "Are you logged in to the cisa-dotgov CF org above? (y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]
then
cf login -a https://api.fr.cloud.gov --sso
fi
echo "Targeting space"
cf target -o cisa-dotgov -s $1
echo "Creating new login.gov credentials for $1..."
django_key=$(python3 -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())')
openssl req -noenc -x509 -days 365 -newkey rsa:2048 -keyout private-$1.pem -out public-$1.crt
login_key=$(base64 -i private-$1.pem)
echo "Creating the final json"
cf env getgov-$1 | awk '/VCAP_SERVICES: /,/^$/' | sed s/VCAP_SERVICES:// | jq '."user-provided"[0].credentials' | jq --arg django_key "$django_key" --arg login_key "$login_key" '. + {"DJANGO_SECRET_KEY":$django_key, "DJANGO_SECRET_LOGIN_KEY":$login_key}' > credentials-$1.json
echo "Updating creds on the sandbox"
cf uups getgov-credentials -p credentials-$1.json
cf restage getgov-$1 --strategy rolling
echo "\n\n\nNow you will need to update some things for Login. Please sign-in to https://dashboard.int.identitysandbox.gov/."
echo "Navigate to our application config: https://dashboard.int.identitysandbox.gov/service_providers/2640/edit?"
echo "There are two things to update."
echo "1. Remove the old cert associated with the user's email (under Public Certificates)"
echo "2. You need to upload the public-$1.crt file generated as part of the previous command. See the "choose cert file" button under Public Certificates."
echo "Then, tell the developer to update their local .env file by retrieving their credentials from the sandbox"

View file

@ -7,6 +7,7 @@
"http://localhost:8080/",
"http://localhost:8080/health/",
"http://localhost:8080/request/",
"http://localhost:8080/request/start",
"http://localhost:8080/request/organization/",
"http://localhost:8080/request/org_federal/",
"http://localhost:8080/request/org_election/",

View file

@ -1,12 +1,14 @@
from datetime import date
import logging
import copy
from typing import Optional
from django import forms
from django.db.models import Value, CharField, Q
from django.db.models.functions import Concat, Coalesce
from django.http import HttpResponseRedirect
from registrar.models.federal_agency import FederalAgency
from registrar.utility.admin_helpers import (
AutocompleteSelectWithPlaceholder,
get_action_needed_reason_default_email,
get_rejection_reason_default_email,
get_field_links_as_list,
@ -236,6 +238,14 @@ class DomainRequestAdminForm(forms.ModelForm):
"current_websites": NoAutocompleteFilteredSelectMultiple("current_websites", False),
"alternative_domains": NoAutocompleteFilteredSelectMultiple("alternative_domains", False),
"other_contacts": NoAutocompleteFilteredSelectMultiple("other_contacts", False),
"portfolio": AutocompleteSelectWithPlaceholder(
DomainRequest._meta.get_field("portfolio"), admin.site, attrs={"data-placeholder": "---------"}
),
"sub_organization": AutocompleteSelectWithPlaceholder(
DomainRequest._meta.get_field("sub_organization"),
admin.site,
attrs={"data-placeholder": "---------", "ajax-url": "get-suborganization-list-json"},
),
}
labels = {
"action_needed_reason_email": "Email",
@ -1952,6 +1962,70 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
custom_election_board.admin_order_field = "is_election_board" # type: ignore
custom_election_board.short_description = "Election office" # type: ignore
# Define methods to display fields from the related portfolio
def portfolio_senior_official(self, obj) -> Optional[SeniorOfficial]:
return obj.portfolio.senior_official if obj.portfolio and obj.portfolio.senior_official else None
portfolio_senior_official.short_description = "Senior official" # type: ignore
def portfolio_organization_type(self, obj):
return (
DomainRequest.OrganizationChoices.get_org_label(obj.portfolio.organization_type)
if obj.portfolio and obj.portfolio.organization_type
else "-"
)
portfolio_organization_type.short_description = "Organization type" # type: ignore
def portfolio_federal_type(self, obj):
return (
BranchChoices.get_branch_label(obj.portfolio.federal_type)
if obj.portfolio and obj.portfolio.federal_type
else "-"
)
portfolio_federal_type.short_description = "Federal type" # type: ignore
def portfolio_organization_name(self, obj):
return obj.portfolio.organization_name if obj.portfolio else ""
portfolio_organization_name.short_description = "Organization name" # type: ignore
def portfolio_federal_agency(self, obj):
return obj.portfolio.federal_agency if obj.portfolio else ""
portfolio_federal_agency.short_description = "Federal agency" # type: ignore
def portfolio_state_territory(self, obj):
return obj.portfolio.state_territory if obj.portfolio else ""
portfolio_state_territory.short_description = "State, territory, or military post" # type: ignore
def portfolio_address_line1(self, obj):
return obj.portfolio.address_line1 if obj.portfolio else ""
portfolio_address_line1.short_description = "Address line 1" # type: ignore
def portfolio_address_line2(self, obj):
return obj.portfolio.address_line2 if obj.portfolio else ""
portfolio_address_line2.short_description = "Address line 2" # type: ignore
def portfolio_city(self, obj):
return obj.portfolio.city if obj.portfolio else ""
portfolio_city.short_description = "City" # type: ignore
def portfolio_zipcode(self, obj):
return obj.portfolio.zipcode if obj.portfolio else ""
portfolio_zipcode.short_description = "Zip code" # type: ignore
def portfolio_urbanization(self, obj):
return obj.portfolio.urbanization if obj.portfolio else ""
portfolio_urbanization.short_description = "Urbanization" # type: ignore
# This is just a placeholder. This field will be populated in the detail_table_fieldset view.
# This is not a field that exists on the model.
def status_history(self, obj):
@ -1983,30 +2057,38 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
None,
{
"fields": [
"portfolio",
"sub_organization",
"requested_suborganization",
"suborganization_city",
"suborganization_state_territory",
"status_history",
"status",
"rejection_reason",
"rejection_reason_email",
"action_needed_reason",
"action_needed_reason_email",
"investigator",
"creator",
"approved_domain",
"investigator",
"notes",
]
},
),
(
"Requested by",
{
"fields": [
"portfolio",
"sub_organization",
"requested_suborganization",
"suborganization_city",
"suborganization_state_territory",
"creator",
]
},
),
(".gov domain", {"fields": ["requested_domain", "alternative_domains"]}),
(
"Contacts",
{
"fields": [
"senior_official",
"portfolio_senior_official",
"other_contacts",
"no_other_contacts_rationale",
"cisa_representative_first_name",
@ -2063,10 +2145,55 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
],
},
),
# the below three sections are for portfolio fields
(
"Type of organization",
{
"fields": [
"portfolio_organization_type",
"portfolio_federal_type",
]
},
),
(
"Organization name and mailing address",
{
"fields": [
"portfolio_organization_name",
"portfolio_federal_agency",
]
},
),
(
"Show details",
{
"classes": ["collapse--dgfieldset"],
"description": "Extends organization name and mailing address",
"fields": [
"portfolio_state_territory",
"portfolio_address_line1",
"portfolio_address_line2",
"portfolio_city",
"portfolio_zipcode",
"portfolio_urbanization",
],
},
),
]
# Readonly fields for analysts and superusers
readonly_fields = (
"portfolio_senior_official",
"portfolio_organization_type",
"portfolio_federal_type",
"portfolio_organization_name",
"portfolio_federal_agency",
"portfolio_state_territory",
"portfolio_address_line1",
"portfolio_address_line2",
"portfolio_city",
"portfolio_zipcode",
"portfolio_urbanization",
"other_contacts",
"current_websites",
"alternative_domains",
@ -2115,10 +2242,12 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
def get_fieldsets(self, request, obj=None):
fieldsets = super().get_fieldsets(request, obj)
# Hide certain suborg fields behind the organization feature flag
# Hide certain portfolio and suborg fields behind the organization requests flag
# if it is not enabled
if not flag_is_active_for_user(request.user, "organization_feature"):
if not flag_is_active_for_user(request.user, "organization_requests"):
excluded_fields = [
"portfolio",
"sub_organization",
"requested_suborganization",
"suborganization_city",
"suborganization_state_territory",

File diff suppressed because one or more lines are too long

View file

@ -86,6 +86,506 @@ function handleSuborganizationFields(
portfolioDropdown.on("change", toggleSuborganizationFields);
}
/**
* This function handles the portfolio selection as well as display of
* portfolio-related fields in the DomainRequest Form.
*
* IMPORTANT NOTE: The logic in this method is paired dynamicPortfolioFields
*/
function handlePortfolioSelection() {
// These dropdown are select2 fields so they must be interacted with via jquery
const portfolioDropdown = django.jQuery("#id_portfolio");
const suborganizationDropdown = django.jQuery("#id_sub_organization");
const suborganizationField = document.querySelector(".field-sub_organization");
const requestedSuborganizationField = document.querySelector(".field-requested_suborganization");
const suborganizationCity = document.querySelector(".field-suborganization_city");
const suborganizationStateTerritory = document.querySelector(".field-suborganization_state_territory");
const seniorOfficialField = document.querySelector(".field-senior_official");
const otherEmployeesField = document.querySelector(".field-other_contacts");
const noOtherContactsRationaleField = document.querySelector(".field-no_other_contacts_rationale");
const cisaRepresentativeFirstNameField = document.querySelector(".field-cisa_representative_first_name");
const cisaRepresentativeLastNameField = document.querySelector(".field-cisa_representative_last_name");
const cisaRepresentativeEmailField = document.querySelector(".field-cisa_representative_email");
const orgTypeFieldSet = document.querySelector(".field-is_election_board").parentElement;
const orgTypeFieldSetDetails = orgTypeFieldSet.nextElementSibling;
const orgNameFieldSet = document.querySelector(".field-organization_name").parentElement;
const orgNameFieldSetDetails = orgNameFieldSet.nextElementSibling;
const portfolioSeniorOfficialField = document.querySelector(".field-portfolio_senior_official");
const portfolioSeniorOfficial = portfolioSeniorOfficialField.querySelector(".readonly");
const portfolioSeniorOfficialAddress = portfolioSeniorOfficialField.querySelector(".dja-address-contact-list");
const portfolioOrgTypeFieldSet = document.querySelector(".field-portfolio_organization_type").parentElement;
const portfolioOrgType = document.querySelector(".field-portfolio_organization_type .readonly");
const portfolioFederalTypeField = document.querySelector(".field-portfolio_federal_type");
const portfolioFederalType = portfolioFederalTypeField.querySelector(".readonly");
const portfolioOrgNameField = document.querySelector(".field-portfolio_organization_name")
const portfolioOrgName = portfolioOrgNameField.querySelector(".readonly");
const portfolioOrgNameFieldSet = portfolioOrgNameField.parentElement;
const portfolioOrgNameFieldSetDetails = portfolioOrgNameFieldSet.nextElementSibling;
const portfolioFederalAgencyField = document.querySelector(".field-portfolio_federal_agency");
const portfolioFederalAgency = portfolioFederalAgencyField.querySelector(".readonly");
const portfolioStateTerritory = document.querySelector(".field-portfolio_state_territory .readonly");
const portfolioAddressLine1 = document.querySelector(".field-portfolio_address_line1 .readonly");
const portfolioAddressLine2 = document.querySelector(".field-portfolio_address_line2 .readonly");
const portfolioCity = document.querySelector(".field-portfolio_city .readonly");
const portfolioZipcode = document.querySelector(".field-portfolio_zipcode .readonly");
const portfolioUrbanizationField = document.querySelector(".field-portfolio_urbanization");
const portfolioUrbanization = portfolioUrbanizationField.querySelector(".readonly");
const portfolioJsonUrl = document.getElementById("portfolio_json_url")?.value || null;
let isPageLoading = true;
/**
* Fetches portfolio data by ID using an AJAX call.
*
* @param {number|string} portfolio_id - The ID of the portfolio to retrieve.
* @returns {Promise<Object|null>} - A promise that resolves to the portfolio data object if successful,
* or null if there was an error.
*
* This function performs an asynchronous fetch request to retrieve portfolio data.
* If the request is successful, it returns the portfolio data as an object.
* If an error occurs during the request or the data contains an error, it logs the error
* to the console and returns null.
*/
function getPortfolio(portfolio_id) {
return fetch(`${portfolioJsonUrl}?id=${portfolio_id}`)
.then(response => response.json())
.then(data => {
if (data.error) {
console.error("Error in AJAX call: " + data.error);
return null;
} else {
return data;
}
})
.catch(error => {
console.error("Error retrieving portfolio", error);
return null;
});
}
/**
* Updates various UI elements with the data from a given portfolio object.
*
* @param {Object} portfolio - The portfolio data object containing values to populate in the UI.
*
* This function updates multiple fields in the UI to reflect data in the `portfolio` object:
* - Clears and replaces selections in the `suborganizationDropdown` with values from `portfolio.suborganizations`.
* - Calls `updatePortfolioSeniorOfficial` to set the senior official information.
* - Sets the portfolio organization type, federal type, name, federal agency, and other address-related fields.
*
* The function expects that elements like `portfolioOrgType`, `portfolioFederalAgency`, etc.,
* are already defined and accessible in the global scope.
*/
function updatePortfolioFieldsData(portfolio) {
// replace selections in suborganizationDropdown with
// values in portfolio.suborganizations
suborganizationDropdown.empty();
// update portfolio senior official
updatePortfolioSeniorOfficial(portfolio.senior_official);
// update portfolio organization type
portfolioOrgType.innerText = portfolio.organization_type;
// update portfolio federal type
portfolioFederalType.innerText = portfolio.federal_type
// update portfolio organization name
portfolioOrgName.innerText = portfolio.organization_name;
// update portfolio federal agency
portfolioFederalAgency.innerText = portfolio.federal_agency ? portfolio.federal_agency.agency : '';
// update portfolio state
portfolioStateTerritory.innerText = portfolio.state_territory;
// update portfolio address line 1
portfolioAddressLine1.innerText = portfolio.address_line1;
// update portfolio address line 2
portfolioAddressLine2.innerText = portfolio.address_line2;
// update portfolio city
portfolioCity.innerText = portfolio.city;
// update portfolio zip code
portfolioZipcode.innerText = portfolio.zipcode
// update portfolio urbanization
portfolioUrbanization.innerText = portfolio.urbanization;
}
/**
* Updates the UI to display the senior official information from a given object.
*
* @param {Object} senior_official - The senior official's data object, containing details like
* first name, last name, and ID. If `senior_official` is null, displays a default message.
*
* This function:
* - Displays the senior official's name as a link (if available) in the `portfolioSeniorOfficial` element.
* - If a senior official exists, it sets `portfolioSeniorOfficialAddress` to show the official's contact info
* and displays it by calling `updateSeniorOfficialContactInfo`.
* - If no senior official is provided, it hides `portfolioSeniorOfficialAddress` and shows a "No senior official found." message.
*
* Dependencies:
* - Expects the `portfolioSeniorOfficial` and `portfolioSeniorOfficialAddress` elements to be available globally.
* - Uses `showElement` and `hideElement` for visibility control.
*/
function updatePortfolioSeniorOfficial(senior_official) {
if (senior_official) {
let seniorOfficialName = [senior_official.first_name, senior_official.last_name].join(' ');
let seniorOfficialLink = `<a href=/admin/registrar/seniorofficial/${senior_official.id}/change/ class='test'>${seniorOfficialName}</a>`
portfolioSeniorOfficial.innerHTML = seniorOfficialName ? seniorOfficialLink : "-";
updateSeniorOfficialContactInfo(portfolioSeniorOfficialAddress, senior_official);
showElement(portfolioSeniorOfficialAddress);
} else {
portfolioSeniorOfficial.innerText = "No senior official found.";
hideElement(portfolioSeniorOfficialAddress);
}
}
/**
* Populates and displays contact information for a senior official within a specified address field element.
*
* @param {HTMLElement} addressField - The DOM element containing contact info fields for the senior official.
* @param {Object} senior_official - The senior official's data object, containing properties like title, email, and phone.
*
* This function:
* - Sets the `title`, `email`, and `phone` fields in `addressField` to display the senior official's data.
* - Updates the `titleSpan` with the official's title, or "None" if unavailable.
* - Updates the `emailSpan` with the official's email, or "None" if unavailable.
* - If an email is provided, populates `hiddenInput` with the email for copying and shows the `copyButton`.
* - If no email is provided, hides the `copyButton`.
* - Updates the `phoneSpan` with the official's phone number, or "None" if unavailable.
*
* Dependencies:
* - Uses `showElement` and `hideElement` to control visibility of the `copyButton`.
* - Expects `addressField` to have specific classes (.contact_info_title, .contact_info_email, etc.) for query selectors to work.
*/
function updateSeniorOfficialContactInfo(addressField, senior_official) {
const titleSpan = addressField.querySelector(".contact_info_title");
const emailSpan = addressField.querySelector(".contact_info_email");
const phoneSpan = addressField.querySelector(".contact_info_phone");
const hiddenInput = addressField.querySelector("input");
const copyButton = addressField.querySelector(".admin-icon-group");
if (titleSpan) {
titleSpan.textContent = senior_official.title || "None";
};
if (emailSpan) {
emailSpan.textContent = senior_official.email || "None";
if (senior_official.email) {
hiddenInput.value = senior_official.email;
showElement(copyButton);
}else {
hideElement(copyButton);
}
}
if (phoneSpan) {
phoneSpan.textContent = senior_official.phone || "None";
};
}
/**
* Dynamically updates the visibility of certain portfolio fields based on specific conditions.
*
* This function adjusts the display of fields within the portfolio UI based on:
* - The presence of a senior official's contact information.
* - The selected state or territory, affecting the visibility of the urbanization field.
* - The organization type (Federal vs. non-Federal), toggling the visibility of related fields.
*
* Functionality:
* 1. **Senior Official Contact Info Display**:
* - If `portfolioSeniorOfficial` contains "No additional contact information found",
* hides `portfolioSeniorOfficialAddress`; otherwise, shows it.
*
* 2. **Urbanization Field Display**:
* - Displays `portfolioUrbanizationField` only when the `portfolioStateTerritory` value is "PR" (Puerto Rico).
*
* 3. **Federal Organization Type Display**:
* - If `portfolioOrgType` is "Federal", hides `portfolioOrgNameField` and shows both `portfolioFederalAgencyField`
* and `portfolioFederalTypeField`.
* - If not Federal, shows `portfolioOrgNameField` and hides `portfolioFederalAgencyField` and `portfolioFederalTypeField`.
* - Certain text fields (Organization Type, Organization Name, Federal Type, Federal Agency) updated to links
* to edit the portfolio
*
* Dependencies:
* - Expects specific elements to be defined globally (`portfolioSeniorOfficial`, `portfolioUrbanizationField`, etc.).
* - Uses `showElement` and `hideElement` functions to control element visibility.
*/
function updatePortfolioFieldsDataDynamicDisplay() {
// Handle visibility of senior official's contact information
if (portfolioSeniorOfficial.innerText.includes("No senior official found.")) {
hideElement(portfolioSeniorOfficialAddress);
} else {
showElement(portfolioSeniorOfficialAddress);
}
// Handle visibility of urbanization field based on state/territory value
let portfolioStateTerritoryValue = portfolioStateTerritory.innerText;
if (portfolioStateTerritoryValue === "PR") {
showElement(portfolioUrbanizationField);
} else {
hideElement(portfolioUrbanizationField);
}
// Handle visibility of fields based on organization type (Federal vs. others)
if (portfolioOrgType.innerText === "Federal") {
hideElement(portfolioOrgNameField);
showElement(portfolioFederalAgencyField);
showElement(portfolioFederalTypeField);
} else {
showElement(portfolioOrgNameField);
hideElement(portfolioFederalAgencyField);
hideElement(portfolioFederalTypeField);
}
// Modify the display of certain fields to convert them from text to links
// to edit the portfolio
let portfolio_id = portfolioDropdown.val();
let portfolioEditUrl = `/admin/registrar/portfolio/${portfolio_id}/change/`;
let portfolioOrgTypeValue = portfolioOrgType.innerText;
portfolioOrgType.innerHTML = `<a href=${portfolioEditUrl}>${portfolioOrgTypeValue}</a>`;
let portfolioOrgNameValue = portfolioOrgName.innerText;
portfolioOrgName.innerHTML = `<a href=${portfolioEditUrl}>${portfolioOrgNameValue}</a>`;
let portfolioFederalAgencyValue = portfolioFederalAgency.innerText;
portfolioFederalAgency.innerHTML = `<a href=${portfolioEditUrl}>${portfolioFederalAgencyValue}</a>`;
let portfolioFederalTypeValue = portfolioFederalType.innerText;
if (portfolioFederalTypeValue !== '-')
portfolioFederalType.innerHTML = `<a href=${portfolioEditUrl}>${portfolioFederalTypeValue}</a>`;
}
/**
* Asynchronously updates portfolio fields in the UI based on the selected portfolio.
*
* This function first checks if the page is loading or if a portfolio selection is available
* in the `portfolioDropdown`. If a portfolio is selected, it retrieves the portfolio data,
* then updates the UI fields to display relevant data. If no portfolio is selected, it simply
* refreshes the UI field display without new data. The `isPageLoading` flag prevents
* updates during page load.
*
* Workflow:
* 1. **Check Page Loading**:
* - If `isPageLoading` is `true`, set it to `false` and exit to prevent redundant updates.
* - If `isPageLoading` is `false`, proceed with portfolio field updates.
*
* 2. **Portfolio Selection**:
* - If a portfolio is selected (`portfolioDropdown.val()`), fetch the portfolio data.
* - Once data is fetched, run three update functions:
* - `updatePortfolioFieldsData`: Populates specific portfolio-related fields.
* - `updatePortfolioFieldsDisplay`: Handles the visibility of general portfolio fields.
* - `updatePortfolioFieldsDataDynamicDisplay`: Manages conditional display based on portfolio data.
* - If no portfolio is selected, only refreshes the field display using `updatePortfolioFieldsDisplay`.
*
* Dependencies:
* - Expects global elements (`portfolioDropdown`, etc.) and `isPageLoading` flag to be defined.
* - Assumes `getPortfolio`, `updatePortfolioFieldsData`, `updatePortfolioFieldsDisplay`, and `updatePortfolioFieldsDataDynamicDisplay` are available as functions.
*/
async function updatePortfolioFields() {
if (!isPageLoading) {
if (portfolioDropdown.val()) {
getPortfolio(portfolioDropdown.val()).then((portfolio) => {
updatePortfolioFieldsData(portfolio);
updatePortfolioFieldsDisplay();
updatePortfolioFieldsDataDynamicDisplay();
});
} else {
updatePortfolioFieldsDisplay();
}
} else {
isPageLoading = false;
}
}
/**
* Updates the Suborganization Dropdown with new data based on the provided portfolio ID.
*
* This function uses the Select2 jQuery plugin to update the dropdown by fetching suborganization
* data relevant to the selected portfolio. Upon invocation, it checks if Select2 is already initialized
* on `suborganizationDropdown` and destroys the existing instance to avoid duplication.
* It then reinitializes Select2 with customized options for an AJAX request, allowing the user to search
* and select suborganizations dynamically, with results filtered based on `portfolio_id`.
*
* Key workflow:
* 1. **Document Ready**: Ensures that the function runs only once the DOM is fully loaded.
* 2. **Check and Reinitialize Select2**:
* - If Select2 is already initialized, its destroyed to refresh with new options.
* - Select2 is reinitialized with AJAX settings for dynamic data fetching.
* 3. **AJAX Options**:
* - **Data Function**: Prepares the query by capturing the user's search term (`params.term`)
* and the provided `portfolio_id` to filter relevant suborganizations.
* - **Data Type**: Ensures responses are returned as JSON.
* - **Delay**: Introduces a 250ms delay to prevent excessive requests on fast typing.
* - **Cache**: Enables caching to improve performance.
* 4. **Theme and Placeholder**:
* - Sets the dropdown theme to admin-autocomplete for consistent styling.
* - Allows clearing of the dropdown and displays a placeholder as defined in the HTML.
*
* Dependencies:
* - Requires `suborganizationDropdown` element, the jQuery library, and the Select2 plugin.
* - `portfolio_id` is passed to filter results relevant to a specific portfolio.
*/
function updateSubOrganizationDropdown(portfolio_id) {
django.jQuery(document).ready(function() {
if (suborganizationDropdown.data('select2')) {
suborganizationDropdown.select2('destroy');
}
// Reinitialize Select2 with the updated URL
suborganizationDropdown.select2({
ajax: {
data: function (params) {
var query = {
search: params.term,
portfolio_id: portfolio_id
}
return query;
},
dataType: 'json',
delay: 250,
cache: true
},
theme: 'admin-autocomplete',
allowClear: true,
placeholder: suborganizationDropdown.attr('data-placeholder')
});
});
}
/**
* Updates the display of portfolio-related fields based on whether a portfolio is selected.
*
* This function controls the visibility of specific fields by showing or hiding them
* depending on the presence of a selected portfolio ID in the dropdown. When a portfolio
* is selected, certain fields are shown (like suborganizations and portfolio-related fields),
* while others are hidden (like senior official and other employee-related fields).
*
* Workflow:
* 1. **Retrieve Portfolio ID**:
* - Fetches the selected value from `portfolioDropdown` to check if a portfolio is selected.
*
* 2. **Display Fields for Selected Portfolio**:
* - If a `portfolio_id` exists, it updates the `suborganizationDropdown` for the specific portfolio.
* - Shows or hides various fields to display only relevant portfolio information:
* - Shows `suborganizationField`, `portfolioSeniorOfficialField`, and fields related to the portfolio organization.
* - Hides fields that are not applicable when a portfolio is selected, such as `seniorOfficialField` and `otherEmployeesField`.
*
* 3. **Display Fields for No Portfolio Selected**:
* - If no portfolio is selected (i.e., `portfolio_id` is falsy), it reverses the visibility:
* - Hides `suborganizationField` and other portfolio-specific fields.
* - Shows fields that are applicable when no portfolio is selected, such as the `seniorOfficialField`.
*
* Dependencies:
* - `portfolioDropdown` is assumed to be a dropdown element containing portfolio IDs.
* - `showElement` and `hideElement` utility functions are used to control element visibility.
* - Various global field elements (e.g., `suborganizationField`, `seniorOfficialField`, `portfolioOrgTypeFieldSet`) are used.
*/
function updatePortfolioFieldsDisplay() {
// Retrieve the selected portfolio ID
let portfolio_id = portfolioDropdown.val();
if (portfolio_id) {
// A portfolio is selected - update suborganization dropdown and show/hide relevant fields
// Update suborganization dropdown for the selected portfolio
updateSubOrganizationDropdown(portfolio_id);
// Show fields relevant to a selected portfolio
showElement(suborganizationField);
hideElement(seniorOfficialField);
showElement(portfolioSeniorOfficialField);
// Hide fields not applicable when a portfolio is selected
hideElement(otherEmployeesField);
hideElement(noOtherContactsRationaleField);
hideElement(cisaRepresentativeFirstNameField);
hideElement(cisaRepresentativeLastNameField);
hideElement(cisaRepresentativeEmailField);
hideElement(orgTypeFieldSet);
hideElement(orgTypeFieldSetDetails);
hideElement(orgNameFieldSet);
hideElement(orgNameFieldSetDetails);
// Show portfolio-specific fields
showElement(portfolioOrgTypeFieldSet);
showElement(portfolioOrgNameFieldSet);
showElement(portfolioOrgNameFieldSetDetails);
} else {
// No portfolio is selected - reverse visibility of fields
// Hide suborganization field as no portfolio is selected
hideElement(suborganizationField);
// Show fields that are relevant when no portfolio is selected
showElement(seniorOfficialField);
hideElement(portfolioSeniorOfficialField);
showElement(otherEmployeesField);
showElement(noOtherContactsRationaleField);
showElement(cisaRepresentativeFirstNameField);
showElement(cisaRepresentativeLastNameField);
showElement(cisaRepresentativeEmailField);
// Show organization type and name fields
showElement(orgTypeFieldSet);
showElement(orgTypeFieldSetDetails);
showElement(orgNameFieldSet);
showElement(orgNameFieldSetDetails);
// Hide portfolio-specific fields that arent applicable
hideElement(portfolioOrgTypeFieldSet);
hideElement(portfolioOrgNameFieldSet);
hideElement(portfolioOrgNameFieldSetDetails);
}
updateSuborganizationFieldsDisplay();
}
/**
* Updates the visibility of suborganization-related fields based on the selected value in the suborganization dropdown.
*
* If a suborganization is selected:
* - Hides the fields related to requesting a new suborganization (`requestedSuborganizationField`).
* - Hides the city (`suborganizationCity`) and state/territory (`suborganizationStateTerritory`) fields for the suborganization.
*
* If no suborganization is selected:
* - Shows the fields for requesting a new suborganization (`requestedSuborganizationField`).
* - Displays the city (`suborganizationCity`) and state/territory (`suborganizationStateTerritory`) fields.
*
* This function ensures the form dynamically reflects whether a specific suborganization is being selected or requested.
*/
function updateSuborganizationFieldsDisplay() {
let portfolio_id = portfolioDropdown.val();
let suborganization_id = suborganizationDropdown.val();
if (portfolio_id && !suborganization_id) {
// Show suborganization request fields
showElement(requestedSuborganizationField);
showElement(suborganizationCity);
showElement(suborganizationStateTerritory);
} else {
// Hide suborganization request fields if suborganization is selected
hideElement(requestedSuborganizationField);
hideElement(suborganizationCity);
hideElement(suborganizationStateTerritory);
}
}
/**
* Initializes necessary data and display configurations for the portfolio fields.
*/
function initializePortfolioSettings() {
// Update the visibility of portfolio-related fields based on current dropdown selection.
updatePortfolioFieldsDisplay();
// Dynamically adjust the display of certain fields based on the selected portfolio's characteristics.
updatePortfolioFieldsDataDynamicDisplay();
}
/**
* Sets event listeners for key UI elements.
*/
function setEventListeners() {
// When the `portfolioDropdown` selection changes, refresh the displayed portfolio fields.
portfolioDropdown.on("change", updatePortfolioFields);
// When the 'suborganizationDropdown' selection changes
suborganizationDropdown.on("change", updateSuborganizationFieldsDisplay);
}
// Run initial setup functions
initializePortfolioSettings();
setEventListeners();
}
// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>>
// Initialization code.
@ -797,6 +1297,63 @@ document.addEventListener('DOMContentLoaded', function() {
customEmail.loadRejectedEmail()
});
/** An IIFE that hides and shows approved domain select2 row in domain request
* conditionally based on the Status field selection. If Approved, show. If not Approved,
* don't show.
*/
document.addEventListener('DOMContentLoaded', function() {
const domainRequestForm = document.getElementById("domainrequest_form");
if (!domainRequestForm) {
return;
}
const statusToCheck = "approved";
const statusSelect = document.getElementById("id_status");
const sessionVariableName = "showApprovedDomain";
let approvedDomainFormGroup = document.querySelector(".field-approved_domain");
function updateFormGroupVisibility(showFormGroups) {
if (showFormGroups) {
showElement(approvedDomainFormGroup);
}else {
hideElement(approvedDomainFormGroup);
}
}
// Handle showing/hiding the related fields on page load.
function initializeFormGroups() {
let isStatus = statusSelect.value == statusToCheck;
// Initial handling of these groups.
updateFormGroupVisibility(isStatus);
// Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage
statusSelect.addEventListener('change', () => {
// Show the approved if the status is what we expect.
isStatus = statusSelect.value == statusToCheck;
updateFormGroupVisibility(isStatus);
addOrRemoveSessionBoolean(sessionVariableName, isStatus);
});
// Listen to Back/Forward button navigation and handle approvedDomainFormGroup display based on session storage
// When you navigate using forward/back after changing status but not saving, when you land back on the DA page the
// status select will say (for example) Rejected but the selected option can be something else. To manage the show/hide
// accurately for this edge case, we use cache and test for the back/forward navigation.
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
if (entry.type === "back_forward") {
let showTextAreaFormGroup = sessionStorage.getItem(sessionVariableName) !== null;
updateFormGroupVisibility(showTextAreaFormGroup);
}
});
});
observer.observe({ type: "navigation" });
}
initializeFormGroups();
});
/** An IIFE for copy summary button (appears in DomainRegistry models)
*/
@ -844,10 +1401,10 @@ document.addEventListener('DOMContentLoaded', function() {
if (contacts) {
contacts.forEach(contact => {
// Check if the <dl> element is not empty
const name = contact.querySelector('a#contact_info_name')?.innerText;
const title = contact.querySelector('span#contact_info_title')?.innerText;
const email = contact.querySelector('span#contact_info_email')?.innerText;
const phone = contact.querySelector('span#contact_info_phone')?.innerText;
const name = contact.querySelector('a.contact_info_name')?.innerText;
const title = contact.querySelector('span.contact_info_title')?.innerText;
const email = contact.querySelector('span.contact_info_email')?.innerText;
const phone = contact.querySelector('span.contact_info_phone')?.innerText;
const url = nameToUrlMap[name] || '#';
// Format the contact information
const listItem = document.createElement('li');
@ -898,9 +1455,9 @@ document.addEventListener('DOMContentLoaded', function() {
const seniorOfficialDiv = document.querySelector('.form-row.field-senior_official');
const seniorOfficialElement = document.getElementById('id_senior_official');
const seniorOfficialName = seniorOfficialElement.options[seniorOfficialElement.selectedIndex].text;
const seniorOfficialTitle = extractTextById('contact_info_title', seniorOfficialDiv);
const seniorOfficialEmail = extractTextById('contact_info_email', seniorOfficialDiv);
const seniorOfficialPhone = extractTextById('contact_info_phone', seniorOfficialDiv);
const seniorOfficialTitle = seniorOfficialDiv.querySelector('.contact_info_title');
const seniorOfficialEmail = seniorOfficialDiv.querySelector('.contact_info_email');
const seniorOfficialPhone = seniorOfficialDiv.querySelector('.contact_info_phone');
let seniorOfficialInfo = `${seniorOfficialName}${seniorOfficialTitle}${seniorOfficialEmail}${seniorOfficialPhone}`;
const html_summary = `<strong>Recommendation:</strong></br>` +
@ -958,6 +1515,7 @@ document.addEventListener('DOMContentLoaded', function() {
/** An IIFE for dynamically changing some fields on the portfolio admin model
* IMPORTANT NOTE: The logic in this IIFE is paired handlePortfolioSelection
*/
(function dynamicPortfolioFields(){
@ -1184,9 +1742,9 @@ document.addEventListener('DOMContentLoaded', function() {
function updateContactInfo(data) {
if (!contactList) return;
const titleSpan = contactList.querySelector("#contact_info_title");
const emailSpan = contactList.querySelector("#contact_info_email");
const phoneSpan = contactList.querySelector("#contact_info_phone");
const titleSpan = contactList.querySelector(".contact_info_title");
const emailSpan = contactList.querySelector(".contact_info_email");
const phoneSpan = contactList.querySelector(".contact_info_phone");
if (titleSpan) {
titleSpan.textContent = data.title || "None";
@ -1218,7 +1776,7 @@ document.addEventListener('DOMContentLoaded', function() {
(function dynamicDomainRequestFields(){
const domainRequestPage = document.getElementById("domainrequest_form");
if (domainRequestPage) {
handleSuborganizationFields();
handlePortfolioSelection();
}
})();

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -1,3 +1,4 @@
@use "uswds-core" as *;
@use "base" as *;
// Fixes some font size disparities with the Figma
@ -29,3 +30,24 @@
.usa-alert__body--widescreen {
max-width: $widescreen-max-width !important;
}
.usa-site-alert--hot-pink {
.usa-alert {
background-color: $hot-pink;
border-left-color: $hot-pink;
.usa-alert__body {
color: color('base-darkest');
background-color: $hot-pink;
}
}
}
@supports ((-webkit-mask:url()) or (mask:url())) {
.usa-site-alert--hot-pink .usa-alert .usa-alert__body::before {
background-color: color('base-darkest');
}
}
.usa-site-alert--hot-pink .usa-alert .usa-alert__body::before {
background-image: url('../img/usa-icons-bg/error.svg');
}

View file

@ -2,6 +2,7 @@
@use "cisa_colors" as *;
$widescreen-max-width: 1920px;
$hot-pink: #FFC3F9;
/* Styles for making visible to screen reader / AT users only. */
.sr-only {
@ -29,15 +30,15 @@ body {
padding-bottom: units(6) * 2 ; //Workaround because USWDS units jump from 10 to 15
}
#wrapper.wrapper--padding-top-6 {
padding-top: units(6);
}
#wrapper.dashboard {
background-color: color('primary-lightest');
padding-top: units(5)!important;
}
#wrapper.dashboard--portfolio {
padding-top: units(4)!important;
}
#wrapper.dashboard--grey-1 {
background-color: color('gray-1');
}

View file

@ -119,7 +119,7 @@ in the form $setting: value,
/*---------------------------
## Emergency state
----------------------------*/
$theme-color-emergency: #FFC3F9,
$theme-color-emergency: "red-warm-60v",
/*---------------------------
# Input settings

View file

@ -363,7 +363,6 @@ CSP_DEFAULT_SRC = ("'self'",)
CSP_STYLE_SRC = [
"'self'",
"https://www.ssa.gov/accessibility/andi/andi.css",
"https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css",
]
CSP_SCRIPT_SRC_ELEM = [
"'self'",
@ -371,7 +370,6 @@ CSP_SCRIPT_SRC_ELEM = [
"https://cdn.jsdelivr.net/npm/chart.js",
"https://www.ssa.gov",
"https://ajax.googleapis.com",
"https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js",
]
CSP_CONNECT_SRC = ["'self'", "https://www.google-analytics.com/", "https://www.ssa.gov/accessibility/andi/andi.js"]
CSP_INCLUDE_NONCE_IN = ["script-src-elem", "style-src"]

View file

@ -21,6 +21,7 @@ from registrar.views.report_views import (
ExportDomainRequestDataFull,
ExportDataTypeUser,
ExportDataTypeRequests,
ExportMembersPortfolio,
)
# --jsons
@ -28,6 +29,8 @@ from registrar.views.domain_requests_json import get_domain_requests_json
from registrar.views.domains_json import get_domains_json
from registrar.views.utility.api_views import (
get_senior_official_from_federal_agency_json,
get_portfolio_json,
get_suborganization_list_json,
get_federal_and_portfolio_types_from_federal_agency_json,
get_action_needed_email_for_user_json,
get_rejection_email_for_user_json,
@ -39,12 +42,13 @@ from registrar.views.utility import always_404
from api.views import available, rdap, get_current_federal, get_current_full
DOMAIN_REQUEST_NAMESPACE = views.DomainRequestWizard.URL_NAMESPACE
domain_request_urls = [
path("", views.DomainRequestWizard.as_view(), name=""),
path("finished/", views.Finished.as_view(), name="finished"),
]
# dynamically generate the other domain_request_urls
domain_request_urls = [
path("", RedirectView.as_view(pattern_name="domain-request:start"), name="redirect-to-start"),
path("start/", views.DomainRequestWizard.as_view(), name="start"),
path("finished/", views.Finished.as_view(), name="finished"),
]
for step, view in [
# add/remove steps here
(Step.ORGANIZATION_TYPE, views.OrganizationType),
@ -65,7 +69,7 @@ for step, view in [
(PortfolioDomainRequestStep.REQUESTING_ENTITY, views.RequestingEntity),
(PortfolioDomainRequestStep.ADDITIONAL_DETAILS, views.PortfolioAdditionalDetails),
]:
domain_request_urls.append(path(f"{step}/", view.as_view(), name=step))
domain_request_urls.append(path(f"<int:id>/{step}/", view.as_view(), name=step))
urlpatterns = [
@ -90,6 +94,11 @@ urlpatterns = [
views.PortfolioMemberView.as_view(),
name="member",
),
path(
"member/<int:pk>/delete",
views.PortfolioMemberDeleteView.as_view(),
name="member-delete",
),
path(
"member/<int:pk>/permissions",
views.PortfolioMemberEditView.as_view(),
@ -105,6 +114,11 @@ urlpatterns = [
views.PortfolioInvitedMemberView.as_view(),
name="invitedmember",
),
path(
"invitedmember/<int:pk>/delete",
views.PortfolioInvitedMemberDeleteView.as_view(),
name="invitedmember-delete",
),
path(
"invitedmember/<int:pk>/permissions",
views.PortfolioInvitedMemberEditView.as_view(),
@ -200,6 +214,16 @@ urlpatterns = [
get_senior_official_from_federal_agency_json,
name="get-senior-official-from-federal-agency-json",
),
path(
"admin/api/get-portfolio-json/",
get_portfolio_json,
name="get-portfolio-json",
),
path(
"admin/api/get-suborganization-list-json/",
get_suborganization_list_json,
name="get-suborganization-list-json",
),
path(
"admin/api/get-federal-and-portfolio-types-from-federal-agency-json/",
get_federal_and_portfolio_types_from_federal_agency_json,
@ -216,6 +240,11 @@ urlpatterns = [
name="get-rejection-email-for-user-json",
),
path("admin/", admin.site.urls),
path(
"reports/export_members_portfolio/",
ExportMembersPortfolio.as_view(),
name="export_members_portfolio",
),
path(
"reports/export_data_type_user/",
ExportDataTypeUser.as_view(),
@ -321,9 +350,9 @@ urlpatterns = [
name="user-profile",
),
path(
"invitation/<int:pk>/delete",
views.DomainInvitationDeleteView.as_view(http_method_names=["post"]),
name="invitation-delete",
"invitation/<int:pk>/cancel",
views.DomainInvitationCancelView.as_view(http_method_names=["post"]),
name="invitation-cancel",
),
path(
"domain-request/<int:pk>/delete",

View file

@ -97,5 +97,19 @@ def portfolio_permissions(request):
def is_widescreen_mode(request):
widescreen_paths = ["/domains/", "/requests/", "/members/"]
return {"is_widescreen_mode": any(path in request.path for path in widescreen_paths) or request.path == "/"}
widescreen_paths = []
portfolio_widescreen_paths = [
"/domains/",
"/requests/",
"/request/",
"/no-organization-requests/",
"/no-organization-domains/",
"/domain-request/",
]
is_widescreen = any(path in request.path for path in widescreen_paths) or request.path == "/"
is_portfolio_widescreen = bool(
hasattr(request.user, "is_org_user")
and request.user.is_org_user(request)
and any(path in request.path for path in portfolio_widescreen_paths)
)
return {"is_widescreen_mode": is_widescreen or is_portfolio_widescreen}

View file

@ -115,11 +115,14 @@ class RequestingEntityForm(RegistrarForm):
if is_requesting_new_suborganization:
# Validate custom suborganization fields
if not cleaned_data.get("requested_suborganization"):
self.add_error("requested_suborganization", "Requested suborganization is required.")
self.add_error("requested_suborganization", "Enter the name of your suborganization.")
if not cleaned_data.get("suborganization_city"):
self.add_error("suborganization_city", "City is required.")
self.add_error("suborganization_city", "Enter the city where your suborganization is located.")
if not cleaned_data.get("suborganization_state_territory"):
self.add_error("suborganization_state_territory", "State, territory, or military post is required.")
self.add_error(
"suborganization_state_territory",
"Select the state, territory, or military post where your suborganization is located.",
)
elif not suborganization:
self.add_error("sub_organization", "Suborganization is required.")

View file

@ -0,0 +1,90 @@
# Generated by Django 4.2.10 on 2024-11-12 22:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0136_domainrequest_requested_suborganization_and_more"),
]
operations = [
migrations.AddField(
model_name="suborganization",
name="city",
field=models.CharField(blank=True, null=True),
),
migrations.AddField(
model_name="suborganization",
name="state_territory",
field=models.CharField(
blank=True,
choices=[
("AL", "Alabama (AL)"),
("AK", "Alaska (AK)"),
("AS", "American Samoa (AS)"),
("AZ", "Arizona (AZ)"),
("AR", "Arkansas (AR)"),
("CA", "California (CA)"),
("CO", "Colorado (CO)"),
("CT", "Connecticut (CT)"),
("DE", "Delaware (DE)"),
("DC", "District of Columbia (DC)"),
("FL", "Florida (FL)"),
("GA", "Georgia (GA)"),
("GU", "Guam (GU)"),
("HI", "Hawaii (HI)"),
("ID", "Idaho (ID)"),
("IL", "Illinois (IL)"),
("IN", "Indiana (IN)"),
("IA", "Iowa (IA)"),
("KS", "Kansas (KS)"),
("KY", "Kentucky (KY)"),
("LA", "Louisiana (LA)"),
("ME", "Maine (ME)"),
("MD", "Maryland (MD)"),
("MA", "Massachusetts (MA)"),
("MI", "Michigan (MI)"),
("MN", "Minnesota (MN)"),
("MS", "Mississippi (MS)"),
("MO", "Missouri (MO)"),
("MT", "Montana (MT)"),
("NE", "Nebraska (NE)"),
("NV", "Nevada (NV)"),
("NH", "New Hampshire (NH)"),
("NJ", "New Jersey (NJ)"),
("NM", "New Mexico (NM)"),
("NY", "New York (NY)"),
("NC", "North Carolina (NC)"),
("ND", "North Dakota (ND)"),
("MP", "Northern Mariana Islands (MP)"),
("OH", "Ohio (OH)"),
("OK", "Oklahoma (OK)"),
("OR", "Oregon (OR)"),
("PA", "Pennsylvania (PA)"),
("PR", "Puerto Rico (PR)"),
("RI", "Rhode Island (RI)"),
("SC", "South Carolina (SC)"),
("SD", "South Dakota (SD)"),
("TN", "Tennessee (TN)"),
("TX", "Texas (TX)"),
("UM", "United States Minor Outlying Islands (UM)"),
("UT", "Utah (UT)"),
("VT", "Vermont (VT)"),
("VI", "Virgin Islands (VI)"),
("VA", "Virginia (VA)"),
("WA", "Washington (WA)"),
("WV", "West Virginia (WV)"),
("WI", "Wisconsin (WI)"),
("WY", "Wyoming (WY)"),
("AA", "Armed Forces Americas (AA)"),
("AE", "Armed Forces Africa, Canada, Europe, Middle East (AE)"),
("AP", "Armed Forces Pacific (AP)"),
],
max_length=2,
null=True,
verbose_name="state, territory, or military post",
),
),
]

View file

@ -0,0 +1,24 @@
# Generated by Django 4.2.10 on 2024-11-18 16:47
from django.db import migrations
import django_fsm
class Migration(migrations.Migration):
dependencies = [
("registrar", "0137_suborganization_city_suborganization_state_territory"),
]
operations = [
migrations.AlterField(
model_name="domaininvitation",
name="status",
field=django_fsm.FSMField(
choices=[("invited", "Invited"), ("retrieved", "Retrieved"), ("canceled", "Canceled")],
default="invited",
max_length=50,
protected=True,
),
),
]

View file

@ -26,6 +26,7 @@ class DomainInvitation(TimeStampedModel):
class DomainInvitationStatus(models.TextChoices):
INVITED = "invited", "Invited"
RETRIEVED = "retrieved", "Retrieved"
CANCELED = "canceled", "Canceled"
email = models.EmailField(
null=False,
@ -73,3 +74,13 @@ class DomainInvitation(TimeStampedModel):
# something strange happened and this role already existed when
# the invitation was retrieved. Log that this occurred.
logger.warn("Invitation %s was retrieved for a role that already exists.", self)
@transition(field="status", source=DomainInvitationStatus.INVITED, target=DomainInvitationStatus.CANCELED)
def cancel_invitation(self):
"""When an invitation is canceled, change the status to canceled"""
pass
@transition(field="status", source=DomainInvitationStatus.CANCELED, target=DomainInvitationStatus.INVITED)
def update_cancellation_status(self):
"""When an invitation is canceled but reinvited, update the status to invited"""
pass

View file

@ -1,4 +1,6 @@
from django.db import models
from registrar.models.domain_request import DomainRequest
from .utility.time_stamped_model import TimeStampedModel
@ -19,5 +21,18 @@ class Suborganization(TimeStampedModel):
related_name="portfolio_suborganizations",
)
city = models.CharField(
null=True,
blank=True,
)
state_territory = models.CharField(
max_length=2,
choices=DomainRequest.StateTerritoryChoices.choices,
null=True,
blank=True,
verbose_name="state, territory, or military post",
)
def __str__(self) -> str:
return f"{self.name}"

View file

@ -1,11 +1,12 @@
import logging
from django.apps import apps
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.db.models import Q
from registrar.models import DomainInformation, UserDomainRole
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from .domain_invitation import DomainInvitation
from .portfolio_invitation import PortfolioInvitation
@ -471,3 +472,42 @@ class User(AbstractUser):
return DomainRequest.objects.filter(portfolio=portfolio).values_list("id", flat=True)
else:
return UserDomainRole.objects.filter(user=self).values_list("id", flat=True)
def get_active_requests_count_in_portfolio(self, request):
"""Return count of active requests for the portfolio associated with the request."""
# Get the portfolio from the session using the existing method
portfolio = request.session.get("portfolio")
if not portfolio:
return 0 # No portfolio found
allowed_states = [
DomainRequest.DomainRequestStatus.SUBMITTED,
DomainRequest.DomainRequestStatus.IN_REVIEW,
DomainRequest.DomainRequestStatus.ACTION_NEEDED,
]
# Now filter based on the portfolio retrieved
active_requests_count = self.domain_requests_created.filter(
status__in=allowed_states, portfolio=portfolio
).count()
return active_requests_count
def is_only_admin_of_portfolio(self, portfolio):
"""Check if the user is the only admin of the given portfolio."""
UserPortfolioPermission = apps.get_model("registrar", "UserPortfolioPermission")
admin_permission = UserPortfolioRoleChoices.ORGANIZATION_ADMIN
admins = UserPortfolioPermission.objects.filter(portfolio=portfolio, roles__contains=[admin_permission])
admin_count = admins.count()
# Check if the current user is in the list of admins
if admin_count == 1 and admins.first().user == self:
return True # The user is the only admin
# If there are other admins or the user is not the only one
return False

View file

@ -2,7 +2,7 @@ from django.db import models
from django.forms import ValidationError
from registrar.models.user_domain_role import UserDomainRole
from registrar.utility.waffle import flag_is_active_for_user
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices, DomainRequestPermissionDisplay, MemberPermissionDisplay
from .utility.time_stamped_model import TimeStampedModel
from django.contrib.postgres.fields import ArrayField
@ -106,6 +106,37 @@ class UserPortfolioPermission(TimeStampedModel):
portfolio_permissions.update(additional_permissions)
return list(portfolio_permissions)
@classmethod
def get_domain_request_permission_display(cls, roles, additional_permissions):
"""Class method to return a readable string for domain request permissions"""
# Tracks if they can view, create requests, or not do anything
all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, additional_permissions)
all_domain_perms = [
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.EDIT_REQUESTS,
]
if all(perm in all_permissions for perm in all_domain_perms):
return DomainRequestPermissionDisplay.VIEWER_REQUESTER
elif UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS in all_permissions:
return DomainRequestPermissionDisplay.VIEWER
else:
return DomainRequestPermissionDisplay.NONE
@classmethod
def get_member_permission_display(cls, roles, additional_permissions):
"""Class method to return a readable string for member permissions"""
# Tracks if they can view, create requests, or not do anything.
# This is different than get_domain_request_permission_display because member tracks
# permissions slightly differently.
all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, additional_permissions)
if UserPortfolioPermissionChoices.EDIT_MEMBERS in all_permissions:
return MemberPermissionDisplay.MANAGER
elif UserPortfolioPermissionChoices.VIEW_MEMBERS in all_permissions:
return MemberPermissionDisplay.VIEWER
else:
return MemberPermissionDisplay.NONE
def clean(self):
"""Extends clean method to perform additional validation, which can raise errors in django admin."""
super().clean()

View file

@ -0,0 +1,8 @@
from django.db.models.expressions import Func
class ArrayRemoveNull(Func):
"""Custom Func to use array_remove to remove null values"""
function = "array_remove"
template = "%(function)s(%(expressions)s, NULL)"

View file

@ -1,3 +1,4 @@
from registrar.utility import StrEnum
from django.db import models
@ -40,3 +41,29 @@ class UserPortfolioPermissionChoices(models.TextChoices):
@classmethod
def to_dict(cls):
return {key: value.value for key, value in cls.__members__.items()}
class DomainRequestPermissionDisplay(StrEnum):
"""Stores display values for domain request permission combinations.
Overview of values:
- VIEWER_REQUESTER: "Viewer Requester"
- VIEWER: "Viewer"
- NONE: "None"
"""
VIEWER_REQUESTER = "Viewer Requester"
VIEWER = "Viewer"
NONE = "None"
class MemberPermissionDisplay(StrEnum):
"""Stores display values for member permission combinations.
Overview of values:
- MANAGER: "Manager"
- VIEWER: "Viewer"
- NONE: "None"
"""
MANAGER = "Manager"
VIEWER = "Viewer"
NONE = "None"

View file

@ -92,7 +92,7 @@ class CheckUserProfileMiddleware:
We set the "redirect" query param equal to where the user wants to go.
If the user wants to go to '/request/', then we set that
If the user wants to go to '/request/start/' or '/request/', then we set that
information in the query param.
Otherwise, we assume they want to go to the home page.
@ -100,7 +100,8 @@ class CheckUserProfileMiddleware:
# In some cases, we don't want to redirect to home. This handles that.
# Can easily be generalized if need be, but for now lets keep this easy to read.
custom_redirect = "domain-request:" if request.path == "/request/" else None
start_paths = ["/request/", "/request/start/"]
custom_redirect = "domain-request:start" if request.path in start_paths else None
# Don't redirect on excluded pages (such as the setup page itself)
if not any(request.path.startswith(page) for page in self._get_excluded_pages(profile_page)):

View file

@ -45,7 +45,7 @@
{% block header %}
{% if not IS_PRODUCTION %}
{% with add_body_class="margin-left-1" %}
{% include "includes/non-production-alert.html" %}
{% include "includes/banner-non-production-alert.html" %}
{% endwith %}
{% endif %}

View file

@ -4,7 +4,25 @@
Template for an input field with a clipboard
{% endcomment %}
{% if not invisible_input_field %}
{% if empty_field %}
<div class="admin-icon-group">
<input aria-hidden="true" class="display-none" value="">
<button
class="usa-button--dja usa-button usa-button__small-text usa-button--unstyled padding-left-1 usa-button--icon copy-to-clipboard"
type="button"
>
<div class="no-outline-on-click">
<svg
class="usa-icon"
>
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
</svg>
<!-- the span is targeted in JS, do not remove -->
<span>Copy</span>
</div>
</button>
</div>
{% elif not invisible_input_field %}
<div class="admin-icon-group">
{{ field }}
<button

View file

@ -16,10 +16,10 @@
<script src="{% static 'admin/js/vendor/jquery/jquery.min.js' %}"></script>
<!-- Include Select2 JavaScript. Since this view technically falls outside of admin, this is needed. -->
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script src="{% static 'js/select2.min.js' %}"></script>
<script type="application/javascript" src="{% static 'js/get-gov-admin-extra.js' %}" defer></script>
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<link href="{% static 'css/select2.min.css' %}" rel="stylesheet" />
{% endblock %}
{% block breadcrumbs %}

View file

@ -72,9 +72,28 @@
<a class="usa-skipnav" href="#main-content">Skip to main content</a>
{% if not IS_PRODUCTION %}
{% include "includes/non-production-alert.html" %}
{% include "includes/banner-non-production-alert.html" %}
{% endif %}
{% comment %}
<!-- Site banner / red alert banner / emergency banner / incident banner - Remove one of those includes and place outside the comment block to activate the banner.
DO NOT FORGET TO EDIT THE BANNER CONTENT -->
<!-- Red banner with exclamation mark in a circle: -->
{% include "includes/banner-error.html" %}
<!-- Blue banner with 'i'' mark in a circle: -->
{% include "includes/banner-info.html" %}
<!-- Marron banner with exclamation mark in a circle: -->
{% include "includes/banner-service-disruption.html" %}
{% include "includes/banner-site-alert.html" %}
{% include "includes/banner-system-outage.html" %}
<!-- Yellow banner with exclamation mark in a triangle: -->
{% include "includes/banner-warning.html" %}
{% endcomment %}
<section class="usa-banner" aria-label="Official website of the United States government">
<div class="usa-accordion">
<header class="usa-banner__header">
@ -139,7 +158,9 @@
{% endblock header %}
{% block wrapper %}
{% block wrapperdiv %}
<div id="wrapper">
{% endblock wrapperdiv %}
{% block messages %}
{% if messages %}
<ul class="messages">

View file

@ -64,7 +64,7 @@
>
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading" id="modal-1-heading">
<h2 class="usa-modal__heading">
Are you sure you want to extend the expiration date?
</h2>
<div class="usa-prose">
@ -128,7 +128,7 @@
>
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading" id="modal-1-heading">
<h2 class="usa-modal__heading">
Are you sure you want to place this domain on hold?
</h2>
<div class="usa-prose">
@ -195,7 +195,7 @@
>
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading" id="modal-1-heading">
<h2 class="usa-modal__heading">
Are you sure you want to remove this domain from the registry?
</h2>
<div class="usa-prose">

View file

@ -2,6 +2,13 @@
{% load custom_filters %}
{% load i18n static %}
{% block content %}
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
{% url 'get-portfolio-json' as url %}
<input id="portfolio_json_url" class="display-none" value="{{url}}" />
{{ block.super }}
{% endblock content %}
{% block field_sets %}
{# Create an invisible <a> tag so that we can use a click event to toggle the modal. #}
<a id="invisible-ineligible-modal-toggler" class="display-none" href="#toggle-set-ineligible" aria-controls="toggle-set-ineligible" data-open-modal></a>
@ -50,7 +57,7 @@
>
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading" id="modal-1-heading">
<h2 class="usa-modal__heading">
Are you sure you want to select ineligible status?
</h2>
<div class="usa-prose">

View file

@ -6,7 +6,7 @@
{% if show_formatted_name %}
{% if user.get_formatted_name %}
<a id="contact_info_name" href="{% url 'admin:registrar_contact_change' user.id %}">{{ user.get_formatted_name }}</a>
<a class="contact_info_name" href="{% url 'admin:registrar_contact_change' user.id %}">{{ user.get_formatted_name }}</a>
{% else %}
None
{% endif %}
@ -16,7 +16,7 @@
{% if user|has_contact_info %}
{# Title #}
{% if user.title %}
<span id="contact_info_title">{{ user.title }}</span>
<span class="contact_info_title">{{ user.title }}</span>
{% else %}
None
{% endif %}
@ -24,7 +24,7 @@
{# Email #}
{% if user.email %}
<span id="contact_info_email">{{ user.email }}</span>
<span class="contact_info_email">{{ user.email }}</span>
{% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %}
<br>
{% else %}
@ -33,17 +33,24 @@
{# Phone #}
{% if user.phone %}
<span id="contact_info_phone">{{ user.phone }}</span>
<span class="contact_info_phone">{{ user.phone }}</span>
<br>
{% else %}
None<br>
{% endif %}
{% elif fields_always_present %}
<span class="contact_info_title"></span>
</br>
<span class="contact_info_email"></span>
{% include "admin/input_with_clipboard.html" with field=user empty_field=True %}
<br>
<span class="contact_info_phone"></span>
<br>
{% elif not hide_no_contact_info_message %}
No additional contact information found.<br>
{% endif %}
{% if user_verification_type and not skip_additional_contact_info %}
<span id="contact_info_phone">{{ user_verification_type }}</span>
<span class="contact_info_phone">{{ user_verification_type }}</span>
{% endif %}
</address>

View file

@ -66,6 +66,14 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
No changelog to display.
</div>
{% endif %}
{% elif field.field.name == "portfolio_senior_official" %}
<div class="readonly">
{% if original_object.portfolio.senior_official %}
<a href="{% url 'admin:registrar_seniorofficial_change' original_object.portfolio.senior_official.id %}">{{ field.contents }}</a>
{% else %}
No senior official found.<br>
{% endif %}
</div>
{% elif field.field.name == "other_contacts" %}
{% if all_contacts.count > 2 %}
<div class="readonly">
@ -332,6 +340,16 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
<label aria-label="Senior official contact details"></label>
{% include "django/admin/includes/contact_detail_list.html" with user=original_object.senior_official no_title_top_padding=field.is_readonly %}
</div>
{% elif field.field.name == "portfolio_senior_official" %}
<div class="flex-container">
<label aria-label="Senior official contact details"></label>
{% comment %}fields_always_present=True will shortcut the contact_detail_list template when
1. Senior official field should be hidden on domain request because no portfoloio is selected, which is desirable
2. A portfolio is selected but there is no senior official on the portfolio, where the shortcut is not desirable
To solve 2, we use an else No additional contact information found on field.field.name == "portfolio_senior_official"
and we hide the placeholders from detail_table_fieldset in JS{% endcomment %}
{% include "django/admin/includes/contact_detail_list.html" with user=original_object.portfolio.senior_official no_title_top_padding=field.is_readonly fields_always_present=True %}
</div>
{% elif field.field.name == "other_contacts" and original_object.other_contacts.all %}
{% with all_contacts=original_object.other_contacts.all %}
{% if all_contacts.count > 2 %}
@ -341,7 +359,8 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
<table>
<thead>
<tr>
<th colspan="5">Other contact information</th>
<th colspan="4">Other contact information</th>
<th>Action</th>
<tr>
</thead>
<tbody>

View file

@ -9,6 +9,7 @@
<th>Title</th>
<th>Email</th>
<th>Phone</th>
<th>Action</th>
</tr>
</thead>
<tbody>

View file

@ -11,6 +11,7 @@
<th>Email</th>
<th>Phone</th>
<th>Roles</th>
<th>Action</th>
</tr>
</thead>
<tbody>

View file

@ -41,14 +41,16 @@
{% include "includes/domain_dates.html" %}
{% if is_portfolio_user and not is_domain_manager %}
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body">
<p class="usa-alert__text ">
You don't have access to manage {{domain.name}}. If you need to make updates, contact one of the listed domain managers.
</p>
{% if analyst_action != 'edit' or analyst_action_location != domain.pk %}
{% if is_portfolio_user and not is_domain_manager %}
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body">
<p class="usa-alert__text ">
You don't have access to manage {{domain.name}}. If you need to make updates, contact one of the listed domain managers.
</p>
</div>
</div>
</div>
{% endif %}
{% endif %}

View file

@ -4,6 +4,12 @@
{% block title %}Thanks for your domain request! | {% endblock %}
{% comment %} Same as the old wrapper implementation but with padding-top-4 {% endcomment %}
{% block wrapperdiv %}
<div id="wrapper" class="wrapper--padding-top-6">
{% endblock wrapperdiv %}
{% block content %}
<main id="main-content" class="grid-container register-form-step">
<span class="display-flex flex-align-center" >
@ -28,8 +34,8 @@
<li>Your requested domain meets our naming requirements.</li>
</ul>
<p> Well email you if we have questions. Well also email you as soon as we complete our review. You can <a href="{% url 'home' %}">check the status</a>
of your request at any time on the registrar homepage.</p>
<p> Well email you if we have questions. Well also email you as soon as we complete our review. You can <a href="{% if portfolio %}{% url 'domain-requests' %}{% else %}{% url 'home' %}{% endif %}">check the status</a>
of your request at any time on the registrar.</p>
<p> <a class="usa-link" rel="noopener noreferrer" target="_blank" href="{% public_site_url 'contact' %}">Contact us if you need help during this process</a>.</p>

View file

@ -10,32 +10,37 @@
</div>
<div class="tablet:grid-col-9">
<main id="main-content" class="grid-container register-form-step">
{% if steps.prev %}
<a href="{% namespaced_url 'domain-request' steps.prev %}" class="breadcrumb__back">
<input type="hidden" class="display-none" id="wizard-domain-request-id" value="{{domain_request_id}}"/>
{% if steps.current == steps.first %}
{% if portfolio %}
{% url 'domain-requests' as url_2 %}
{% else %}
{% url 'home' as url_2 %}
{% endif %}
<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>
{% if portfolio%}Domain requests{%else%}Manage your domains{% endif%}
</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>New domain request</span>
{% endif %}
</li>
</ol>
</nav>
{% elif steps.prev %}
<a href="{% namespaced_url 'domain-request' steps.prev id=domain_request_id %}" class="breadcrumb__back">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#arrow_back"></use>
</svg><span class="margin-left-05">Previous step</span>
</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 %}
{% block form_messages %}

View file

@ -21,7 +21,7 @@
<p>If you have <a href="{% public_site_url 'domains/before/#information-you%E2%80%99ll-need-to-complete-the-domain-request-form' %}" target="_blank" class="usa-link">all the information you need</a>,
completing your domain request might take around 15 minutes.</p>
<h2>How well reach you</h2>
<p>While reviewing your domain request, we may need to reach out with questions. Well also email you when we complete our review. If the contact information below is not correct, visit <a href="{% url 'user-profile' %}?redirect=domain-request:" class="usa-link">your profile</a> to make updates.</p>
<p>While reviewing your domain request, we may need to reach out with questions. Well also email you when we complete our review. If the contact information below is not correct, visit <a href="{% url 'user-profile' %}?redirect=domain-request:start" class="usa-link">your profile</a> to make updates.</p>
{% include "includes/profile_information.html" with user=user%}

View file

@ -2,11 +2,12 @@
{% load field_helpers url_helpers %}
{% block form_instructions %}
<p>To help with our review, we need to understand whether the domain you're requesting will be used by the Department of Energy or by one of its suborganizations.</p>
<p>To help with our review, we need to understand whether the domain you're requesting will be used by {{ portfolio }} or by one of its suborganizations.</p>
<p>We define a suborganization as any entity (agency, bureau, office) that falls under the overarching organization.</p>
{% endblock %}
{% block form_fields %}
<input id="option-to-add-suborg" value="Other (enter your suborganization manually)"/>
<fieldset class="usa-fieldset">
<legend>
<h2>Who will use the domain youre requesting?</h2>
@ -33,8 +34,8 @@
<div id="suborganization-container" class="margin-top-4">
<h2>Add suborganization information</h2>
<p>
This information will be published in <a class="usa-link usa-link--always-blue" href="{% public_site_url 'about/data' %}">.govs public data</a>. If you dont see your suborganization in the list,
select “other” and enter the name or your suborganization.
This information will be published in <a class="usa-link usa-link--always-blue" target="_blank" href="{% public_site_url 'about/data' %}">.govs public data</a>. If you dont see your suborganization in the list,
select “other.
</p>
{% with attr_required=True %}
{% input_with_errors forms.1.sub_organization %}
@ -43,7 +44,7 @@
{% comment %} This will be toggled if a special value, "other", is selected.
Otherwise this field is invisible.
{% endcomment %}
<div id="suborganization-container__details">
<div id="suborganization-container__details" class="padding-top-2 margin-top-0">
{% with attr_required=True %}
{% input_with_errors forms.1.requested_suborganization %}
{% endwith %}

View file

@ -15,7 +15,7 @@
</svg>
{% endif %}
{% endif %}
<a href="{% namespaced_url 'domain-request' this_step %}"
<a href="{% namespaced_url 'domain-request' this_step id=domain_request_id %}"
{% if this_step == steps.current %}
class="usa-current"
{% else %}

View file

@ -3,6 +3,10 @@
{% block title %}Withdraw request for {{ DomainRequest.requested_domain.name }} | {% endblock %}
{% load static url_helpers %}
{% block wrapperdiv %}
<div id="wrapper" class="wrapper--padding-top-6">
{% endblock wrapperdiv %}
{% block content %}
<div class="grid-container">
<div class="grid-col desktop:grid-offset-2 desktop:grid-col-8">

View file

@ -6,21 +6,30 @@
{% block domain_content %}
<h1>Domain managers</h1>
{% comment %}Copy below differs depending on whether view is in portfolio mode.{% endcomment %}
{% if not portfolio %}
<p>
Domain managers can update all information related to a domain within the
.gov registrar, including security email and DNS name servers.
</p>
{% else %}
<p>
Domain managers can update all information related to a domain within the
.gov registrar, including security email and DNS name servers.
.gov registrar, including contact details, senior official, security email, and DNS name servers.
</p>
{% endif %}
<ul class="usa-list">
<li>There is no limit to the number of domain managers you can add.</li>
<li>After adding a domain manager, an email invitation will be sent to that user with
instructions on how to set up an account.</li>
<li>All domain managers must keep their contact information updated and be responsive if contacted by the .gov team.</li>
<li>All domain managers will be notified when updates are made to this domain.</li>
<li>Domains must have at least one domain manager. You cant remove yourself as a domain manager if youre the only one assigned to this domain.</li>
{% if not portfolio %}<li>All domain managers will be notified when updates are made to this domain.</li>{% endif %}
<li>Domains must have at least one domain manager. You cant remove yourself as a domain manager if youre the only one assigned to this domain.
{% if portfolio %} Add another domain manager before you remove yourself from this domain.{% endif %}</li>
</ul>
{% if domain.permissions %}
{% if domain_manager_roles %}
<section class="section-outlined">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table">
<h2 class> Domain managers </h2>
@ -28,17 +37,18 @@
<thead>
<tr>
<th data-sortable scope="col" role="columnheader">Email</th>
<th class="grid-col-2" data-sortable scope="col" role="columnheader">Role</th>
{% if not portfolio %}<th class="grid-col-2" data-sortable scope="col" role="columnheader">Role</th>{% endif %}
<th class="grid-col-1" scope="col" role="columnheader"><span class="sr-only">Action</span></th>
</tr>
</thead>
<tbody>
{% for permission in domain.permissions.all %}
{% for item in domain_manager_roles %}
<tr>
<th scope="row" role="rowheader" data-sort-value="{{ permission.user.email }}" data-label="Email">
{{ permission.user.email }}
<th scope="row" role="rowheader" data-sort-value="{{ item.permission.user.email }}" data-label="Email">
{{ item.permission.user.email }}
{% if item.has_admin_flag %}<span class="usa-tag margin-left-1 bg-primary">Admin</span>{% endif %}
</th>
<td data-label="Role">{{ permission.role|title }}</td>
{% if not portfolio %}<td data-label="Role">{{ item.permission.role|title }}</td>{% endif %}
<td>
{% if can_delete_users %}
<a
@ -52,7 +62,7 @@
Remove
</a>
{# Display a custom message if the user is trying to delete themselves #}
{% if permission.user.email == current_user_email %}
{% if item.permission.user.email == current_user_email %}
<div
class="usa-modal"
id="toggle-user-alert-{{ forloop.counter }}"
@ -60,7 +70,7 @@
aria-describedby="You will be removed from this domain"
data-force-action
>
<form method="POST" action="{% url "domain-user-delete" pk=domain.id user_pk=permission.user.id %}">
<form method="POST" action="{% url "domain-user-delete" pk=domain.id user_pk=item.permission.user.id %}">
{% with domain_name=domain.name|force_escape %}
{% include 'includes/modal.html' with modal_heading="Are you sure you want to remove yourself as a domain manager?" modal_description="You will no longer be able to manage the domain <strong>"|add:domain_name|add:"</strong>."|safe modal_button=modal_button_self|safe %}
{% endwith %}
@ -71,11 +81,11 @@
class="usa-modal"
id="toggle-user-alert-{{ forloop.counter }}"
aria-labelledby="Are you sure you want to continue?"
aria-describedby="{{ permission.user.email }} will be removed"
aria-describedby="{{ item.permission.user.email }} will be removed"
data-force-action
>
<form method="POST" action="{% url "domain-user-delete" pk=domain.id user_pk=permission.user.id %}">
{% with email=permission.user.email|default:permission.user|force_escape domain_name=domain.name|force_escape %}
<form method="POST" action="{% url "domain-user-delete" pk=domain.id user_pk=item.permission.user.id %}">
{% with email=item.permission.user.email|default:item.permission.user|force_escape domain_name=domain.name|force_escape %}
{% include 'includes/modal.html' with modal_heading="Are you sure you want to remove " heading_value=email|add:"?" modal_description="<strong>"|add:email|add:"</strong> will no longer be able to manage the domain <strong>"|add:domain_name|add:"</strong>."|safe modal_button=modal_button|safe %}
{% endwith %}
</form>
@ -111,7 +121,7 @@
</a>
</section>
{% if domain.invitations.exists %}
{% if invitations %}
<section class="section-outlined">
<h2>Invitations</h2>
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table">
@ -120,21 +130,22 @@
<tr>
<th data-sortable scope="col" role="columnheader">Email</th>
<th data-sortable scope="col" role="columnheader">Date created</th>
<th class="grid-col-2" data-sortable scope="col" role="columnheader">Status</th>
{% if not portfolio %}<th class="grid-col-2" data-sortable scope="col" role="columnheader">Status</th>{% endif %}
<th class="grid-col-1" scope="col" role="columnheader"><span class="sr-only">Action</span></th>
</tr>
</thead>
<tbody>
{% for invitation in domain.invitations.all %}
{% for invitation in invitations %}
<tr>
<th scope="row" role="rowheader" data-sort-value="{{ invitation.user.email }}" data-label="Email">
{{ invitation.email }}
<th scope="row" role="rowheader" data-sort-value="{{ invitation.domain_invitation.user.email }}" data-label="Email">
{{ invitation.domain_invitation.email }}
{% if invitation.has_admin_flag %}<span class="usa-tag margin-left-1 bg-primary">Admin</span>{% endif %}
</th>
<td data-sort-value="{{ invitation.created_at|date:"U" }}" data-label="Date created">{{ invitation.created_at|date }} </td>
<td data-label="Status">{{ invitation.status|title }}</td>
<td data-sort-value="{{ invitation.domain_invitation.created_at|date:"U" }}" data-label="Date created">{{ invitation.domain_invitation.created_at|date }} </td>
{% if not portfolio %}<td data-label="Status">{{ invitation.domain_invitation.status|title }}</td>{% endif %}
<td>
{% if invitation.status == invitation.DomainInvitationStatus.INVITED %}
<form method="POST" action="{% url "invitation-delete" pk=invitation.id %}">
{% if invitation.domain_invitation.status == invitation.domain_invitation.DomainInvitationStatus.INVITED %}
<form method="POST" action="{% url "invitation-cancel" pk=invitation.domain_invitation.id %}">
{% csrf_token %}<input type="submit" class="usa-button--unstyled text-no-underline cursor-pointer" value="Cancel">
</form>
{% endif %}

View file

@ -17,13 +17,8 @@
<h1>Manage your domains</h1>
{% comment %}
IMPORTANT:
If this button is added on any other page, make sure to update the
relevant view to reset request.session["new_request"] = True
{% endcomment %}
<p class="margin-top-4">
<a href="{% url 'domain-request:' %}" class="usa-button"
<a href="{% url 'domain-request:start' %}" class="usa-button"
>
Start a new domain request
</a>

View file

@ -0,0 +1,12 @@
<div class="margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
<div class="usa-alert usa-alert--error">
<div class="usa-alert__body {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
<h4 class="usa-alert__heading">
Header
</h4>
<p class="usa-alert__text maxw-none">
Text here
</p>
</div>
</div>
</div>

View file

@ -0,0 +1,12 @@
<section class="usa-site-alert usa-site-alert--info margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
<div class="usa-alert">
<div class="usa-alert__body {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
<h4 class="usa-alert__heading">
Header
</h4>
<p class="usa-alert__text maxw-none">
Text here
</p>
</div>
</div>
</section>

View file

@ -0,0 +1,9 @@
<section class="usa-site-alert usa-site-alert--emergency usa-site-alert--hot-pink margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
<div class="usa-alert">
<div class="usa-alert__body {% if add_body_class %}{{ add_body_class }}{% endif %} {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
<p class="usa-alert__text maxw-none">
<strong>Attention:</strong> You are on a test site.
</p>
</div>
</div>
</section>

View file

@ -0,0 +1,12 @@
<section class="usa-site-alert usa-site-alert--emergency margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
<div class="usa-alert">
<div class="usa-alert__body {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
<h3 class="usa-alert__heading">
Service disruption
</h3>
<p class="usa-alert__text maxw-none">
Month day, time-in-24-hour-notation UTC: We're investigating a service disruption on the .gov registrar. The .gov zone and individual domains remain online. However, the registrar is running slower than usual.
</p>
</div>
</div>
</section>

View file

@ -0,0 +1,12 @@
<section class="usa-site-alert usa-site-alert--emergency margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
<div class="usa-alert">
<div class="usa-alert__body {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
<h3 class="usa-alert__heading">
Header here
</h3>
<p class="usa-alert__tex maxw-none">
Text here
</p>
</div>
</div>
</section>

View file

@ -0,0 +1,12 @@
<section class="usa-site-alert usa-site-alert--emergency margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
<div class="usa-alert">
<div class="usa-alert__body {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
<h3 class="usa-alert__heading">
System outage
</h3>
<p class="usa-alert__text maxw-none">
Oct 16, 24:00 UTC: We're investigating an outage on the .gov registrar. The .gov zone and individual domains remain online. However, you can't request a new domain or manage an existing one at this time.
</p>
</div>
</div>
</section>

View file

@ -0,0 +1,12 @@
<div class="margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
<div class="usa-alert usa-alert--warning">
<div class="usa-alert__body {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
<h4 class="usa-alert__heading">
Header
</h4>
<p class="usa-alert__text maxw-none">
Text here
</p>
</div>
</div>
</div>

View file

@ -34,7 +34,6 @@
</ul>
</div>
<ul class="usa-nav__primary usa-accordion">
{% if not hide_domains %}
<li class="usa-nav__primary-item">
{% if has_any_domains_portfolio_permission %}
{% url 'domains' as url %}
@ -45,14 +44,13 @@
Domains
</a>
</li>
{% endif %}
<!-- <li class="usa-nav__primary-item">
<a href="#" class="usa-nav-link">
Domain groups
</a>
</li> -->
{% if has_organization_requests_flag and not hide_requests %}
{% if has_organization_requests_flag %}
<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 %}
@ -72,7 +70,7 @@
>
</li>
<li class="usa-nav__submenu-item">
<a href="{% url 'domain-request:' %}"
<a href="{% url 'domain-request:start' %}"
><span>Start a new domain request</span></a
>
</li>
@ -93,7 +91,7 @@
</li>
{% endif %}
{% if has_organization_members_flag and not hide_members %}
{% if has_organization_members_flag %}
<li class="usa-nav__primary-item">
<a href="{% url 'members' %}" class="usa-nav-link {% if path|is_members_subpage %} usa-current{% endif %}">
Members

View file

@ -1,14 +1,14 @@
{% load static %}
<!-- Embedding the portfolio value in a data attribute -->
<span id="portfolio-js-value" class="display-none" data-portfolio="{{ portfolio.id }}"></span>
<span id="portfolio-js-value" class="display-none" data-portfolio="{{ portfolio.id }}" data-has-edit-permission="{{ has_edit_members_portfolio_permission }}"></span>
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
{% url 'get_portfolio_members_json' as url %}
<span id="get_members_json_url" class="display-none">{{url}}</span>
<section class="section-outlined members margin-top-0 section-outlined--border-base-light" id="members">
<div class="section-outlined__header margin-bottom-3 grid-row">
<!-- ---------- SEARCH ---------- -->
<div class="section-outlined__search mobile:grid-col-12 desktop:grid-col-6">
<div class="section-outlined__search mobile:grid-col-12 desktop:grid-col-6 {% if is_widescreen_mode %} section-outlined__search--widescreen {% endif %}">
<section aria-label="Members search component" class="margin-top-2">
<form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %}
@ -36,6 +36,15 @@
</form>
</section>
</div>
<div class="section-outlined__utility-button mobile-lg:padding-right-105 {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}">
<section aria-label="Domains report component" class="margin-top-205">
<a href="{% url 'export_members_portfolio' %}" class="usa-button usa-button--unstyled usa-button--with-icon usa-button--justify-right" role="button">
<svg class="usa-icon usa-icon--big" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
</svg>Export as CSV
</a>
</section>
</div>
</div>
<!-- ---------- MAIN TABLE ---------- -->

View file

@ -2,7 +2,7 @@
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading" id="modal-1-heading">
<h2 class="usa-modal__heading">
{{ modal_heading }}
{%if domain_name_modal is not None %}
<span class="domain-name-wrap">
@ -16,7 +16,7 @@
{% endif %}
</h2>
<div class="usa-prose">
<p id="modal-1-description">
<p>
{{ modal_description }}
</p>
</div>

View file

@ -1,7 +0,0 @@
<div class="usa-site-alert--emergency margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
<div class="usa-alert">
<div class="usa-alert__body {% if add_body_class %}{{ add_body_class }}{% endif %} {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
<b>Attention:</b> You are on a test site.
</div>
</div>
</div>

View file

@ -4,7 +4,7 @@
{% for step in steps %}
<section class="summary-item margin-top-3">
{% if is_editable %}
{% namespaced_url 'domain-request' step as domain_request_url %}
{% namespaced_url 'domain-request' step id=domain_request_id as domain_request_url %}
{% endif %}
{% if step == Step.REQUESTING_ENTITY %}

View file

@ -4,7 +4,7 @@
{% for step in steps %}
<section class="summary-item margin-top-3">
{% if is_editable %}
{% namespaced_url 'domain-request' step as domain_request_url %}
{% namespaced_url 'domain-request' step id=domain_request_id as domain_request_url %}
{% endif %}
{% if step == Step.ORGANIZATION_TYPE %}

View file

@ -1,7 +1,7 @@
{% extends "base.html" %}
{% block wrapper %}
<div id="wrapper" class="{% block wrapper_class %}dashboard--portfolio{% endblock %}">
<div id="wrapper" class="{% block wrapper_class %}wrapper--padding-top-6{% endblock %}">
{% block content %}
<main class="grid-container {% if is_widescreen_mode %} grid-container--widescreen {% endif %}">

View file

@ -1,7 +1,9 @@
{% extends 'portfolio_base.html' %}
{% load static field_helpers%}
{% block title %}Organization member {% endblock %}
{% block title %}
Organization member
{% endblock %}
{% load static %}
@ -33,60 +35,30 @@
</h2>
{% if has_edit_members_portfolio_permission %}
{% if member %}
<a
role="button"
href="#"
class="display-block usa-button text-secondary usa-button--unstyled text-no-underline margin-bottom-3 line-height-sans-5 visible-mobile-flex"
>
Remove member
</a>
{% else %}
<a
role="button"
href="#"
class="display-block usa-button text-secondary usa-button--unstyled text-no-underline margin-bottom-3 line-height-sans-5 visible-mobile-flex"
>
Cancel invitation
</a>
{% endif %}
<div class="usa-accordion usa-accordion--more-actions 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"
>
<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" class="usa-accordion__content usa-prose shadow-1 left-auto right-0" hidden>
<h2>More options</h2>
{% if member %}
<a
role="button"
href="#"
class="usa-button text-secondary usa-button--unstyled text-no-underline margin-top-2 line-height-sans-5"
>
Remove member
</a>
{% else %}
<a
role="button"
href="#"
class="usa-button text-secondary usa-button--unstyled text-no-underline margin-top-2 line-height-sans-5"
>
Cancel invitation
</a>
{% endif %}
<div id="wrapper-delete-action"
data-member-name="{{ member.email }}"
data-member-type="member"
data-member-id="{{ member.id }}"
data-num-domains="{{ portfolio_permission.get_managed_domains_count }}"
data-member-email="{{ member.email }}"
>
<!-- JS should inject member kebob here -->
</div>
{% elif portfolio_invitation %}
<div id="wrapper-delete-action"
data-member-name="{{ portfolio_invitation.email }}"
data-member-type="invitedmember"
data-member-id="{{ portfolio_invitation.id }}"
data-num-domains="{{ portfolio_invitation.get_managed_domains_count }}"
data-member-email="{{ portfolio_invitation.email }}"
>
<!-- JS should inject invited kebob here -->
</div>
{% endif %}
{% endif %}
</div>
<form method="post" id="member-delete-form" action="{{ request.path }}/delete"> {% csrf_token %} </form>
<address>
<strong class="text-primary-dark">Last active:</strong>
{% if member and member.last_login %}

View file

@ -9,11 +9,15 @@
{% endblock %}
{% block portfolio_content %}
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock %}
<div id="main-content">
<div id="toggleable-alert" class="usa-alert usa-alert--slim margin-bottom-2 display-none">
<div class="usa-alert__body usa-alert__body--widescreen">
<p class="usa-alert__text ">
<!-- alert message will be conditionally populated by javascript -->
</p>
</div>
</div>
<div class="grid-row grid-gap">
<div class="mobile:grid-col-12 tablet:grid-col-6">
<h1 id="members-header">Members</h1>

View file

@ -18,7 +18,7 @@
<h2 id="domains-header" class="display-inline-block">You arent managing any domains.</h2>
{% if portfolio_administrators %}
<p>If you believe you should have access to a domain, reach out to your organizations administrators.</p>
<p>Your organizations administrators:</p>
<p>Your organization's administrators:</p>
<ul class="margin-top-0">
{% for administrator in portfolio_administrators %}
{% if administrator.email %}

View file

@ -5,13 +5,13 @@
{% block title %} Domain Requests | {% endblock %}
{% block portfolio_content %}
<h1 id="domains-header">Current domain requests</h1>
<h1 id="domains-header">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>
<p>If you believe you should have access to requests, reach out to your organizations administrators.</p>
<p>Your organization's administrators:</p>
<ul class="margin-top-0">
{% for administrator in portfolio_administrators %}
{% if administrator.email %}

View file

@ -14,21 +14,17 @@
{% endblock %}
<div id="main-content">
<h1 id="domain-requests-header">Domain requests</h1>
<h1 id="domain-requests-header" class="margin-bottom-1">Domain requests</h1>
<div class="grid-row grid-gap">
{% if has_edit_request_portfolio_permission %}
<div class="mobile:grid-col-12 tablet:grid-col-6">
<p class="margin-y-0">Domain requests can only be modified by the person who created the request.</p>
<p class="margin-y-0 maxw-mobile">Domain requests can only be modified by the person who created the request.</p>
</div>
<div class="mobile:grid-col-12 tablet:grid-col-6">
{% comment %}
IMPORTANT:
If this button is added on any other page, make sure to update the
relevant view to reset request.session["new_request"] = True
{% endcomment %}
<p class="float-right-tablet tablet:margin-y-0">
<a href="{% url 'domain-request:' %}" class="usa-button"
<a href="{% url 'domain-request:start' %}" class="usa-button"
>
Start a new domain request
</a>

View file

@ -51,11 +51,11 @@ Edit your User Profile |
>
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading" id="modal-1-heading">
<h2 class="usa-modal__heading">
Add contact information
</h2>
<div class="usa-prose">
<p id="modal-1-description">
<p>
.Gov domain registrants must maintain accurate contact information in the .gov registrar.
Before you can manage your domain, we need you to add your contact information.
</p>

View file

@ -200,7 +200,7 @@ def is_domain_subpage(path):
"domain-users-add",
"domain-request-delete",
"domain-user-delete",
"invitation-delete",
"invitation-cancel",
]
return get_url_name(path) in url_names

View file

@ -1,6 +1,5 @@
import os
import logging
from contextlib import contextmanager
import random
from string import ascii_uppercase
@ -29,6 +28,7 @@ from registrar.models import (
FederalAgency,
UserPortfolioPermission,
Portfolio,
PortfolioInvitation,
)
from epplibwrapper import (
commands,
@ -39,6 +39,7 @@ from epplibwrapper import (
ErrorCode,
responses,
)
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.models.user_domain_role import UserDomainRole
from registrar.models.utility.contact_error import ContactError, ContactErrorCodes
@ -196,6 +197,7 @@ class GenericTestHelper(TestCase):
self.assertEqual(expected_sort_order, returned_sort_order)
@classmethod
def _mock_user_request_for_factory(self, request):
"""Adds sessionmiddleware when using factory to associate session information"""
middleware = SessionMiddleware(lambda req: req)
@ -531,6 +533,8 @@ class MockDb(TestCase):
@classmethod
@less_console_noise_decorator
def sharedSetUp(cls):
cls.mock_client_class = MagicMock()
cls.mock_client = cls.mock_client_class.return_value
username = "test_user"
first_name = "First"
last_name = "Last"
@ -540,6 +544,29 @@ class MockDb(TestCase):
cls.user = get_user_model().objects.create(
username=username, first_name=first_name, last_name=last_name, email=email, title=title, phone=phone
)
cls.meoward_user = get_user_model().objects.create(
username="meoward_username", first_name="first_meoward", last_name="last_meoward", email="meoward@rocks.com"
)
cls.lebowski_user = get_user_model().objects.create(
username="big_lebowski", first_name="big", last_name="lebowski", email="big_lebowski@dude.co"
)
cls.tired_user = get_user_model().objects.create(
username="ministry_of_bedtime", first_name="tired", last_name="sleepy", email="tired_sleepy@igorville.gov"
)
# Custom superuser and staff so that these do not conflict with what may be defined on what implements this.
cls.custom_superuser = create_superuser(
username="cold_superuser", first_name="cold", last_name="icy", email="icy_superuser@igorville.gov"
)
cls.custom_staffuser = create_user(
username="warm_staff", first_name="warm", last_name="cozy", email="cozy_staffuser@igorville.gov"
)
cls.federal_agency_1, _ = FederalAgency.objects.get_or_create(agency="World War I Centennial Commission")
cls.federal_agency_2, _ = FederalAgency.objects.get_or_create(agency="Armed Forces Retirement Home")
cls.portfolio_1, _ = Portfolio.objects.get_or_create(
creator=cls.custom_superuser, federal_agency=cls.federal_agency_1
)
current_date = get_time_aware_date(datetime(2024, 4, 2))
# Create start and end dates using timedelta
@ -547,9 +574,6 @@ class MockDb(TestCase):
cls.end_date = current_date + timedelta(days=2)
cls.start_date = current_date - timedelta(days=2)
cls.federal_agency_1, _ = FederalAgency.objects.get_or_create(agency="World War I Centennial Commission")
cls.federal_agency_2, _ = FederalAgency.objects.get_or_create(agency="Armed Forces Retirement Home")
cls.domain_1, _ = Domain.objects.get_or_create(
name="cdomain1.gov", state=Domain.State.READY, first_ready=get_time_aware_date(datetime(2024, 4, 2))
)
@ -596,9 +620,14 @@ class MockDb(TestCase):
federal_agency=cls.federal_agency_1,
federal_type="executive",
is_election_board=False,
portfolio=cls.portfolio_1,
)
cls.domain_information_2, _ = DomainInformation.objects.get_or_create(
creator=cls.user, domain=cls.domain_2, generic_org_type="interstate", is_election_board=True
creator=cls.user,
domain=cls.domain_2,
generic_org_type="interstate",
is_election_board=True,
portfolio=cls.portfolio_1,
)
cls.domain_information_3, _ = DomainInformation.objects.get_or_create(
creator=cls.user,
@ -671,14 +700,6 @@ class MockDb(TestCase):
is_election_board=False,
)
cls.meoward_user = get_user_model().objects.create(
username="meoward_username", first_name="first_meoward", last_name="last_meoward", email="meoward@rocks.com"
)
cls.lebowski_user = get_user_model().objects.create(
username="big_lebowski", first_name="big", last_name="lebowski", email="big_lebowski@dude.co"
)
_, created = UserDomainRole.objects.get_or_create(
user=cls.meoward_user, domain=cls.domain_1, role=UserDomainRole.Roles.MANAGER
)
@ -709,6 +730,12 @@ class MockDb(TestCase):
status=DomainInvitation.DomainInvitationStatus.RETRIEVED,
)
_, created = DomainInvitation.objects.get_or_create(
email=cls.meoward_user.email,
domain=cls.domain_11,
status=DomainInvitation.DomainInvitationStatus.RETRIEVED,
)
_, created = DomainInvitation.objects.get_or_create(
email="woofwardthethird@rocks.com",
domain=cls.domain_1,
@ -723,6 +750,85 @@ class MockDb(TestCase):
email="squeaker@rocks.com", domain=cls.domain_10, status=DomainInvitation.DomainInvitationStatus.INVITED
)
cls.portfolio_invitation_1, _ = PortfolioInvitation.objects.get_or_create(
email=cls.meoward_user.email,
portfolio=cls.portfolio_1,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[UserPortfolioPermissionChoices.EDIT_MEMBERS],
)
cls.portfolio_invitation_2, _ = PortfolioInvitation.objects.get_or_create(
email=cls.lebowski_user.email,
portfolio=cls.portfolio_1,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[UserPortfolioPermissionChoices.VIEW_MEMBERS],
)
cls.portfolio_invitation_3, _ = PortfolioInvitation.objects.get_or_create(
email=cls.tired_user.email,
portfolio=cls.portfolio_1,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS],
)
cls.portfolio_invitation_4, _ = PortfolioInvitation.objects.get_or_create(
email=cls.custom_superuser.email,
portfolio=cls.portfolio_1,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.EDIT_REQUESTS,
],
)
cls.portfolio_invitation_5, _ = PortfolioInvitation.objects.get_or_create(
email=cls.custom_staffuser.email,
portfolio=cls.portfolio_1,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
)
# Add some invitations that we never retireve
PortfolioInvitation.objects.get_or_create(
email="nonexistentmember_1@igorville.gov",
portfolio=cls.portfolio_1,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[UserPortfolioPermissionChoices.EDIT_MEMBERS],
)
PortfolioInvitation.objects.get_or_create(
email="nonexistentmember_2@igorville.gov",
portfolio=cls.portfolio_1,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[UserPortfolioPermissionChoices.VIEW_MEMBERS],
)
PortfolioInvitation.objects.get_or_create(
email="nonexistentmember_3@igorville.gov",
portfolio=cls.portfolio_1,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS],
)
PortfolioInvitation.objects.get_or_create(
email="nonexistentmember_4@igorville.gov",
portfolio=cls.portfolio_1,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.EDIT_REQUESTS,
],
)
PortfolioInvitation.objects.get_or_create(
email="nonexistentmember_5@igorville.gov",
portfolio=cls.portfolio_1,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
)
with less_console_noise():
cls.domain_request_1 = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
@ -731,10 +837,12 @@ class MockDb(TestCase):
cls.domain_request_2 = completed_domain_request(
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
name="city2.gov",
portfolio=cls.portfolio_1,
)
cls.domain_request_3 = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="city3.gov",
portfolio=cls.portfolio_1,
)
cls.domain_request_4 = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
@ -749,6 +857,7 @@ class MockDb(TestCase):
cls.domain_request_6 = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="city6.gov",
portfolio=cls.portfolio_1,
)
cls.domain_request_3.submit()
cls.domain_request_4.submit()
@ -797,6 +906,7 @@ class MockDb(TestCase):
UserPortfolioPermission.objects.all().delete()
User.objects.all().delete()
DomainInvitation.objects.all().delete()
PortfolioInvitation.objects.all().delete()
cls.federal_agency_1.delete()
cls.federal_agency_2.delete()
@ -837,17 +947,18 @@ def mock_user():
return mock_user
def create_superuser():
def create_superuser(**kwargs):
"""Creates a analyst user with is_staff=True and the group full_access_group"""
User = get_user_model()
p = "adminpass"
user = User.objects.create_user(
username="superuser",
email="admin@example.com",
first_name="first",
last_name="last",
is_staff=True,
password=p,
phone="8003111234",
username=kwargs.get("username", "superuser"),
email=kwargs.get("email", "admin@example.com"),
first_name=kwargs.get("first_name", "first"),
last_name=kwargs.get("last_name", "last"),
is_staff=kwargs.get("is_staff", True),
password=kwargs.get("password", p),
phone=kwargs.get("phone", "8003111234"),
)
# Retrieve the group or create it if it doesn't exist
group, _ = UserGroup.objects.get_or_create(name="full_access_group")
@ -856,18 +967,19 @@ def create_superuser():
return user
def create_user():
def create_user(**kwargs):
"""Creates a analyst user with is_staff=True and the group cisa_analysts_group"""
User = get_user_model()
p = "userpass"
user = User.objects.create_user(
username="staffuser",
email="staff@example.com",
first_name="first",
last_name="last",
is_staff=True,
title="title",
password=p,
phone="8003111234",
username=kwargs.get("username", "staffuser"),
email=kwargs.get("email", "staff@example.com"),
first_name=kwargs.get("first_name", "first"),
last_name=kwargs.get("last_name", "last"),
is_staff=kwargs.get("is_staff", True),
title=kwargs.get("title", "title"),
password=kwargs.get("password", p),
phone=kwargs.get("phone", "8003111234"),
)
# Retrieve the group or create it if it doesn't exist
group, _ = UserGroup.objects.get_or_create(name="cisa_analysts_group")

View file

@ -1526,7 +1526,7 @@ class TestDomainRequestAdmin(MockEppLib):
self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields)
# Test for the copy link
self.assertContains(response, "copy-to-clipboard", count=4)
self.assertContains(response, "copy-to-clipboard", count=5)
# Test that Creator counts display properly
self.assertNotContains(response, "Approved domains")
@ -1626,6 +1626,17 @@ class TestDomainRequestAdmin(MockEppLib):
readonly_fields = self.admin.get_readonly_fields(request, domain_request)
expected_fields = [
"portfolio_senior_official",
"portfolio_organization_type",
"portfolio_federal_type",
"portfolio_organization_name",
"portfolio_federal_agency",
"portfolio_state_territory",
"portfolio_address_line1",
"portfolio_address_line2",
"portfolio_city",
"portfolio_zipcode",
"portfolio_urbanization",
"other_contacts",
"current_websites",
"alternative_domains",
@ -1691,6 +1702,17 @@ class TestDomainRequestAdmin(MockEppLib):
readonly_fields = self.admin.get_readonly_fields(request)
self.maxDiff = None
expected_fields = [
"portfolio_senior_official",
"portfolio_organization_type",
"portfolio_federal_type",
"portfolio_organization_name",
"portfolio_federal_agency",
"portfolio_state_territory",
"portfolio_address_line1",
"portfolio_address_line2",
"portfolio_city",
"portfolio_zipcode",
"portfolio_urbanization",
"other_contacts",
"current_websites",
"alternative_domains",
@ -1723,6 +1745,17 @@ class TestDomainRequestAdmin(MockEppLib):
readonly_fields = self.admin.get_readonly_fields(request)
expected_fields = [
"portfolio_senior_official",
"portfolio_organization_type",
"portfolio_federal_type",
"portfolio_organization_name",
"portfolio_federal_agency",
"portfolio_state_territory",
"portfolio_address_line1",
"portfolio_address_line2",
"portfolio_city",
"portfolio_zipcode",
"portfolio_urbanization",
"other_contacts",
"current_websites",
"alternative_domains",

View file

@ -2,7 +2,8 @@ from django.urls import reverse
from django.test import TestCase, Client
from registrar.models import FederalAgency, SeniorOfficial, User, DomainRequest
from django.contrib.auth import get_user_model
from registrar.tests.common import create_superuser, create_user, completed_domain_request
from registrar.models.portfolio import Portfolio
from registrar.tests.common import create_superuser, create_test_user, create_user, completed_domain_request
from api.tests.common import less_console_noise_decorator
from registrar.utility.constants import BranchChoices
@ -74,6 +75,79 @@ class GetSeniorOfficialJsonTest(TestCase):
self.assertEqual(data["error"], "Senior Official not found")
class GetPortfolioJsonTest(TestCase):
def setUp(self):
self.client = Client()
self.user = create_test_user()
self.superuser = create_superuser()
self.analyst_user = create_user()
self.agency = FederalAgency.objects.create(agency="Test Agency")
self.senior_official = SeniorOfficial.objects.create(
first_name="John", last_name="Doe", title="Director", federal_agency=self.agency
)
self.portfolio = Portfolio.objects.create(
creator=self.user,
federal_agency=self.agency,
senior_official=self.senior_official,
organization_name="Org name",
organization_type=Portfolio.OrganizationChoices.FEDERAL,
)
self.api_url = reverse("get-portfolio-json")
def tearDown(self):
Portfolio.objects.all().delete()
User.objects.all().delete()
SeniorOfficial.objects.all().delete()
FederalAgency.objects.all().delete()
@less_console_noise_decorator
def test_get_portfolio_authenticated_superuser(self):
"""Test that a superuser can get the portfolio information."""
self.client.force_login(self.superuser)
response = self.client.get(self.api_url, {"id": self.portfolio.id})
self.assertEqual(response.status_code, 200)
portfolio = response.json()
self.assertEqual(portfolio["id"], self.portfolio.id)
self.assertEqual(portfolio["creator"], self.user.id)
self.assertEqual(portfolio["organization_name"], self.portfolio.organization_name)
self.assertEqual(portfolio["organization_type"], "Federal")
self.assertEqual(portfolio["notes"], None)
self.assertEqual(portfolio["federal_agency"]["id"], self.agency.id)
self.assertEqual(portfolio["federal_agency"]["agency"], self.agency.agency)
self.assertEqual(portfolio["senior_official"]["id"], self.senior_official.id)
self.assertEqual(portfolio["senior_official"]["first_name"], self.senior_official.first_name)
self.assertEqual(portfolio["senior_official"]["last_name"], self.senior_official.last_name)
self.assertEqual(portfolio["senior_official"]["title"], self.senior_official.title)
self.assertEqual(portfolio["senior_official"]["phone"], None)
self.assertEqual(portfolio["senior_official"]["email"], None)
self.assertEqual(portfolio["federal_type"], "-")
@less_console_noise_decorator
def test_get_portfolio_json_authenticated_analyst(self):
"""Test that an analyst user can fetch the portfolio's information."""
self.client.force_login(self.analyst_user)
response = self.client.get(self.api_url, {"id": self.portfolio.id})
self.assertEqual(response.status_code, 200)
portfolio = response.json()
self.assertEqual(portfolio["id"], self.portfolio.id)
@less_console_noise_decorator
def test_get_portfolio_json_unauthenticated(self):
"""Test that an unauthenticated user receives a 403 with an error message."""
self.client.force_login(self.user)
response = self.client.get(self.api_url, {"id": self.portfolio.id})
self.assertEqual(response.status_code, 302)
@less_console_noise_decorator
def test_get_portfolio_json_not_found(self):
"""Test that a request for a non-existent portfolio returns a 404 with an error message."""
self.client.force_login(self.superuser)
response = self.client.get(self.api_url, {"id": -1})
self.assertEqual(response.status_code, 404)
class GetFederalPortfolioTypeJsonTest(TestCase):
def setUp(self):
self.client = Client()

View file

@ -824,6 +824,92 @@ class TestUser(TestCase):
cm.exception.message, "When portfolio roles or additional permissions are assigned, portfolio is required."
)
@less_console_noise_decorator
def test_get_active_requests_count_in_portfolio_returns_zero_if_no_portfolio(self):
# There is no portfolio referenced in session so should return 0
request = self.factory.get("/")
request.session = {}
count = self.user.get_active_requests_count_in_portfolio(request)
self.assertEqual(count, 0)
@less_console_noise_decorator
def test_get_active_requests_count_in_portfolio_returns_count_if_portfolio(self):
request = self.factory.get("/")
request.session = {"portfolio": self.portfolio}
# Create active requests
domain_1, _ = DraftDomain.objects.get_or_create(name="meoward1.gov")
domain_2, _ = DraftDomain.objects.get_or_create(name="meoward2.gov")
domain_3, _ = DraftDomain.objects.get_or_create(name="meoward3.gov")
domain_4, _ = DraftDomain.objects.get_or_create(name="meoward4.gov")
# Create 3 active requests + 1 that isn't
DomainRequest.objects.create(
creator=self.user,
requested_domain=domain_1,
status=DomainRequest.DomainRequestStatus.SUBMITTED,
portfolio=self.portfolio,
)
DomainRequest.objects.create(
creator=self.user,
requested_domain=domain_2,
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
portfolio=self.portfolio,
)
DomainRequest.objects.create(
creator=self.user,
requested_domain=domain_3,
status=DomainRequest.DomainRequestStatus.ACTION_NEEDED,
portfolio=self.portfolio,
)
DomainRequest.objects.create( # This one should not be counted
creator=self.user,
requested_domain=domain_4,
status=DomainRequest.DomainRequestStatus.REJECTED,
portfolio=self.portfolio,
)
count = self.user.get_active_requests_count_in_portfolio(request)
self.assertEqual(count, 3)
@less_console_noise_decorator
def test_is_only_admin_of_portfolio_returns_true(self):
# Create user as the only admin of the portfolio
UserPortfolioPermission.objects.create(
user=self.user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
self.assertTrue(self.user.is_only_admin_of_portfolio(self.portfolio))
@less_console_noise_decorator
def test_is_only_admin_of_portfolio_returns_false_if_no_admins(self):
# No admin for the portfolio
self.assertFalse(self.user.is_only_admin_of_portfolio(self.portfolio))
@less_console_noise_decorator
def test_is_only_admin_of_portfolio_returns_false_if_multiple_admins(self):
# Create multiple admins for the same portfolio
UserPortfolioPermission.objects.create(
user=self.user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
# Create another user within this test
other_user = User.objects.create(email="second_admin@igorville.gov", username="second_admin")
UserPortfolioPermission.objects.create(
user=other_user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
self.assertFalse(self.user.is_only_admin_of_portfolio(self.portfolio))
@less_console_noise_decorator
def test_is_only_admin_of_portfolio_returns_false_if_user_not_admin(self):
# Create other_user for same portfolio and is given admin access
other_user = User.objects.create(email="second_admin@igorville.gov", username="second_admin")
UserPortfolioPermission.objects.create(
user=other_user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
# User doesn't have admin access so should return false
self.assertFalse(self.user.is_only_admin_of_portfolio(self.portfolio))
class TestContact(TestCase):
@less_console_noise_decorator

View file

@ -5,6 +5,8 @@ from registrar.models import (
DomainRequest,
Domain,
UserDomainRole,
PortfolioInvitation,
User,
)
from registrar.models import Portfolio, DraftDomain
from registrar.models.user_portfolio_permission import UserPortfolioPermission
@ -22,6 +24,7 @@ from registrar.utility.csv_export import (
DomainRequestExport,
DomainRequestGrowth,
DomainRequestDataFull,
MemberExport,
get_default_start_date,
get_default_end_date,
)
@ -42,9 +45,14 @@ from .common import (
get_wsgi_request_object,
less_console_noise,
get_time_aware_date,
GenericTestHelper,
)
from waffle.testutils import override_flag
from datetime import datetime
from django.contrib.admin.models import LogEntry, ADDITION
from django.contrib.contenttypes.models import ContentType
class CsvReportsTest(MockDbForSharedTests):
"""Tests to determine if we are uploading our reports correctly."""
@ -800,6 +808,104 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
self.assertEqual(csv_content, expected_content)
class MemberExportTest(MockDbForIndividualTests, MockEppLib):
def setUp(self):
"""Override of the base setUp to add a request factory"""
super().setUp()
self.factory = RequestFactory()
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@less_console_noise_decorator
def test_member_export(self):
"""Tests the member export report by comparing the csv output."""
# == Data setup == #
# Set last_login for some users
active_date = timezone.make_aware(datetime(2024, 2, 1))
User.objects.filter(id__in=[self.custom_superuser.id, self.custom_staffuser.id]).update(last_login=active_date)
# Create a logentry for meoward, created by lebowski to test invited_by.
content_type = ContentType.objects.get_for_model(PortfolioInvitation)
LogEntry.objects.create(
user=self.lebowski_user,
content_type=content_type,
object_id=self.portfolio_invitation_1.id,
object_repr=str(self.portfolio_invitation_1),
action_flag=ADDITION,
change_message="Created invitation",
action_time=timezone.make_aware(datetime(2023, 4, 12)),
)
# Create log entries for each remaining invitation. Exclude meoward and tired_user.
for invitation in PortfolioInvitation.objects.exclude(
id__in=[self.portfolio_invitation_1.id, self.portfolio_invitation_3.id]
):
LogEntry.objects.create(
user=self.custom_staffuser,
content_type=content_type,
object_id=invitation.id,
object_repr=str(invitation),
action_flag=ADDITION,
change_message="Created invitation",
action_time=timezone.make_aware(datetime(2024, 1, 15)),
)
# Retrieve invitations
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
self.meoward_user.check_portfolio_invitations_on_login()
self.lebowski_user.check_portfolio_invitations_on_login()
self.tired_user.check_portfolio_invitations_on_login()
self.custom_superuser.check_portfolio_invitations_on_login()
self.custom_staffuser.check_portfolio_invitations_on_login()
# Update the created at date on UserPortfolioPermission, so we can test a consistent date.
UserPortfolioPermission.objects.filter(portfolio=self.portfolio_1).update(
created_at=timezone.make_aware(datetime(2022, 4, 1))
)
# == End of data setup == #
# Create a request and add the user to the request
request = self.factory.get("/")
request.user = self.user
self.maxDiff = None
# Add portfolio to session
request = GenericTestHelper._mock_user_request_for_factory(request)
request.session["portfolio"] = self.portfolio_1
# Create a CSV file in memory
csv_file = StringIO()
# Call the export function
MemberExport.export_data_to_csv(csv_file, request=request)
# Reset the CSV file's position to the beginning
csv_file.seek(0)
# Read the content into a variable
csv_content = csv_file.read()
expected_content = (
# Header
"Email,Organization admin,Invited by,Joined date,Last active,Domain requests,"
"Member management,Domain management,Number of domains,Domains\n"
# Content
"meoward@rocks.com,False,big_lebowski@dude.co,2022-04-01,Invalid date,None,"
'Manager,True,2,"adomain2.gov,cdomain1.gov"\n'
"big_lebowski@dude.co,False,help@get.gov,2022-04-01,Invalid date,None,Viewer,True,1,cdomain1.gov\n"
"tired_sleepy@igorville.gov,False,System,2022-04-01,Invalid date,Viewer,None,False,0,\n"
"icy_superuser@igorville.gov,True,help@get.gov,2022-04-01,2024-02-01,Viewer Requester,Manager,False,0,\n"
"cozy_staffuser@igorville.gov,True,help@get.gov,2022-04-01,2024-02-01,Viewer Requester,None,False,0,\n"
"nonexistentmember_1@igorville.gov,False,help@get.gov,Unretrieved,Invited,None,Manager,False,0,\n"
"nonexistentmember_2@igorville.gov,False,help@get.gov,Unretrieved,Invited,None,Viewer,False,0,\n"
"nonexistentmember_3@igorville.gov,False,help@get.gov,Unretrieved,Invited,Viewer,None,False,0,\n"
"nonexistentmember_4@igorville.gov,True,help@get.gov,Unretrieved,"
"Invited,Viewer Requester,Manager,False,0,\n"
"nonexistentmember_5@igorville.gov,True,help@get.gov,Unretrieved,Invited,Viewer Requester,None,False,0,\n"
)
# Normalize line endings and remove commas,
# spaces and leading/trailing whitespace
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.assertEqual(csv_content, expected_content)
class HelperFunctions(MockDbForSharedTests):
"""This asserts that 1=1. Its limited usefulness lies in making sure the helper methods stay healthy."""

View file

@ -49,9 +49,9 @@ class TestViews(TestCase):
@less_console_noise_decorator
def test_domain_request_form_not_logged_in(self):
"""Domain request form not accessible without a logged-in user."""
response = self.client.get("/request/")
response = self.client.get(reverse("domain-request:start"))
self.assertEqual(response.status_code, 302)
self.assertIn("/login?next=/request/", response.headers["Location"])
self.assertIn("/login?next=/request/start/", response.headers["Location"])
class TestWithUser(MockEppLib):
@ -476,7 +476,7 @@ class HomeTests(TestWithUser):
@less_console_noise_decorator
def test_domain_request_form_view(self):
response = self.client.get("/request/", follow=True)
response = self.client.get(reverse("domain-request:start"), follow=True)
self.assertContains(
response,
"Youre about to start your .gov domain request.",
@ -503,7 +503,7 @@ class HomeTests(TestWithUser):
title="title",
)
self.client.force_login(restricted_user)
response = self.client.get("/request/", follow=True)
response = self.client.get(reverse("domain-request:start"), follow=True)
self.assertEqual(response.status_code, 403)
restricted_user.delete()
@ -718,7 +718,7 @@ class FinishUserProfileTests(TestWithUser, WebTest):
self.app.set_user(incomplete_regular_user.username)
with override_flag("", active=True):
# This will redirect the user to the setup page
finish_setup_page = self.app.get(reverse("domain-request:")).follow()
finish_setup_page = self.app.get(reverse("domain-request:start")).follow()
self._set_session_cookie()
# Assert that we're on the right page
@ -914,7 +914,7 @@ class UserProfileTests(TestWithUser, WebTest):
@less_console_noise_decorator
def test_new_request_main_nav(self):
"""test that Your profile is in main nav of new request"""
response = self.client.get("/request/", follow=True)
response = self.client.get(reverse("domain-request:start"), follow=True)
self.assertContains(response, "Your profile")
@less_console_noise_decorator
@ -927,7 +927,7 @@ class UserProfileTests(TestWithUser, WebTest):
def test_user_profile_back_button_when_coming_from_domain_request(self):
"""tests user profile,
and when they are redirected from the domain request page"""
response = self.client.get("/user-profile?redirect=domain-request:")
response = self.client.get("/user-profile?redirect=domain-request:start")
self.assertContains(response, "Your profile")
self.assertContains(response, "Go back to your domain request")
self.assertNotContains(response, "Back to manage your domains")

View file

@ -323,6 +323,27 @@ class TestDomainDetail(TestDomainOverview):
self.assertContains(detail_page, "noinformation.gov")
self.assertContains(detail_page, "Domain missing domain information")
def test_domain_detail_with_analyst_managing_domain(self):
"""Test that domain management page returns 200 and does not display
blue error message when an analyst is managing the domain"""
with less_console_noise():
staff_user = create_user()
self.client.force_login(staff_user)
# need to set the analyst_action and analyst_action_location
# in the session to emulate user clicking Manage Domain
# in the admin interface
session = self.client.session
session["analyst_action"] = "edit"
session["analyst_action_location"] = self.domain.id
session.save()
detail_page = self.client.get(reverse("domain", kwargs={"pk": self.domain.id}))
self.assertNotContains(
detail_page, "To manage information for this domain, you must add yourself as a domain manager."
)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
def test_domain_readonly_on_detail_page(self):
@ -370,6 +391,17 @@ class TestDomainManagers(TestDomainOverview):
]
AllowedEmail.objects.bulk_create(allowed_emails)
def setUp(self):
super().setUp()
# Add portfolio in order to test portfolio view
self.portfolio = Portfolio.objects.create(creator=self.user, organization_name="Ice Cream")
# Add the portfolio to the domain_information object
self.domain_information.portfolio = self.portfolio
# Add portfolio perms to the user object
self.portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
user=self.user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
@classmethod
def tearDownClass(cls):
super().tearDownClass()
@ -383,13 +415,22 @@ class TestDomainManagers(TestDomainOverview):
def test_domain_managers(self):
response = self.client.get(reverse("domain-users", kwargs={"pk": self.domain.id}))
self.assertContains(response, "Domain managers")
self.assertContains(response, "Add a domain manager")
# assert that the non-portfolio view contains Role column and doesn't contain Admin
self.assertContains(response, "Role</th>")
self.assertNotContains(response, "Admin")
self.assertContains(response, "This domain has one manager. Adding more can prevent issues.")
@less_console_noise_decorator
def test_domain_managers_add_link(self):
"""Button to get to user add page works."""
management_page = self.app.get(reverse("domain-users", kwargs={"pk": self.domain.id}))
add_page = management_page.click("Add a domain manager")
self.assertContains(add_page, "Add a domain manager")
@override_flag("organization_feature", active=True)
def test_domain_managers_portfolio_view(self):
response = self.client.get(reverse("domain-users", kwargs={"pk": self.domain.id}))
self.assertContains(response, "Domain managers")
self.assertContains(response, "Add a domain manager")
# assert that the portfolio view doesn't contain Role column and does contain Admin
self.assertNotContains(response, "Role</th>")
self.assertContains(response, "Admin")
self.assertContains(response, "This domain has one manager. Adding more can prevent issues.")
@less_console_noise_decorator
def test_domain_user_add(self):
@ -706,21 +747,18 @@ class TestDomainManagers(TestDomainOverview):
"""Posting to the delete view deletes an invitation."""
email_address = "mayor@igorville.gov"
invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address)
mock_client = MockSESClient()
with boto3_mocking.clients.handler_for("sesv2", mock_client):
self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id}))
mock_client.EMAILS_SENT.clear()
with self.assertRaises(DomainInvitation.DoesNotExist):
DomainInvitation.objects.get(id=invitation.id)
self.client.post(reverse("invitation-cancel", kwargs={"pk": invitation.id}))
invitation = DomainInvitation.objects.get(id=invitation.id)
self.assertEqual(invitation.status, DomainInvitation.DomainInvitationStatus.CANCELED)
@less_console_noise_decorator
def test_domain_invitation_cancel_retrieved_invitation(self):
"""Posting to the delete view when invitation retrieved returns an error message"""
"""Posting to the cancel view when invitation retrieved returns an error message"""
email_address = "mayor@igorville.gov"
invitation, _ = DomainInvitation.objects.get_or_create(
domain=self.domain, email=email_address, status=DomainInvitation.DomainInvitationStatus.RETRIEVED
)
response = self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id}), follow=True)
response = self.client.post(reverse("invitation-cancel", kwargs={"pk": invitation.id}), follow=True)
# Assert that an error message is displayed to the user
self.assertContains(response, f"Invitation to {email_address} has already been retrieved.")
# Assert that the Cancel link is not displayed
@ -731,7 +769,7 @@ class TestDomainManagers(TestDomainOverview):
@less_console_noise_decorator
def test_domain_invitation_cancel_no_permissions(self):
"""Posting to the delete view as a different user should fail."""
"""Posting to the cancel view as a different user should fail."""
email_address = "mayor@igorville.gov"
invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address)
@ -740,7 +778,7 @@ class TestDomainManagers(TestDomainOverview):
self.client.force_login(other_user)
mock_client = MagicMock()
with boto3_mocking.clients.handler_for("sesv2", mock_client):
result = self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id}))
result = self.client.post(reverse("invitation-cancel", kwargs={"pk": invitation.id}))
self.assertEqual(result.status_code, 403)

View file

@ -2,8 +2,9 @@ from django.urls import reverse
from api.tests.common import less_console_noise_decorator
from registrar.config import settings
from registrar.models import Portfolio, SeniorOfficial
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
from django_webtest import WebTest # type: ignore
from django.core.handlers.wsgi import WSGIRequest
from registrar.models import (
DomainRequest,
Domain,
@ -959,7 +960,7 @@ class TestPortfolio(WebTest):
)
# Assert buttons and links within the page are correct
self.assertContains(response, "usa-button--more-actions") # test that 3 dot is present
self.assertContains(response, "wrapper-delete-action") # test that 3 dot is present
self.assertContains(response, "sprite.svg#edit") # test that Edit link is present
self.assertContains(response, "sprite.svg#settings") # test that Manage link is present
self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present
@ -1077,9 +1078,8 @@ class TestPortfolio(WebTest):
self.assertContains(
response, 'This member does not manage any domains. To assign this member a domain, click "Manage"'
)
# Assert buttons and links within the page are correct
self.assertContains(response, "usa-button--more-actions") # test that 3 dot is present
self.assertContains(response, "wrapper-delete-action") # test that 3 dot is present
self.assertContains(response, "sprite.svg#edit") # test that Edit link is present
self.assertContains(response, "sprite.svg#settings") # test that Manage link is present
self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present
@ -1392,6 +1392,510 @@ class TestPortfolio(WebTest):
self.assertTrue(DomainRequest.objects.filter(pk=domain_request.pk).exists())
domain_request.delete()
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_members_table_contains_hidden_permissions_js_hook(self):
# In the members_table.html we use data-has-edit-permission as a boolean
# to indicate if a user has permission to edit members in the specific portfolio
# 1. User w/ edit permission
UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
# Create a member under same portfolio
member_email = "a_member@example.com"
member, _ = User.objects.get_or_create(username="a_member", email=member_email)
UserPortfolioPermission.objects.get_or_create(
user=member,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
)
# I log in as the User so I can see the Members Table
self.client.force_login(self.user)
# Specifically go to the Member Table page
response = self.client.get(reverse("members"))
self.assertContains(response, 'data-has-edit-permission="True"')
# 2. User w/o edit permission (additional permission of EDIT_MEMBERS removed)
permission = UserPortfolioPermission.objects.get(user=self.user, portfolio=self.portfolio)
# Remove the EDIT_MEMBERS additional permission
permission.additional_permissions = [
perm for perm in permission.additional_permissions if perm != UserPortfolioPermissionChoices.EDIT_MEMBERS
]
# Save the updated permissions list
permission.save()
# Re-fetch the page to check for updated permissions
response = self.client.get(reverse("members"))
self.assertContains(response, 'data-has-edit-permission="False"')
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_member_page_has_kebab_wrapper_for_member_if_user_has_edit_permission(self):
"""Test that the kebab wrapper displays for a member with edit permissions"""
# I'm a user
UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
# Create a member under same portfolio
member_email = "a_member@example.com"
member, _ = User.objects.get_or_create(username="a_member", email=member_email)
upp, _ = UserPortfolioPermission.objects.get_or_create(
user=member,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
)
# I log in as the User so I can see the Manage Member page
self.client.force_login(self.user)
# Specifically go to the Manage Member page
response = self.client.get(reverse("member", args=[upp.id]), follow=True)
self.assertEqual(response.status_code, 200)
# Check for email AND member type (which here is just member)
self.assertContains(response, f'data-member-name="{member_email}"')
self.assertContains(response, 'data-member-type="member"')
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_member_page_has_kebab_wrapper_for_invited_member_if_user_has_edit_permission(self):
"""Test that the kebab wrapper displays for an invitedmember with edit permissions"""
# I'm a user
UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
# Invite a member under same portfolio
invited_member_email = "invited_member@example.com"
invitation = PortfolioInvitation.objects.create(
email=invited_member_email,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
)
# I log in as the User so I can see the Manage Member page
self.client.force_login(self.user)
response = self.client.get(reverse("invitedmember", args=[invitation.id]), follow=True)
self.assertEqual(response.status_code, 200)
# Assert the invited members email + invitedmember type
self.assertContains(response, f'data-member-name="{invited_member_email}"')
self.assertContains(response, 'data-member-type="invitedmember"')
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_member_page_does_not_have_kebab_wrapper(self):
"""Test that the kebab does not display."""
# I'm a user
UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
# That creates a member with only view access
member_email = "member_with_view_access@example.com"
member, _ = User.objects.get_or_create(username="test_member_with_view_access", email=member_email)
upp, _ = UserPortfolioPermission.objects.get_or_create(
user=member,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
],
)
# I log in as the Member with only view permissions to evaluate the pages behaviour
# when viewed by someone who doesn't have edit perms
self.client.force_login(member)
# Go to the Manage Member page
response = self.client.get(reverse("member", args=[upp.id]), follow=True)
self.assertEqual(response.status_code, 200)
# Assert that the kebab edit options are unavailable
self.assertNotContains(response, 'data-member-type="member"')
self.assertNotContains(response, 'data-member-type="invitedmember"')
self.assertNotContains(response, f'data-member-name="{member_email}"')
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_member_page_has_correct_form_wrapper(self):
"""Test that the manage members page the right form wrapper"""
# I'm a user
UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
# That creates a member
member_email = "a_member@example.com"
member, _ = User.objects.get_or_create(email=member_email)
upp, _ = UserPortfolioPermission.objects.get_or_create(
user=member,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
)
# Login as the User to see the Manage Member page
self.client.force_login(self.user)
# Specifically go to the Manage Member page
response = self.client.get(reverse("member", args=[upp.id]), follow=True)
# Check for a 200 response
self.assertEqual(response.status_code, 200)
# Check for form method + that its "post" and id "member-delete-form"
self.assertContains(response, "<form")
self.assertContains(response, 'method="post"')
self.assertContains(response, 'id="member-delete-form"')
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_toggleable_alert_wrapper_exists_on_members_page(self):
# I'm a user
UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
# That creates a member
member_email = "a_member@example.com"
member, _ = User.objects.get_or_create(email=member_email)
UserPortfolioPermission.objects.get_or_create(
user=member,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
)
# Login as the User to see the Members Table page
self.client.force_login(self.user)
# Specifically go to the Members Table page
response = self.client.get(reverse("members"))
# Assert that the toggleable alert ID exists
self.assertContains(response, '<div id="toggleable-alert"')
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_portfolio_member_delete_view_members_table_active_requests(self):
"""Error state w/ deleting a member with active request on Members Table"""
# I'm a user
UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
# That creates a member
member_email = "a_member@example.com"
member, _ = User.objects.get_or_create(email=member_email)
upp, _ = UserPortfolioPermission.objects.get_or_create(
user=member,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
)
with patch.object(User, "get_active_requests_count_in_portfolio", return_value=1):
self.client.force_login(self.user)
# We check X_REQUESTED_WITH bc those return JSON responses
response = self.client.post(
reverse("member-delete", kwargs={"pk": upp.pk}), HTTP_X_REQUESTED_WITH="XMLHttpRequest"
)
self.assertEqual(response.status_code, 400) # Bad request due to active requests
support_url = "https://get.gov/contact/"
expected_error_message = (
f"This member has an active domain request and can't be removed from the organization. "
f"<a href='{support_url}' target='_blank'>Contact the .gov team</a> to remove them."
)
self.assertContains(response, expected_error_message, status_code=400)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_portfolio_member_delete_view_members_table_only_admin(self):
"""Error state w/ deleting a member that's the only admin on Members Table"""
# I'm a user with admin permission
admin_perm_user, _ = UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
with patch.object(User, "is_only_admin_of_portfolio", return_value=True):
self.client.force_login(self.user)
# We check X_REQUESTED_WITH bc those return JSON responses
response = self.client.post(
reverse("member-delete", kwargs={"pk": admin_perm_user.pk}), HTTP_X_REQUESTED_WITH="XMLHttpRequest"
)
self.assertEqual(response.status_code, 400)
expected_error_message = (
"There must be at least one admin in your organization. Give another member admin "
"permissions, make sure they log into the registrar, and then remove this member."
)
self.assertContains(response, expected_error_message, status_code=400)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_portfolio_member_table_delete_view_success(self):
"""Success state with deleting on Members Table page bc no active request AND not only admin"""
# I'm a user
UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
# Creating a member that can be deleted (see patch)
member_email = "deleteable_member@example.com"
member, _ = User.objects.get_or_create(email=member_email)
# Set up the member in the portfolio
upp, _ = UserPortfolioPermission.objects.get_or_create(
user=member,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
)
# And set that the member has no active requests AND it's not the only admin
with patch.object(User, "get_active_requests_count_in_portfolio", return_value=0), patch.object(
User, "is_only_admin_of_portfolio", return_value=False
):
# Attempt to delete
self.client.force_login(self.user)
response = self.client.post(
# We check X_REQUESTED_WITH bc those return JSON responses
reverse("member-delete", kwargs={"pk": upp.pk}),
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
)
# Check for a successful deletion
self.assertEqual(response.status_code, 200)
expected_success_message = f"You've removed {member.email} from the organization."
self.assertContains(response, expected_success_message, status_code=200)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_portfolio_member_delete_view_manage_members_page_active_requests(self):
"""Error state when deleting a member with active requests on the Manage Members page"""
# I'm an admin user
UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
# Create a member with active requests
member_email = "member_with_active_request@example.com"
member, _ = User.objects.get_or_create(email=member_email)
upp, _ = UserPortfolioPermission.objects.get_or_create(
user=member,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
)
with patch.object(User, "get_active_requests_count_in_portfolio", return_value=1):
with patch("django.contrib.messages.error") as mock_error:
self.client.force_login(self.user)
response = self.client.post(
reverse("member-delete", kwargs={"pk": upp.pk}),
)
# We don't want to do follow=True in response bc that does automatic redirection
# We want 302 bc indicates redirect
self.assertEqual(response.status_code, 302)
support_url = "https://get.gov/contact/"
expected_error_message = (
f"This member has an active domain request and can't be removed from the organization. "
f"<a href='{support_url}' target='_blank'>Contact the .gov team</a> to remove them."
)
args, kwargs = mock_error.call_args
# Check if first arg is a WSGIRequest, confirms request object passed correctly
# WSGIRequest protocol is basically the HTTPRequest but in Django form (ie POST '/member/1/delete')
self.assertIsInstance(args[0], WSGIRequest)
# Check that the error message matches the expected error message
self.assertEqual(args[1], expected_error_message)
# Location is used for a 3xx HTTP status code to indicate that the URL was redirected
# and then confirm that we're still on the Manage Members page
self.assertEqual(response.headers["Location"], reverse("member", kwargs={"pk": upp.pk}))
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_portfolio_member_delete_view_manage_members_page_only_admin(self):
"""Error state when trying to delete the only admin on the Manage Members page"""
# Create an admin with admin user perms
admin_perm_user, _ = UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
# Set them to be the only admin and attempt to delete
with patch.object(User, "is_only_admin_of_portfolio", return_value=True):
with patch("django.contrib.messages.error") as mock_error:
self.client.force_login(self.user)
response = self.client.post(
reverse("member-delete", kwargs={"pk": admin_perm_user.pk}),
)
self.assertEqual(response.status_code, 302)
expected_error_message = (
"There must be at least one admin in your organization. Give another member admin "
"permissions, make sure they log into the registrar, and then remove this member."
)
args, kwargs = mock_error.call_args
# Check if first arg is a WSGIRequest, confirms request object passed correctly
# WSGIRequest protocol is basically the HTTPRequest but in Django form (ie POST '/member/1/delete')
self.assertIsInstance(args[0], WSGIRequest)
# Check that the error message matches the expected error message
self.assertEqual(args[1], expected_error_message)
# Location is used for a 3xx HTTP status code to indicate that the URL was redirected
# and then confirm that we're still on the Manage Members page
self.assertEqual(response.headers["Location"], reverse("member", kwargs={"pk": admin_perm_user.pk}))
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_portfolio_member_delete_view_manage_members_page_invitedmember(self):
"""Success state w/ deleting invited member on Manage Members page should redirect back to Members Table"""
# I'm a user
UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
# Invite a member under same portfolio
invited_member_email = "invited_member@example.com"
invitation = PortfolioInvitation.objects.create(
email=invited_member_email,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
)
with patch("django.contrib.messages.success") as mock_success:
self.client.force_login(self.user)
response = self.client.post(
reverse("invitedmember-delete", kwargs={"pk": invitation.pk}),
)
self.assertEqual(response.status_code, 302)
expected_success_message = f"You've removed {invitation.email} from the organization."
args, kwargs = mock_success.call_args
# Check if first arg is a WSGIRequest, confirms request object passed correctly
# WSGIRequest protocol is basically the HTTPRequest but in Django form (ie POST '/member/1/delete')
self.assertIsInstance(args[0], WSGIRequest)
# Check that the error message matches the expected error message
self.assertEqual(args[1], expected_success_message)
# Location is used for a 3xx HTTP status code to indicate that the URL was redirected
# and then confirm that we're now on Members Table page
self.assertEqual(response.headers["Location"], reverse("members"))
class TestPortfolioMemberDomainsView(TestWithUser, WebTest):
@classmethod
@ -1645,7 +2149,7 @@ class TestRequestingEntity(WebTest):
def test_requesting_entity_page_new_request(self):
"""Tests that the requesting entity page loads correctly when a new request is started"""
response = self.app.get(reverse("domain-request:"))
response = self.app.get(reverse("domain-request:start"))
# Navigate past the intro page
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
@ -1658,7 +2162,7 @@ class TestRequestingEntity(WebTest):
self.assertContains(response, "Add suborganization information")
# We expect to see the portfolio name in two places:
# the header, and as one of the radio button options.
self.assertContains(response, self.portfolio.organization_name, count=2)
self.assertContains(response, self.portfolio.organization_name, count=3)
# We expect the dropdown list to contain the suborganizations that currently exist on this portfolio
self.assertContains(response, self.suborganization.name, count=1)
@ -1672,7 +2176,7 @@ class TestRequestingEntity(WebTest):
@less_console_noise_decorator
def test_requesting_entity_page_existing_suborg_submission(self):
"""Tests that you can submit a form on this page and set a suborg"""
response = self.app.get(reverse("domain-request:"))
response = self.app.get(reverse("domain-request:start"))
# Navigate past the intro page
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
@ -1705,7 +2209,7 @@ class TestRequestingEntity(WebTest):
@less_console_noise_decorator
def test_requesting_entity_page_new_suborg_submission(self):
"""Tests that you can submit a form on this page and set a new suborg"""
response = self.app.get(reverse("domain-request:"))
response = self.app.get(reverse("domain-request:start"))
# Navigate past the intro page
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
@ -1745,7 +2249,7 @@ class TestRequestingEntity(WebTest):
@less_console_noise_decorator
def test_requesting_entity_page_organization_submission(self):
"""Tests submitting an organization on the requesting org form"""
response = self.app.get(reverse("domain-request:"))
response = self.app.get(reverse("domain-request:start"))
# Navigate past the intro page
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
@ -1794,9 +2298,13 @@ class TestRequestingEntity(WebTest):
form["portfolio_requesting_entity-is_requesting_new_suborganization"] = True
response = form.submit()
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
self.assertContains(response, "Requested suborganization is required.", status_code=200)
self.assertContains(response, "City is required.", status_code=200)
self.assertContains(response, "State, territory, or military post is required.", status_code=200)
self.assertContains(response, "Enter the name of your suborganization.", status_code=200)
self.assertContains(response, "Enter the city where your suborganization is located.", status_code=200)
self.assertContains(
response,
"Select the state, territory, or military post where your suborganization is located.",
status_code=200,
)
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)

View file

@ -49,12 +49,13 @@ class DomainRequestTests(TestWithUser, WebTest):
super().tearDown()
DomainRequest.objects.all().delete()
DomainInformation.objects.all().delete()
User.objects.all().delete()
self.federal_agency.delete()
@less_console_noise_decorator
def test_domain_request_form_intro_acknowledgement(self):
"""Tests that user is presented with intro acknowledgement page"""
intro_page = self.app.get(reverse("domain-request:"))
intro_page = self.app.get(reverse("domain-request:start"))
self.assertContains(intro_page, "Youre about to start your .gov domain request")
@less_console_noise_decorator
@ -105,12 +106,12 @@ class DomainRequestTests(TestWithUser, WebTest):
self.assertEqual(detail_page.status_code, 302)
# You can access the 'Location' header to get the redirect URL
redirect_url = detail_page.url
self.assertEqual(redirect_url, "/request/generic_org_type/")
self.assertEqual(redirect_url, f"/request/{domain_request.id}/generic_org_type/")
@less_console_noise_decorator
def test_domain_request_form_empty_submit(self):
"""Tests empty submit on the first page after the acknowledgement page"""
intro_page = self.app.get(reverse("domain-request:"))
intro_page = self.app.get(reverse("domain-request:start"))
# django-webtest does not handle cookie-based sessions well because it keeps
# resetting the session key on each new request, thus destroying the concept
# of a "session". We are going to do it manually, saving the session ID here
@ -141,7 +142,7 @@ class DomainRequestTests(TestWithUser, WebTest):
domain_request.save()
# now, attempt to create another one
intro_page = self.app.get(reverse("domain-request:"))
intro_page = self.app.get(reverse("domain-request:start"))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
intro_form = intro_page.forms[0]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
@ -154,59 +155,6 @@ class DomainRequestTests(TestWithUser, WebTest):
self.assertContains(type_page, "You cannot submit this request yet")
@less_console_noise_decorator
def test_domain_request_into_acknowledgement_creates_new_request(self):
"""
We had to solve a bug where the wizard was creating 2 requests on first intro acknowledgement ('continue')
The wizard was also creating multiiple requests on 'continue' -> back button -> 'continue' etc.
This tests that the domain requests get created only when they should.
"""
# Get the intro page
self.app.get(reverse("home"))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
intro_page = self.app.get(reverse("domain-request:"))
# Select the form
intro_form = intro_page.forms[0]
# Submit the form, this creates 1 Request
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
response = intro_form.submit(name="submit_button", value="intro_acknowledge")
# Landing on the next page used to create another 1 request
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
response.follow()
# Check if a new DomainRequest object has been created
domain_request_count = DomainRequest.objects.count()
self.assertEqual(domain_request_count, 1)
# Let's go back to intro and submit again, this should not create a new request
# This is the equivalent of a back button nav from step 1 to intro -> continue
intro_form = intro_page.forms[0]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
type_form = intro_form.submit(name="submit_button", value="intro_acknowledge")
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
type_form.follow()
domain_request_count = DomainRequest.objects.count()
self.assertEqual(domain_request_count, 1)
# Go home, which will reset the session flag for new request
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
self.app.get(reverse("home"))
# This time, clicking continue will create a new request
intro_form = intro_page.forms[0]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
intro_result = intro_form.submit(name="submit_button", value="intro_acknowledge")
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
intro_result.follow()
domain_request_count = DomainRequest.objects.count()
self.assertEqual(domain_request_count, 2)
@boto3_mocking.patching
@less_console_noise_decorator
def test_domain_request_form_submission(self):
@ -225,7 +173,7 @@ class DomainRequestTests(TestWithUser, WebTest):
SKIPPED_PAGES = 3
num_pages = len(self.TITLES) - SKIPPED_PAGES
intro_page = self.app.get(reverse("domain-request:"))
intro_page = self.app.get(reverse("domain-request:start"))
# django-webtest does not handle cookie-based sessions well because it keeps
# resetting the session key on each new request, thus destroying the concept
# of a "session". We are going to do it manually, saving the session ID here
@ -253,7 +201,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# the post request should return a redirect to the next form in
# the domain request page
self.assertEqual(type_result.status_code, 302)
self.assertEqual(type_result["Location"], "/request/organization_federal/")
self.assertEqual(type_result["Location"], f"/request/{domain_request.id}/organization_federal/")
num_pages_tested += 1
# ---- FEDERAL BRANCH PAGE ----
@ -273,7 +221,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# the post request should return a redirect to the next form in
# the domain request page
self.assertEqual(federal_result.status_code, 302)
self.assertEqual(federal_result["Location"], "/request/organization_contact/")
self.assertEqual(federal_result["Location"], f"/request/{domain_request.id}/organization_contact/")
num_pages_tested += 1
# ---- ORG CONTACT PAGE ----
@ -305,7 +253,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# the post request should return a redirect to the next form in
# the domain request page
self.assertEqual(org_contact_result.status_code, 302)
self.assertEqual(org_contact_result["Location"], "/request/senior_official/")
self.assertEqual(org_contact_result["Location"], f"/request/{domain_request.id}/senior_official/")
num_pages_tested += 1
# ---- SENIOR OFFICIAL PAGE ----
@ -330,7 +278,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# the post request should return a redirect to the next form in
# the domain request page
self.assertEqual(so_result.status_code, 302)
self.assertEqual(so_result["Location"], "/request/current_sites/")
self.assertEqual(so_result["Location"], f"/request/{domain_request.id}/current_sites/")
num_pages_tested += 1
# ---- CURRENT SITES PAGE ----
@ -352,7 +300,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# the post request should return a redirect to the next form in
# the domain request page
self.assertEqual(current_sites_result.status_code, 302)
self.assertEqual(current_sites_result["Location"], "/request/dotgov_domain/")
self.assertEqual(current_sites_result["Location"], f"/request/{domain_request.id}/dotgov_domain/")
num_pages_tested += 1
# ---- DOTGOV DOMAIN PAGE ----
@ -372,7 +320,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# the post request should return a redirect to the next form in
# the domain request page
self.assertEqual(dotgov_result.status_code, 302)
self.assertEqual(dotgov_result["Location"], "/request/purpose/")
self.assertEqual(dotgov_result["Location"], f"/request/{domain_request.id}/purpose/")
num_pages_tested += 1
# ---- PURPOSE PAGE ----
@ -391,7 +339,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# the post request should return a redirect to the next form in
# the domain request page
self.assertEqual(purpose_result.status_code, 302)
self.assertEqual(purpose_result["Location"], "/request/other_contacts/")
self.assertEqual(purpose_result["Location"], f"/request/{domain_request.id}/other_contacts/")
num_pages_tested += 1
# ---- OTHER CONTACTS PAGE ----
@ -429,7 +377,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# the post request should return a redirect to the next form in
# the domain request page
self.assertEqual(other_contacts_result.status_code, 302)
self.assertEqual(other_contacts_result["Location"], "/request/additional_details/")
self.assertEqual(other_contacts_result["Location"], f"/request/{domain_request.id}/additional_details/")
num_pages_tested += 1
# ---- ADDITIONAL DETAILS PAGE ----
@ -459,7 +407,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# the post request should return a redirect to the next form in
# the domain request page
self.assertEqual(additional_details_result.status_code, 302)
self.assertEqual(additional_details_result["Location"], "/request/requirements/")
self.assertEqual(additional_details_result["Location"], f"/request/{domain_request.id}/requirements/")
num_pages_tested += 1
# ---- REQUIREMENTS PAGE ----
@ -479,7 +427,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# the post request should return a redirect to the next form in
# the domain request page
self.assertEqual(requirements_result.status_code, 302)
self.assertEqual(requirements_result["Location"], "/request/review/")
self.assertEqual(requirements_result["Location"], f"/request/{domain_request.id}/review/")
num_pages_tested += 1
# ---- REVIEW AND FINSIHED PAGES ----
@ -549,7 +497,7 @@ class DomainRequestTests(TestWithUser, WebTest):
num_pages_tested = 0
# skipping elections, type_of_work, tribal_government
intro_page = self.app.get(reverse("domain-request:"))
intro_page = self.app.get(reverse("domain-request:start"))
# django-webtest does not handle cookie-based sessions well because it keeps
# resetting the session key on each new request, thus destroying the concept
# of a "session". We are going to do it manually, saving the session ID here
@ -577,7 +525,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# the post request should return a redirect to the next form in
# the domain request page
self.assertEqual(type_result.status_code, 302)
self.assertEqual(type_result["Location"], "/request/organization_federal/")
self.assertEqual(type_result["Location"], f"/request/{domain_request.id}/organization_federal/")
num_pages_tested += 1
# ---- FEDERAL BRANCH PAGE ----
@ -597,7 +545,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# the post request should return a redirect to the next form in
# the domain request page
self.assertEqual(federal_result.status_code, 302)
self.assertEqual(federal_result["Location"], "/request/organization_contact/")
self.assertEqual(federal_result["Location"], f"/request/{domain_request.id}/organization_contact/")
num_pages_tested += 1
# ---- ORG CONTACT PAGE ----
@ -629,7 +577,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# the post request should return a redirect to the next form in
# the domain request page
self.assertEqual(org_contact_result.status_code, 302)
self.assertEqual(org_contact_result["Location"], "/request/senior_official/")
self.assertEqual(org_contact_result["Location"], f"/request/{domain_request.id}/senior_official/")
num_pages_tested += 1
# ---- SENIOR OFFICIAL PAGE ----
@ -654,7 +602,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# the post request should return a redirect to the next form in
# the domain request page
self.assertEqual(so_result.status_code, 302)
self.assertEqual(so_result["Location"], "/request/current_sites/")
self.assertEqual(so_result["Location"], f"/request/{domain_request.id}/current_sites/")
num_pages_tested += 1
# ---- CURRENT SITES PAGE ----
@ -676,7 +624,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# the post request should return a redirect to the next form in
# the domain request page
self.assertEqual(current_sites_result.status_code, 302)
self.assertEqual(current_sites_result["Location"], "/request/dotgov_domain/")
self.assertEqual(current_sites_result["Location"], f"/request/{domain_request.id}/dotgov_domain/")
num_pages_tested += 1
# ---- DOTGOV DOMAIN PAGE ----
@ -696,7 +644,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# the post request should return a redirect to the next form in
# the domain request page
self.assertEqual(dotgov_result.status_code, 302)
self.assertEqual(dotgov_result["Location"], "/request/purpose/")
self.assertEqual(dotgov_result["Location"], f"/request/{domain_request.id}/purpose/")
num_pages_tested += 1
# ---- PURPOSE PAGE ----
@ -715,7 +663,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# the post request should return a redirect to the next form in
# the domain request page
self.assertEqual(purpose_result.status_code, 302)
self.assertEqual(purpose_result["Location"], "/request/other_contacts/")
self.assertEqual(purpose_result["Location"], f"/request/{domain_request.id}/other_contacts/")
num_pages_tested += 1
# ---- OTHER CONTACTS PAGE ----
@ -753,7 +701,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# the post request should return a redirect to the next form in
# the domain request page
self.assertEqual(other_contacts_result.status_code, 302)
self.assertEqual(other_contacts_result["Location"], "/request/additional_details/")
self.assertEqual(other_contacts_result["Location"], f"/request/{domain_request.id}/additional_details/")
num_pages_tested += 1
# ---- ADDITIONAL DETAILS PAGE ----
@ -783,7 +731,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# the post request should return a redirect to the next form in
# the domain request page
self.assertEqual(additional_details_result.status_code, 302)
self.assertEqual(additional_details_result["Location"], "/request/requirements/")
self.assertEqual(additional_details_result["Location"], f"/request/{domain_request.id}/requirements/")
num_pages_tested += 1
# ---- REQUIREMENTS PAGE ----
@ -811,7 +759,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# the post request should return a redirect to the next form in
# the domain request page
self.assertEqual(requirements_result.status_code, 302)
self.assertEqual(requirements_result["Location"], "/request/review/")
self.assertEqual(requirements_result["Location"], f"/request/{domain_request.id}/review/")
num_pages_tested += 1
# ---- REVIEW AND FINSIHED PAGES ----
@ -873,7 +821,7 @@ class DomainRequestTests(TestWithUser, WebTest):
@less_console_noise_decorator
def test_domain_request_form_conditional_federal(self):
"""Federal branch question is shown for federal organizations."""
intro_page = self.app.get(reverse("domain-request:"))
intro_page = self.app.get(reverse("domain-request:start"))
# django-webtest does not handle cookie-based sessions well because it keeps
# resetting the session key on each new request, thus destroying the concept
# of a "session". We are going to do it manually, saving the session ID here
@ -904,7 +852,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# the post request should return a redirect to the federal branch
# question
self.assertEqual(type_result.status_code, 302)
self.assertEqual(type_result["Location"], "/request/organization_federal/")
self.assertIn("organization_federal", type_result["Location"])
# and the step label should appear in the sidebar of the resulting page
# but the step label for the elections page should not appear
@ -921,7 +869,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# the post request should return a redirect to the contact
# question
self.assertEqual(federal_result.status_code, 302)
self.assertEqual(federal_result["Location"], "/request/organization_contact/")
self.assertIn("organization_federal", type_result["Location"])
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
contact_page = federal_result.follow()
self.assertContains(contact_page, "Federal agency")
@ -929,7 +877,7 @@ class DomainRequestTests(TestWithUser, WebTest):
@less_console_noise_decorator
def test_domain_request_form_conditional_elections(self):
"""Election question is shown for other organizations."""
intro_page = self.app.get(reverse("domain-request:"))
intro_page = self.app.get(reverse("domain-request:start"))
# django-webtest does not handle cookie-based sessions well because it keeps
# resetting the session key on each new request, thus destroying the concept
# of a "session". We are going to do it manually, saving the session ID here
@ -959,7 +907,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# the post request should return a redirect to the elections question
self.assertEqual(type_result.status_code, 302)
self.assertEqual(type_result["Location"], "/request/organization_election/")
self.assertIn("organization_election", type_result["Location"])
# and the step label should appear in the sidebar of the resulting page
# but the step label for the elections page should not appear
@ -976,7 +924,7 @@ class DomainRequestTests(TestWithUser, WebTest):
# the post request should return a redirect to the contact
# question
self.assertEqual(election_result.status_code, 302)
self.assertEqual(election_result["Location"], "/request/organization_contact/")
self.assertIn("organization_contact", election_result["Location"])
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
contact_page = election_result.follow()
self.assertNotContains(contact_page, "Federal agency")
@ -984,7 +932,8 @@ class DomainRequestTests(TestWithUser, WebTest):
@less_console_noise_decorator
def test_domain_request_form_section_skipping(self):
"""Can skip forward and back in sections"""
intro_page = self.app.get(reverse("domain-request:"))
DomainRequest.objects.all().delete()
intro_page = self.app.get(reverse("domain-request:start"))
# django-webtest does not handle cookie-based sessions well because it keeps
# resetting the session key on each new request, thus destroying the concept
# of a "session". We are going to do it manually, saving the session ID here
@ -1019,17 +968,20 @@ class DomainRequestTests(TestWithUser, WebTest):
# Now click back to the organization type
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
new_page = federal_page.click(str(self.TITLES["generic_org_type"]), index=0)
# Should be a link to the organization_federal page since it is now unlocked
all_domain_requests = DomainRequest.objects.all()
self.assertEqual(all_domain_requests.count(), 1)
new_request_id = all_domain_requests.first().id
self.assertGreater(
len(new_page.html.find_all("a", href="/request/organization_federal/")),
len(new_page.html.find_all("a", href=f"/request/{new_request_id}/organization_federal/")),
0,
)
@less_console_noise_decorator
def test_domain_request_form_nonfederal(self):
"""Non-federal organizations don't have to provide their federal agency."""
intro_page = self.app.get(reverse("domain-request:"))
intro_page = self.app.get(reverse("domain-request:start"))
# django-webtest does not handle cookie-based sessions well because it keeps
# resetting the session key on each new request, thus destroying the concept
# of a "session". We are going to do it manually, saving the session ID here
@ -1069,12 +1021,12 @@ class DomainRequestTests(TestWithUser, WebTest):
# the post request should return a redirect to the
# about your organization page if it was successful.
self.assertEqual(contact_result.status_code, 302)
self.assertEqual(contact_result["Location"], "/request/about_your_organization/")
self.assertIn("about_your_organization", contact_result["Location"])
@less_console_noise_decorator
def test_domain_request_about_your_organization_special(self):
"""Special districts have to answer an additional question."""
intro_page = self.app.get(reverse("domain-request:"))
intro_page = self.app.get(reverse("domain-request:start"))
# django-webtest does not handle cookie-based sessions well because it keeps
# resetting the session key on each new request, thus destroying the concept
# of a "session". We are going to do it manually, saving the session ID here
@ -1104,7 +1056,7 @@ class DomainRequestTests(TestWithUser, WebTest):
def test_federal_agency_dropdown_excludes_expected_values(self):
"""The Federal Agency dropdown on a domain request form should not
include options for gov Administration and Non-Federal Agency"""
intro_page = self.app.get(reverse("domain-request:"))
intro_page = self.app.get(reverse("domain-request:start"))
# django-webtest does not handle cookie-based sessions well because it keeps
# resetting the session key on each new request, thus destroying the concept
# of a "session". We are going to do it manually, saving the session ID here
@ -1152,7 +1104,7 @@ class DomainRequestTests(TestWithUser, WebTest):
def test_yes_no_contact_form_inits_blank_for_new_domain_request(self):
"""On the Other Contacts page, the yes/no form gets initialized with nothing selected for
new domain requests"""
other_contacts_page = self.app.get(reverse("domain-request:other_contacts"))
other_contacts_page = self.app.get(reverse("domain-request:other_contacts", kwargs={"id": 0}))
other_contacts_form = other_contacts_page.forms[0]
self.assertEquals(other_contacts_form["other_contacts-has_other_contacts"].value, None)
@ -1160,7 +1112,7 @@ class DomainRequestTests(TestWithUser, WebTest):
def test_yes_no_additional_form_inits_blank_for_new_domain_request(self):
"""On the Additional Details page, the yes/no form gets initialized with nothing selected for
new domain requests"""
additional_details_page = self.app.get(reverse("domain-request:additional_details"))
additional_details_page = self.app.get(reverse("domain-request:additional_details", kwargs={"id": 0}))
additional_form = additional_details_page.forms[0]
# Check the cisa representative yes/no field
@ -1184,7 +1136,7 @@ class DomainRequestTests(TestWithUser, WebTest):
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
other_contacts_page = self.app.get(reverse("domain-request:other_contacts"))
other_contacts_page = self.app.get(reverse("domain-request:other_contacts", kwargs={"id": domain_request.pk}))
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
other_contacts_form = other_contacts_page.forms[0]
@ -1209,7 +1161,9 @@ class DomainRequestTests(TestWithUser, WebTest):
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
additional_details_page = self.app.get(reverse("domain-request:additional_details"))
additional_details_page = self.app.get(
reverse("domain-request:additional_details", kwargs={"id": domain_request.pk})
)
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
additional_details_form = additional_details_page.forms[0]
@ -1239,7 +1193,7 @@ class DomainRequestTests(TestWithUser, WebTest):
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
other_contacts_page = self.app.get(reverse("domain-request:other_contacts"))
other_contacts_page = self.app.get(reverse("domain-request:other_contacts", kwargs={"id": domain_request.pk}))
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
other_contacts_form = other_contacts_page.forms[0]
@ -1268,7 +1222,9 @@ class DomainRequestTests(TestWithUser, WebTest):
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
additional_details_page = self.app.get(reverse("domain-request:additional_details"))
additional_details_page = self.app.get(
reverse("domain-request:additional_details", kwargs={"id": domain_request.pk})
)
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
additional_details_form = additional_details_page.forms[0]
@ -1306,7 +1262,9 @@ class DomainRequestTests(TestWithUser, WebTest):
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
additional_details_page = self.app.get(reverse("domain-request:additional_details"))
additional_details_page = self.app.get(
reverse("domain-request:additional_details", kwargs={"id": domain_request.pk})
)
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
additional_details_form = additional_details_page.forms[0]
@ -1368,7 +1326,9 @@ class DomainRequestTests(TestWithUser, WebTest):
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
additional_details_page = self.app.get(reverse("domain-request:additional_details"))
additional_details_page = self.app.get(
reverse("domain-request:additional_details", kwargs={"id": domain_request.pk})
)
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
additional_details_form = additional_details_page.forms[0]
@ -1413,7 +1373,9 @@ class DomainRequestTests(TestWithUser, WebTest):
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
additional_details_page = self.app.get(reverse("domain-request:additional_details"))
additional_details_page = self.app.get(
reverse("domain-request:additional_details", kwargs={"id": domain_request.pk})
)
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
additional_details_form = additional_details_page.forms[0]
@ -1444,7 +1406,9 @@ class DomainRequestTests(TestWithUser, WebTest):
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
additional_details_page = self.app.get(reverse("domain-request:additional_details"))
additional_details_page = self.app.get(
reverse("domain-request:additional_details", kwargs={"id": domain_request.pk})
)
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
additional_details_form = additional_details_page.forms[0]
@ -1481,7 +1445,9 @@ class DomainRequestTests(TestWithUser, WebTest):
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
additional_details_page = self.app.get(reverse("domain-request:additional_details"))
additional_details_page = self.app.get(
reverse("domain-request:additional_details", kwargs={"id": domain_request.id})
)
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
additional_details_form = additional_details_page.forms[0]
@ -1512,7 +1478,7 @@ class DomainRequestTests(TestWithUser, WebTest):
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
other_contacts_page = self.app.get(reverse("domain-request:other_contacts"))
other_contacts_page = self.app.get(reverse("domain-request:other_contacts", kwargs={"id": domain_request.pk}))
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
other_contacts_form = other_contacts_page.forms[0]
@ -1560,7 +1526,7 @@ class DomainRequestTests(TestWithUser, WebTest):
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
other_contacts_page = self.app.get(reverse("domain-request:other_contacts"))
other_contacts_page = self.app.get(reverse("domain-request:other_contacts", kwargs={"id": domain_request.pk}))
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
other_contacts_form = other_contacts_page.forms[0]
@ -1644,7 +1610,7 @@ class DomainRequestTests(TestWithUser, WebTest):
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
other_contacts_page = self.app.get(reverse("domain-request:other_contacts"))
other_contacts_page = self.app.get(reverse("domain-request:other_contacts", kwargs={"id": domain_request.pk}))
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
other_contacts_form = other_contacts_page.forms[0]
@ -1685,7 +1651,7 @@ class DomainRequestTests(TestWithUser, WebTest):
@less_console_noise_decorator
def test_if_yes_no_form_is_no_then_no_other_contacts_required(self):
"""Applicants with no other contacts have to give a reason."""
other_contacts_page = self.app.get(reverse("domain-request:other_contacts"))
other_contacts_page = self.app.get(reverse("domain-request:other_contacts", kwargs={"id": 0}))
other_contacts_form = other_contacts_page.forms[0]
other_contacts_form["other_contacts-has_other_contacts"] = "False"
response = other_contacts_page.forms[0].submit()
@ -1701,7 +1667,7 @@ class DomainRequestTests(TestWithUser, WebTest):
@less_console_noise_decorator
def test_if_yes_no_form_is_yes_then_other_contacts_required(self):
"""Applicants with other contacts do not have to give a reason."""
other_contacts_page = self.app.get(reverse("domain-request:other_contacts"))
other_contacts_page = self.app.get(reverse("domain-request:other_contacts", kwargs={"id": 0}))
other_contacts_form = other_contacts_page.forms[0]
other_contacts_form["other_contacts-has_other_contacts"] = "True"
response = other_contacts_page.forms[0].submit()
@ -1777,7 +1743,7 @@ class DomainRequestTests(TestWithUser, WebTest):
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
other_contacts_page = self.app.get(reverse("domain-request:other_contacts"))
other_contacts_page = self.app.get(reverse("domain-request:other_contacts", kwargs={"id": domain_request.id}))
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
other_contacts_form = other_contacts_page.forms[0]
@ -1850,7 +1816,7 @@ class DomainRequestTests(TestWithUser, WebTest):
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
other_contacts_page = self.app.get(reverse("domain-request:other_contacts"))
other_contacts_page = self.app.get(reverse("domain-request:other_contacts", kwargs={"id": domain_request.id}))
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
other_contacts_form = other_contacts_page.forms[0]
@ -1927,7 +1893,7 @@ class DomainRequestTests(TestWithUser, WebTest):
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
other_contacts_page = self.app.get(reverse("domain-request:other_contacts"))
other_contacts_page = self.app.get(reverse("domain-request:other_contacts", kwargs={"id": domain_request.id}))
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
other_contacts_form = other_contacts_page.forms[0]
@ -2007,7 +1973,7 @@ class DomainRequestTests(TestWithUser, WebTest):
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
other_contacts_page = self.app.get(reverse("domain-request:other_contacts"))
other_contacts_page = self.app.get(reverse("domain-request:other_contacts", kwargs={"id": domain_request.pk}))
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
other_contacts_form = other_contacts_page.forms[0]
@ -2083,7 +2049,7 @@ class DomainRequestTests(TestWithUser, WebTest):
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
other_contacts_page = self.app.get(reverse("domain-request:other_contacts"))
other_contacts_page = self.app.get(reverse("domain-request:other_contacts", kwargs={"id": domain_request.pk}))
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
other_contacts_form = other_contacts_page.forms[0]
@ -2153,7 +2119,7 @@ class DomainRequestTests(TestWithUser, WebTest):
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
so_page = self.app.get(reverse("domain-request:senior_official"))
so_page = self.app.get(reverse("domain-request:senior_official", kwargs={"id": domain_request.pk}))
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
so_form = so_page.forms[0]
@ -2222,7 +2188,7 @@ class DomainRequestTests(TestWithUser, WebTest):
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
so_page = self.app.get(reverse("domain-request:senior_official"))
so_page = self.app.get(reverse("domain-request:senior_official", kwargs={"id": domain_request.pk}))
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
so_form = so_page.forms[0]
@ -2303,7 +2269,7 @@ class DomainRequestTests(TestWithUser, WebTest):
@less_console_noise_decorator
def test_domain_request_about_your_organiztion_interstate(self):
"""Special districts have to answer an additional question."""
intro_page = self.app.get(reverse("domain-request:"))
intro_page = self.app.get(reverse("domain-request:start"))
# django-webtest does not handle cookie-based sessions well because it keeps
# resetting the session key on each new request, thus destroying the concept
# of a "session". We are going to do it manually, saving the session ID here
@ -2332,7 +2298,7 @@ class DomainRequestTests(TestWithUser, WebTest):
@less_console_noise_decorator
def test_domain_request_tribal_government(self):
"""Tribal organizations have to answer an additional question."""
intro_page = self.app.get(reverse("domain-request:"))
intro_page = self.app.get(reverse("domain-request:start"))
# django-webtest does not handle cookie-based sessions well because it keeps
# resetting the session key on each new request, thus destroying the concept
# of a "session". We are going to do it manually, saving the session ID here
@ -2363,7 +2329,7 @@ class DomainRequestTests(TestWithUser, WebTest):
@less_console_noise_decorator
def test_domain_request_so_dynamic_text(self):
intro_page = self.app.get(reverse("domain-request:"))
intro_page = self.app.get(reverse("domain-request:start"))
# django-webtest does not handle cookie-based sessions well because it keeps
# resetting the session key on each new request, thus destroying the concept
# of a "session". We are going to do it manually, saving the session ID here
@ -2447,7 +2413,7 @@ class DomainRequestTests(TestWithUser, WebTest):
@less_console_noise_decorator
def test_domain_request_dotgov_domain_dynamic_text(self):
intro_page = self.app.get(reverse("domain-request:"))
intro_page = self.app.get(reverse("domain-request:start"))
# django-webtest does not handle cookie-based sessions well because it keeps
# resetting the session key on each new request, thus destroying the concept
# of a "session". We are going to do it manually, saving the session ID here
@ -2555,8 +2521,23 @@ class DomainRequestTests(TestWithUser, WebTest):
@less_console_noise_decorator
def test_domain_request_formsets(self):
"""Users are able to add more than one of some fields."""
current_sites_page = self.app.get(reverse("domain-request:current_sites"))
DomainRequest.objects.all().delete()
# Create a new domain request
intro_page = self.app.get(reverse("domain-request:start"))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
intro_form = intro_page.forms[0]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
intro_form.submit()
all_domain_requests = DomainRequest.objects.all()
self.assertEqual(all_domain_requests.count(), 1)
new_domain_request_id = all_domain_requests.first().id
# Skip to the current sites page
current_sites_page = self.app.get(reverse("domain-request:current_sites", kwargs={"id": new_domain_request_id}))
# fill in the form field
current_sites_form = current_sites_page.forms[0]
self.assertIn("current_sites-0-website", current_sites_form.fields)
@ -2573,8 +2554,11 @@ class DomainRequestTests(TestWithUser, WebTest):
value = current_sites_form["current_sites-0-website"].value
self.assertEqual(value, "https://example.com")
self.assertIn("current_sites-1-website", current_sites_form.fields)
all_domain_requests = DomainRequest.objects.all()
self.assertEqual(all_domain_requests.count(), 1, msg="Expected one domain request but got multiple")
# and it is correctly referenced in the ManyToOne relationship
domain_request = DomainRequest.objects.get() # there's only one
domain_request = all_domain_requests.first() # there's only one
self.assertEqual(
domain_request.current_websites.filter(website="https://example.com").count(),
1,
@ -2712,7 +2696,7 @@ class DomainRequestTests(TestWithUser, WebTest):
Make sure the long name is displaying in the domain request form,
org step
"""
intro_page = self.app.get(reverse("domain-request:"))
intro_page = self.app.get(reverse("domain-request:start"))
# django-webtest does not handle cookie-based sessions well because it keeps
# resetting the session key on each new request, thus destroying the concept
# of a "session". We are going to do it manually, saving the session ID here
@ -2738,7 +2722,7 @@ class DomainRequestTests(TestWithUser, WebTest):
NOTE: This may be a moot point if we implement a more solid pattern in the
future, like not a submit action at all on the review page."""
review_page = self.app.get(reverse("domain-request:review"))
review_page = self.app.get(reverse("domain-request:review", kwargs={"id": 0}))
self.assertContains(review_page, "toggle-submit-domain-request")
self.assertContains(review_page, "Your request form is incomplete")
@ -2751,7 +2735,7 @@ class DomainRequestTests(TestWithUser, WebTest):
user=self.user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
)
# This user should be forbidden from creating new domain requests
intro_page = self.app.get(reverse("domain-request:"), expect_errors=True)
intro_page = self.app.get(reverse("domain-request:start"), expect_errors=True)
self.assertEqual(intro_page.status_code, 403)
# This user should also be forbidden from editing existing ones
@ -2773,7 +2757,7 @@ class DomainRequestTests(TestWithUser, WebTest):
)
# This user should be allowed to create new domain requests
intro_page = self.app.get(reverse("domain-request:"))
intro_page = self.app.get(reverse("domain-request:start"))
self.assertEqual(intro_page.status_code, 200)
# This user should also be allowed to edit existing ones
@ -2975,6 +2959,69 @@ class TestDomainRequestWizard(TestWithUser, WebTest):
DomainRequest.objects.all().delete()
DomainInformation.objects.all().delete()
@less_console_noise_decorator
def test_breadcrumb_navigation(self):
"""
Tests the breadcrumb navigation behavior in domain request wizard.
Ensures that:
- Breadcrumb shows correct text based on portfolio flag
- Links point to correct destinations
- Back button appears on appropriate steps
- Back button is not present on first step
"""
# Create initial domain request
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
user=self.user,
)
# Test without portfolio flag
start_page = self.app.get(f"/domain-request/{domain_request.id}/edit/").follow()
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# Check initial breadcrumb state.
# Ensure that the request name is shown if it exists, otherwise just show new domain request.
self.assertContains(start_page, '<ol class="usa-breadcrumb__list">')
self.assertContains(start_page, "city.gov")
self.assertContains(start_page, 'href="/"')
self.assertContains(start_page, "Manage your domains")
self.assertNotContains(start_page, "Previous step")
# Move to next step
form = start_page.forms[0]
next_page = form.submit().follow()
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# Verify that the back button appears
self.assertContains(next_page, "Previous step")
self.assertContains(next_page, "#arrow_back")
# Test with portfolio flag
with override_flag("organization_feature", active=True), override_flag("organization_requests", active=True):
portfolio = Portfolio.objects.create(
creator=self.user,
organization_name="test portfolio",
)
permission = UserPortfolioPermission.objects.create(
user=self.user,
portfolio=portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
)
# Check portfolio-specific breadcrumb
portfolio_page = self.app.get(f"/domain-request/{domain_request.id}/edit/").follow()
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
self.assertContains(portfolio_page, "Domain requests")
# Clean up portfolio
permission.delete()
portfolio.delete()
# Clean up
domain_request.delete()
@less_console_noise_decorator
def test_unlocked_steps_empty_domain_request(self):
"""Test when all fields in the domain request are empty."""
@ -3016,7 +3063,7 @@ class TestDomainRequestWizard(TestWithUser, WebTest):
# 10 unlocked steps, one active step, the review step will have link_usa but not check_circle
self.assertContains(detail_page, "#check_circle", count=9)
# Type of organization
self.assertContains(detail_page, "usa-current", count=1)
self.assertContains(detail_page, "usa-current", count=2)
self.assertContains(detail_page, "link_usa-checked", count=10)
else:
@ -3078,7 +3125,7 @@ class TestDomainRequestWizard(TestWithUser, WebTest):
# which unlocks if domain exists), one active step, the review step is locked
self.assertContains(detail_page, "#check_circle", count=4)
# Type of organization
self.assertContains(detail_page, "usa-current", count=1)
self.assertContains(detail_page, "usa-current", count=2)
self.assertContains(detail_page, "link_usa-checked", count=4)
else:
@ -3152,17 +3199,12 @@ class TestDomainRequestWizard(TestWithUser, WebTest):
self.assertContains(detail_page, "#lock", 1)
# The current option should be selected
self.assertContains(detail_page, "usa-current", count=1)
self.assertContains(detail_page, "usa-current", count=2)
# We default to the requesting entity page
expected_url = reverse("domain-request:portfolio_requesting_entity")
expected_url = reverse("domain-request:portfolio_requesting_entity", kwargs={"id": domain_request.id})
# This returns the entire url, thus "in"
self.assertIn(expected_url, detail_page.request.url)
# We shouldn't show the "domains" and "domain requests" buttons
# on this page.
self.assertNotContains(detail_page, "Domains")
self.assertNotContains(detail_page, "Domain requests")
else:
self.fail(f"Expected a redirect, but got a different response: {response}")

View file

@ -4,6 +4,7 @@ from django.utils.html import format_html
from django.urls import reverse
from django.utils.html import escape
from registrar.models.utility.generic_helper import value_of_attribute
from django.contrib.admin.widgets import AutocompleteSelect
def get_action_needed_reason_default_email(domain_request, action_needed_reason):
@ -94,3 +95,26 @@ def get_field_links_as_list(
else:
links = "".join(links)
return format_html(f'<ul class="add-list-reset">{links}</ul>') if links else msg_for_none
class AutocompleteSelectWithPlaceholder(AutocompleteSelect):
"""Override of the default autoselect element. This is because by default,
the autocomplete element clears data-placeholder"""
def build_attrs(self, base_attrs, extra_attrs=None):
attrs = super().build_attrs(base_attrs, extra_attrs=extra_attrs)
if "data-placeholder" in base_attrs:
attrs["data-placeholder"] = base_attrs["data-placeholder"]
return attrs
def __init__(self, field, admin_site, attrs=None, choices=(), using=None):
"""Set a custom ajax url for the select2 if passed through attrs"""
if attrs:
self.custom_ajax_url = attrs.pop("ajax-url", None)
super().__init__(field, admin_site, attrs, choices, using)
def get_url(self):
"""Override the get_url method to use the custom ajax url"""
if self.custom_ajax_url:
return reverse(self.custom_ajax_url)
return reverse(self.url_name % self.admin_site.name)

View file

@ -10,6 +10,9 @@ from registrar.models import (
DomainInformation,
PublicContact,
UserDomainRole,
PortfolioInvitation,
UserGroup,
UserPortfolioPermission,
)
from django.db.models import (
Case,
@ -20,17 +23,25 @@ from django.db.models import (
ManyToManyField,
Q,
QuerySet,
TextField,
Value,
When,
OuterRef,
Subquery,
Exists,
Func,
)
from django.utils import timezone
from django.db.models.functions import Concat, Coalesce
from django.contrib.postgres.aggregates import StringAgg
from django.db.models.functions import Concat, Coalesce, Cast
from django.contrib.postgres.aggregates import ArrayAgg, StringAgg
from django.contrib.admin.models import LogEntry, ADDITION
from django.contrib.contenttypes.models import ContentType
from registrar.models.utility.generic_helper import convert_queryset_to_dict
from registrar.models.utility.orm_helper import ArrayRemoveNull
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
from registrar.templatetags.custom_filters import get_region
from registrar.utility.constants import BranchChoices
from registrar.utility.enums import DefaultEmail
from registrar.utility.enums import DefaultEmail, DefaultUserValues
logger = logging.getLogger(__name__)
@ -120,14 +131,14 @@ class BaseExport(ABC):
return Q()
@classmethod
def get_filter_conditions(cls, **export_kwargs):
def get_filter_conditions(cls, **kwargs):
"""
Get a Q object of filter conditions to filter when building queryset.
"""
return Q()
@classmethod
def get_computed_fields(cls):
def get_computed_fields(cls, **kwargs):
"""
Get a dict of computed fields. These are fields that do not exist on the model normally
and will be passed to .annotate() when building a queryset.
@ -156,7 +167,7 @@ class BaseExport(ABC):
return queryset
@classmethod
def write_csv_before(cls, csv_writer, **export_kwargs):
def write_csv_before(cls, csv_writer, **kwargs):
"""
Write to csv file before the write_csv method.
Override in subclasses where needed.
@ -173,7 +184,7 @@ class BaseExport(ABC):
Parameters:
initial_queryset (QuerySet): Initial queryset.
computed_fields (dict, optional): Fields to compute {field_name: expression}.
computed_fields (dict, optional): Fields to compute {field_name: expression}.
related_table_fields (list, optional): Extra fields to retrieve; defaults to annotation keys if None.
include_many_to_many (bool, optional): Determines if we should include many to many fields or not
**kwargs: Additional keyword arguments for specific parameters (e.g., public_contacts, domain_invitations,
@ -187,8 +198,8 @@ class BaseExport(ABC):
# We can infer that if we're passing in annotations,
# we want to grab the result of said annotation.
if computed_fields:
related_table_fields.extend(computed_fields.keys())
if computed_fields :
related_table_fields.extend(computed_fields .keys())
# Get prexisting fields on the model
model_fields = set()
@ -203,7 +214,7 @@ class BaseExport(ABC):
return cls.update_queryset(queryset, **kwargs)
@classmethod
def export_data_to_csv(cls, csv_file, **export_kwargs):
def export_data_to_csv(cls, csv_file, **kwargs):
"""
All domain metadata:
Exports domains of all statuses plus domain managers.
@ -211,14 +222,30 @@ class BaseExport(ABC):
writer = csv.writer(csv_file)
columns = cls.get_columns()
models_dict = cls.get_model_annotation_dict(**kwargs)
# Write to csv file before the write_csv
cls.write_csv_before(writer, **kwargs)
# Write the csv file
rows = cls.write_csv(writer, columns, models_dict)
# Return rows that for easier parsing and testing
return rows
@classmethod
def get_annotated_queryset(cls, **kwargs):
"""Returns an annotated queryset based off of all query conditions."""
sort_fields = cls.get_sort_fields()
kwargs = cls.get_additional_args()
# Get additional args and merge with incoming kwargs
additional_args = cls.get_additional_args()
kwargs.update(additional_args)
select_related = cls.get_select_related()
prefetch_related = cls.get_prefetch_related()
exclusions = cls.get_exclusions()
annotations_for_sort = cls.get_annotations_for_sort()
filter_conditions = cls.get_filter_conditions(**export_kwargs)
computed_fields = cls.get_computed_fields()
filter_conditions = cls.get_filter_conditions(**kwargs)
computed_fields = cls.get_computed_fields(**kwargs)
related_table_fields = cls.get_related_table_fields()
model_queryset = (
@ -231,15 +258,24 @@ class BaseExport(ABC):
.order_by(*sort_fields)
.distinct()
)
return cls.annotate_and_retrieve_fields(model_queryset, computed_fields, related_table_fields, **kwargs)
# Convert the queryset to a dictionary (including annotated fields)
annotated_queryset = cls.annotate_and_retrieve_fields(
model_queryset, computed_fields, related_table_fields, **kwargs
)
models_dict = convert_queryset_to_dict(annotated_queryset, is_model=False)
@classmethod
def get_model_annotation_dict(cls, **kwargs):
return convert_queryset_to_dict(cls.get_annotated_queryset(**kwargs), is_model=False)
@classmethod
def export_data_to_csv(cls, csv_file, **kwargs):
"""
All domain metadata:
Exports domains of all statuses plus domain managers.
"""
writer = csv.writer(csv_file)
columns = cls.get_columns()
models_dict = cls.get_model_annotation_dict(**kwargs)
# Write to csv file before the write_csv
cls.write_csv_before(writer, **export_kwargs)
cls.write_csv_before(writer, **kwargs)
# Write the csv file
rows = cls.write_csv(writer, columns, models_dict)
@ -285,6 +321,218 @@ class BaseExport(ABC):
pass
class MemberExport(BaseExport):
"""CSV export for the MembersTable. The members table combines the content
of three tables: PortfolioInvitation, UserPortfolioPermission, and DomainInvitation."""
@classmethod
def model(self):
"""
No model is defined for the member report as it is a combination of multiple fields.
This is a special edge case, but the base report requires this to be defined.
"""
return None
@classmethod
def get_model_annotation_dict(cls, request=None, **kwargs):
"""Combines the permissions and invitation model annotations for
the final returned csv export which combines both of these contexts.
Returns a dictionary of a union between:
- UserPortfolioPermissionModelAnnotation.get_annotated_queryset(portfolio, csv_report=True)
- PortfolioInvitationModelAnnotation.get_annotated_queryset(portfolio, csv_report=True)
"""
portfolio = request.session.get("portfolio")
if not portfolio:
return {}
# Union the two querysets to combine UserPortfolioPermission + invites.
# Unions cannot have a col mismatch, so we must clamp what is returned here.
shared_columns = [
"id",
"first_name",
"last_name",
"email_display",
"last_active",
"roles",
"additional_permissions_display",
"member_display",
"domain_info",
"type",
"joined_date",
"invited_by",
]
# Permissions
permissions = (
UserPortfolioPermission.objects.filter(portfolio=portfolio)
.select_related("user")
.annotate(
first_name=F("user__first_name"),
last_name=F("user__last_name"),
email_display=F("user__email"),
last_active=Coalesce(
Func(F("user__last_login"), Value("YYYY-MM-DD"), function="to_char", output_field=TextField()),
Value("Invalid date"),
output_field=CharField(),
),
additional_permissions_display=F("additional_permissions"),
member_display=Case(
# If email is present and not blank, use email
When(Q(user__email__isnull=False) & ~Q(user__email=""), then=F("user__email")),
# If first name or last name is present, use concatenation of first_name + " " + last_name
When(
Q(user__first_name__isnull=False) | Q(user__last_name__isnull=False),
then=Concat(
Coalesce(F("user__first_name"), Value("")),
Value(" "),
Coalesce(F("user__last_name"), Value("")),
),
),
# If neither, use an empty string
default=Value(""),
output_field=CharField(),
),
domain_info=ArrayAgg(
F("user__permissions__domain__name"),
distinct=True,
# only include domains in portfolio
filter=Q(user__permissions__domain__isnull=False)
& Q(user__permissions__domain__domain_info__portfolio=portfolio),
),
type=Value("member", output_field=CharField()),
joined_date=Func(F("created_at"), Value("YYYY-MM-DD"), function="to_char", output_field=CharField()),
invited_by=cls.get_invited_by_query(object_id_query=cls.get_portfolio_invitation_id_query()),
)
.values(*shared_columns)
)
# Invitations
domain_invitations = DomainInvitation.objects.filter(
email=OuterRef("email"), # Check if email matches the OuterRef("email")
domain__domain_info__portfolio=portfolio, # Check if the domain's portfolio matches the given portfolio
).annotate(domain_info=F("domain__name"))
invitations = (
PortfolioInvitation.objects.exclude(status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED)
.filter(portfolio=portfolio)
.annotate(
first_name=Value(None, output_field=CharField()),
last_name=Value(None, output_field=CharField()),
email_display=F("email"),
last_active=Value("Invited", output_field=CharField()),
additional_permissions_display=F("additional_permissions"),
member_display=F("email"),
# Use ArrayRemove to return an empty list when no domain invitations are found
domain_info=ArrayRemoveNull(
ArrayAgg(
Subquery(domain_invitations.values("domain_info")),
distinct=True,
)
),
type=Value("invitedmember", output_field=CharField()),
joined_date=Value("Unretrieved", output_field=CharField()),
invited_by=cls.get_invited_by_query(object_id_query=Cast(OuterRef("id"), output_field=CharField())),
)
.values(*shared_columns)
)
return convert_queryset_to_dict(permissions.union(invitations), is_model=False)
@classmethod
def get_invited_by_query(cls, object_id_query):
"""Returns the user that created the given portfolio invitation.
Grabs this data from the audit log, given that a portfolio invitation object
is specified via object_id_query."""
return Coalesce(
Subquery(
LogEntry.objects.filter(
content_type=ContentType.objects.get_for_model(PortfolioInvitation),
object_id=object_id_query,
action_flag=ADDITION,
)
.annotate(
display_email=Case(
When(
Exists(
UserGroup.objects.filter(
name__in=["cisa_analysts_group", "full_access_group"],
user=OuterRef("user"),
)
),
then=Value(DefaultUserValues.HELP_EMAIL.value),
),
default=F("user__email"),
output_field=CharField(),
)
)
.order_by("action_time")
.values("display_email")[:1]
),
Value(DefaultUserValues.SYSTEM.value),
output_field=CharField(),
)
@classmethod
def get_portfolio_invitation_id_query(cls):
"""Gets the id of the portfolio invitation that created this UserPortfolioPermission.
This makes the assumption that if an invitation is retrieved, it must have created the given
UserPortfolioPermission object."""
return Cast(
Subquery(
PortfolioInvitation.objects.filter(
status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED,
# Double outer ref because we first go into the LogEntry query,
# then into the parent UserPortfolioPermission.
email=OuterRef(OuterRef("user__email")),
portfolio=OuterRef(OuterRef("portfolio")),
).values("id")[:1]
),
output_field=CharField(),
)
@classmethod
def get_columns(cls):
"""
Returns the list of column string names for CSV export. Override in subclasses as needed.
"""
return [
"Email",
"Organization admin",
"Invited by",
"Joined date",
"Last active",
"Domain requests",
"Member management",
"Domain management",
"Number of domains",
"Domains",
]
@classmethod
@abstractmethod
def parse_row(cls, columns, model):
"""
Given a set of columns and a model dictionary, generate a new row from cleaned column data.
Must be implemented by subclasses
"""
roles = model.get("roles", [])
permissions = model.get("additional_permissions_display")
user_managed_domains = model.get("domain_info", [])
length_user_managed_domains = len(user_managed_domains)
FIELDS = {
"Email": model.get("email_display"),
"Organization admin": bool(UserPortfolioRoleChoices.ORGANIZATION_ADMIN in roles),
"Invited by": model.get("invited_by"),
"Joined date": model.get("joined_date"),
"Last active": model.get("last_active"),
"Domain requests": UserPortfolioPermission.get_domain_request_permission_display(roles, permissions),
"Member management": UserPortfolioPermission.get_member_permission_display(roles, permissions),
"Domain management": bool(length_user_managed_domains > 0),
"Number of domains": length_user_managed_domains,
"Domains": ",".join(user_managed_domains),
}
return [FIELDS.get(column, "") for column in columns]
class DomainExport(BaseExport):
"""
A collection of functions which return csv files regarding Domains. Although class is
@ -697,7 +945,7 @@ class DomainDataType(DomainExport):
"""
Get a list of tables to pass to prefetch_related when building queryset.
"""
return ["permissions"]
return ["domain__permissions"]
@classmethod
def get_related_table_fields(cls):
@ -723,7 +971,7 @@ class DomainDataTypeUser(DomainDataType):
"""
@classmethod
def get_filter_conditions(cls, request=None):
def get_filter_conditions(cls, request=None, **kwargs):
"""
Get a Q object of filter conditions to filter when building queryset.
"""
@ -741,7 +989,7 @@ class DomainRequestsDataType:
"""
@classmethod
def get_filter_conditions(cls, request=None):
def get_filter_conditions(cls, request=None, **kwargs):
if request is None or not hasattr(request, "user") or not request.user.is_authenticated:
return Q(id__in=[])
@ -898,7 +1146,7 @@ class DomainDataFull(DomainExport):
return ["domain"]
@classmethod
def get_filter_conditions(cls):
def get_filter_conditions(cls, **kwargs):
"""
Get a Q object of filter conditions to filter when building queryset.
"""
@ -985,7 +1233,7 @@ class DomainDataFederal(DomainExport):
return ["domain"]
@classmethod
def get_filter_conditions(cls):
def get_filter_conditions(cls, **kwargs):
"""
Get a Q object of filter conditions to filter when building queryset.
"""
@ -1068,10 +1316,14 @@ class DomainGrowth(DomainExport):
return ["domain"]
@classmethod
def get_filter_conditions(cls, start_date=None, end_date=None):
def get_filter_conditions(cls, start_date=None, end_date=None, **kwargs):
"""
Get a Q object of filter conditions to filter when building queryset.
"""
if not start_date or not end_date:
# Return nothing
return Q(id__in=[])
filter_ready = Q(
domain__state__in=[Domain.State.READY],
domain__first_ready__gte=start_date,
@ -1140,10 +1392,14 @@ class DomainManaged(DomainExport):
return ["permissions"]
@classmethod
def get_filter_conditions(cls, start_date=None, end_date=None):
def get_filter_conditions(cls, end_date=None, **kwargs):
"""
Get a Q object of filter conditions to filter when building queryset.
"""
if not end_date:
# Return nothing
return Q(id__in=[])
end_date_formatted = format_end_date(end_date)
return Q(
domain__permissions__isnull=False,
@ -1275,10 +1531,14 @@ class DomainUnmanaged(DomainExport):
return ["permissions"]
@classmethod
def get_filter_conditions(cls, start_date=None, end_date=None):
def get_filter_conditions(cls, end_date=None, **kwargs):
"""
Get a Q object of filter conditions to filter when building queryset.
"""
if not end_date:
# Return nothing
return Q(id__in=[])
end_date_formatted = format_end_date(end_date)
return Q(
domain__permissions__isnull=True,
@ -1649,10 +1909,13 @@ class DomainRequestGrowth(DomainRequestExport):
]
@classmethod
def get_filter_conditions(cls, start_date=None, end_date=None):
def get_filter_conditions(cls, start_date=None, end_date=None, **kwargs):
"""
Get a Q object of filter conditions to filter when building queryset.
"""
if not start_date or not end_date:
# Return nothing
return Q(id__in=[])
start_date_formatted = format_start_date(start_date)
end_date_formatted = format_end_date(end_date)
@ -1745,7 +2008,7 @@ class DomainRequestDataFull(DomainRequestExport):
]
@classmethod
def get_computed_fields(cls, delimiter=", "):
def get_computed_fields(cls, delimiter=", ", **kwargs):
"""
Get a dict of computed fields.
"""

View file

@ -35,12 +35,25 @@ class DefaultEmail(Enum):
Overview of emails:
- PUBLIC_CONTACT_DEFAULT: "dotgov@cisa.dhs.gov"
- LEGACY_DEFAULT: "registrar@dotgov.gov"
- HELP_EMAIL: "help@get.gov"
"""
PUBLIC_CONTACT_DEFAULT = "dotgov@cisa.dhs.gov"
LEGACY_DEFAULT = "registrar@dotgov.gov"
class DefaultUserValues(StrEnum):
"""Stores default values for a default user.
Overview of defaults:
- SYSTEM: "System" <= Default username
- UNRETRIEVED: "Unretrieved" <= Default email state
"""
HELP_EMAIL = "help@get.gov"
SYSTEM = "System"
UNRETRIEVED = "Unretrieved"
class Step(StrEnum):
"""
Names for each page of the domain request wizard.

View file

@ -11,7 +11,7 @@ from .domain import (
DomainSecurityEmailView,
DomainUsersView,
DomainAddUserView,
DomainInvitationDeleteView,
DomainInvitationCancelView,
DomainDeleteUserView,
)
from .user_profile import UserProfileView, FinishProfileSetupView

View file

@ -2,7 +2,7 @@
Authorization is handled by the `DomainPermissionView`. To ensure that only
authorized users can see information on a domain, every view here should
inherit from `DomainPermissionView` (or DomainInvitationPermissionDeleteView).
inherit from `DomainPermissionView` (or DomainInvitationPermissionCancelView).
"""
from datetime import date
@ -28,6 +28,7 @@ from registrar.models import (
UserPortfolioPermission,
PublicContact,
)
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
from registrar.utility.enums import DefaultEmail
from registrar.utility.errors import (
GenericError,
@ -62,7 +63,7 @@ from epplibwrapper import (
)
from ..utility.email import send_templated_email, EmailSendingError
from .utility import DomainPermissionView, DomainInvitationPermissionDeleteView
from .utility import DomainPermissionView, DomainInvitationPermissionCancelView
logger = logging.getLogger(__name__)
@ -841,11 +842,88 @@ class DomainUsersView(DomainBaseView):
# Add modal buttons to the context (such as for delete)
context = self._add_modal_buttons_to_context(context)
# Get portfolio from session (if set)
portfolio = self.request.session.get("portfolio")
# Add domain manager roles separately in order to also pass admin status
context = self._add_domain_manager_roles_to_context(context, portfolio)
# Add domain invitations separately in order to also pass admin status
context = self._add_invitations_to_context(context, portfolio)
# Get the email of the current user
context["current_user_email"] = self.request.user.email
return context
def get(self, request, *args, **kwargs):
"""Get method for DomainUsersView."""
# Call the parent class's `get` method to get the response and context
response = super().get(request, *args, **kwargs)
# Ensure context is available after the parent call
context = response.context_data if hasattr(response, "context_data") else {}
# Check if context contains `domain_managers_roles` and its length is 1
if context.get("domain_manager_roles") and len(context["domain_manager_roles"]) == 1:
# Add an info message
messages.info(request, "This domain has one manager. Adding more can prevent issues.")
return response
def _add_domain_manager_roles_to_context(self, context, portfolio):
"""Add domain_manager_roles to context separately, as roles need admin indicator."""
# Prepare a list to store roles with an admin flag
domain_manager_roles = []
for permission in self.object.permissions.all():
# Determine if the user has the ORGANIZATION_ADMIN role
has_admin_flag = any(
UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_permission.roles
and portfolio == portfolio_permission.portfolio
for portfolio_permission in permission.user.portfolio_permissions.all()
)
# Add the role along with the computed flag to the list
domain_manager_roles.append({"permission": permission, "has_admin_flag": has_admin_flag})
# Pass roles_with_flags to the context
context["domain_manager_roles"] = domain_manager_roles
return context
def _add_invitations_to_context(self, context, portfolio):
"""Add invitations to context separately as invitations needs admin indicator."""
# Prepare a list to store invitations with an admin flag
invitations = []
for domain_invitation in self.object.invitations.all():
# Check if there are any PortfolioInvitations linked to the same portfolio with the ORGANIZATION_ADMIN role
has_admin_flag = False
# Query PortfolioInvitations linked to the same portfolio and check roles
portfolio_invitations = PortfolioInvitation.objects.filter(
portfolio=portfolio, email=domain_invitation.email
)
# If any of the PortfolioInvitations have the ORGANIZATION_ADMIN role, set the flag to True
for portfolio_invitation in portfolio_invitations:
if UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_invitation.roles:
has_admin_flag = True
break # Once we find one match, no need to check further
# Add the role along with the computed flag to the list if the domain invitation
# if the status is not canceled
if domain_invitation.status != "canceled":
invitations.append({"domain_invitation": domain_invitation, "has_admin_flag": has_admin_flag})
# Pass roles_with_flags to the context
context["invitations"] = invitations
return context
def _add_booleans_to_context(self, context):
# Determine if the current user can delete managers
domain_pk = None
@ -909,6 +987,23 @@ class DomainAddUserView(DomainFormBaseView):
existing_org_invitation and existing_org_invitation.portfolio != requestor_org
)
def _check_invite_status(self, invite, email):
"""Check if invitation status is canceled or retrieved, and gives the appropiate response"""
if invite.status == DomainInvitation.DomainInvitationStatus.RETRIEVED:
messages.warning(
self.request,
f"{email} is already a manager for this domain.",
)
return False
elif invite.status == DomainInvitation.DomainInvitationStatus.CANCELED:
invite.update_cancellation_status()
invite.save()
return True
else:
# else if it has been sent but not accepted
messages.warning(self.request, f"{email} has already been invited to this domain")
return False
def _send_domain_invitation_email(self, email: str, requestor: User, requested_user=None, add_success=True):
"""Performs the sending of the domain invitation email,
does not make a domain information object
@ -944,17 +1039,8 @@ class DomainAddUserView(DomainFormBaseView):
# 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")
# check if the invite has already been accepted or has a canceled invite
add_success = self._check_invite_status(invite, email)
except Exception:
logger.error("An error occured")
@ -976,6 +1062,7 @@ class DomainAddUserView(DomainFormBaseView):
self.object,
exc_info=True,
)
logger.info(exc)
raise EmailSendingError("Could not send email invitation.") from exc
else:
if add_success:
@ -1051,11 +1138,9 @@ class DomainAddUserView(DomainFormBaseView):
return redirect(self.get_success_url())
# The order of the superclasses matters here. BaseDeleteView has a bug where the
# "form_valid" function does not call super, so it cannot use SuccessMessageMixin.
# The workaround is to use SuccessMessageMixin first.
class DomainInvitationDeleteView(SuccessMessageMixin, DomainInvitationPermissionDeleteView):
object: DomainInvitation # workaround for type mismatch in DeleteView
class DomainInvitationCancelView(SuccessMessageMixin, DomainInvitationPermissionCancelView):
object: DomainInvitation
fields = []
def post(self, request, *args, **kwargs):
"""Override post method in order to error in the case when the
@ -1063,6 +1148,8 @@ class DomainInvitationDeleteView(SuccessMessageMixin, DomainInvitationPermission
self.object = self.get_object()
form = self.get_form()
if form.is_valid() and self.object.status == self.object.DomainInvitationStatus.INVITED:
self.object.cancel_invitation()
self.object.save()
return self.form_valid(form)
else:
# Produce an error message if the domain invatation status is RETRIEVED

View file

@ -53,7 +53,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
URL_NAMESPACE = "domain-request"
# name for accessing /domain-request/<id>/edit
EDIT_URL_NAME = "edit-domain-request"
NEW_URL_NAME = "/request/"
NEW_URL_NAME = "/request/start/"
# region: Titles
# We need to pass our human-readable step titles as context to the templates.
@ -158,6 +158,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
# Configure titles, wizard_conditions, unlocking_steps, and steps
self.configure_step_options()
self._domain_request = None # for caching
self.kwargs = {}
def configure_step_options(self):
"""Changes which steps are available to the user based on self.is_portfolio.
@ -182,7 +183,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
def has_pk(self):
"""Does this wizard know about a DomainRequest database record?"""
return "domain_request_id" in self.storage
return bool(self.kwargs.get("id") is not None)
def get_step_enum(self):
"""Determines which step enum we should use for the wizard"""
@ -214,11 +215,10 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
raise ValueError("Invalid value for User")
if self.has_pk():
id = self.storage["domain_request_id"]
try:
self._domain_request = DomainRequest.objects.get(
creator=creator,
pk=id,
pk=self.kwargs.get("id"),
)
return self._domain_request
except DomainRequest.DoesNotExist:
@ -238,8 +238,6 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
self._domain_request.save()
else:
self._domain_request = DomainRequest.objects.create(creator=self.request.user)
self.storage["domain_request_id"] = self._domain_request.id
return self._domain_request
@property
@ -295,6 +293,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
def get(self, request, *args, **kwargs):
"""This method handles GET requests."""
self.kwargs = kwargs
if not self.is_portfolio and self.request.user.is_org_user(request):
self.is_portfolio = True
# Configure titles, wizard_conditions, unlocking_steps, and steps
@ -307,7 +306,6 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
# and remove any prior wizard data from their session
if current_url == self.EDIT_URL_NAME and "id" in kwargs:
del self.storage
self.storage["domain_request_id"] = kwargs["id"]
# if accessing this class directly, redirect to either to an acknowledgement
# page or to the first step in the processes (if an edit rather than a new request);
@ -319,15 +317,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
# Clear context so the prop getter won't create a request here.
# Creating a request will be handled in the post method for the
# intro page.
return render(
request,
"domain_request_intro.html",
{
"hide_requests": True,
"hide_domains": True,
"hide_members": True,
},
)
return render(request, "domain_request_intro.html")
else:
return self.goto(self.steps.first)
@ -450,7 +440,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
if self.domain_request.requested_domain is not None:
requested_domain_name = self.domain_request.requested_domain.name
context_stuff = {}
context = {}
# Note: we will want to consolidate the non_org_steps_complete check into the same check that
# org_steps_complete is using at some point.
@ -458,7 +448,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
org_steps_complete = len(self.db_check_for_unlocking_steps()) == len(self.steps)
if (not self.is_portfolio and non_org_steps_complete) or (self.is_portfolio and org_steps_complete):
modal_button = '<button type="submit" ' 'class="usa-button" ' ">Submit request</button>"
context_stuff = {
context = {
"not_form": False,
"form_titles": self.titles,
"steps": self.steps,
@ -475,7 +465,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
}
else: # form is not complete
modal_button = '<button type="button" class="usa-button" data-close-modal>Return to request</button>'
context_stuff = {
context = {
"not_form": True,
"form_titles": self.titles,
"steps": self.steps,
@ -489,24 +479,16 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
"user": self.request.user,
"requested_domain__name": requested_domain_name,
}
# Hides the requests and domains buttons in the navbar
context_stuff["hide_requests"] = self.is_portfolio
context_stuff["hide_domains"] = self.is_portfolio
return context_stuff
context["domain_request_id"] = self.domain_request.id
return context
def get_step_list(self) -> list:
"""Dynamically generated list of steps in the form wizard."""
return request_step_list(self, self.get_step_enum())
def goto(self, step):
if step == "generic_org_type" or step == "portfolio_requesting_entity":
# We need to avoid creating a new domain request if the user
# clicks the back button
self.request.session["new_request"] = False
self.steps.current = step
return redirect(reverse(f"{self.URL_NAMESPACE}:{step}"))
return redirect(reverse(f"{self.URL_NAMESPACE}:{step}", kwargs={"id": self.domain_request.id}))
def goto_next_step(self):
"""Redirects to the next step."""
@ -532,9 +514,6 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
# which button did the user press?
button: str = request.POST.get("submit_button", "")
if "new_request" not in request.session:
request.session["new_request"] = True
# if user has acknowledged the intro message
if button == "intro_acknowledge":
# Split into a function: C901 'DomainRequestWizard.post' is too complex (11)
@ -572,9 +551,6 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
def handle_intro_acknowledge(self, request):
"""If we are starting a new request, clear storage
and redirect to the first step"""
if request.path_info == self.NEW_URL_NAME:
if self.request.session["new_request"] is True:
del self.storage
return self.goto(self.steps.first)
def save(self, forms: list):

View file

@ -7,7 +7,6 @@ def index(request):
if request and request.user and request.user.is_authenticated:
# This controls the creation of a new domain request in the wizard
request.session["new_request"] = True
context["user_domain_count"] = request.user.get_user_domain_ids(request).count()
return render(request, "home.html", context)

View file

@ -12,6 +12,7 @@ from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.views.utility.mixins import PortfolioMembersPermission
from registrar.models.utility.orm_helper import ArrayRemoveNull
class PortfolioMembersJson(PortfolioMembersPermission, View):
@ -100,7 +101,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
user__permissions__domain__domain_info__portfolio=portfolio
), # only include domains in portfolio
),
source=Value("permission", output_field=CharField()),
type=Value("member", output_field=CharField()),
)
.values(
"id",
@ -112,7 +113,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
"additional_permissions_display",
"member_display",
"domain_info",
"source",
"type",
)
)
return permissions
@ -134,13 +135,13 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
additional_permissions_display=F("additional_permissions"),
member_display=F("email"),
# Use ArrayRemove to return an empty list when no domain invitations are found
domain_info=ArrayRemove(
domain_info=ArrayRemoveNull(
ArrayAgg(
Subquery(domain_invitations.values("domain_info")),
distinct=True,
)
),
source=Value("invitation", output_field=CharField()),
type=Value("invitedmember", output_field=CharField()),
).values(
"id",
"first_name",
@ -151,7 +152,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
"additional_permissions_display",
"member_display",
"domain_info",
"source",
"type",
)
return invitations
@ -188,12 +189,12 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
view_only = not user.has_edit_members_portfolio_permission(portfolio) or not user_can_edit_other_users
is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in (item.get("roles") or [])
action_url = reverse("member" if item["source"] == "permission" else "invitedmember", kwargs={"pk": item["id"]})
action_url = reverse(item["type"], kwargs={"pk": item["id"]})
# Serialize member data
member_json = {
"id": item.get("id", ""),
"source": item.get("source", ""),
"id": item.get("id", ""), # id is id of UserPortfolioPermission or PortfolioInvitation
"type": item.get("type", ""), # source is member or invitedmember
"name": " ".join(filter(None, [item.get("first_name", ""), item.get("last_name", "")])),
"email": item.get("email_display", ""),
"member_display": item.get("member_display", ""),
@ -214,8 +215,3 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
}
return member_json
# Custom Func to use array_remove to remove null values
class ArrayRemove(Func):
function = "array_remove"
template = "%(function)s(%(expressions)s, NULL)"

View file

@ -1,13 +1,17 @@
import logging
from django.http import Http404
from django.http import Http404, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.safestring import mark_safe
from django.contrib import messages
from registrar.forms import portfolio as portfolioForms
from registrar.models import Portfolio, User
from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.views.utility.mixins import PortfolioMemberPermission
from registrar.views.utility.permission_views import (
PortfolioDomainRequestsPermissionView,
PortfolioDomainsPermissionView,
@ -40,8 +44,6 @@ class PortfolioDomainRequestsView(PortfolioDomainRequestsPermissionView, View):
template_name = "portfolio_requests.html"
def get(self, request):
if self.request.user.is_authenticated:
request.session["new_request"] = True
return render(request, "portfolio_requests.html")
@ -83,6 +85,58 @@ class PortfolioMemberView(PortfolioMemberPermissionView, View):
)
class PortfolioMemberDeleteView(PortfolioMemberPermission, View):
def post(self, request, pk):
"""
Find and delete the portfolio member using the provided primary key (pk).
Redirect to a success page after deletion (or any other appropriate page).
"""
portfolio_member_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
member = portfolio_member_permission.user
active_requests_count = member.get_active_requests_count_in_portfolio(request)
support_url = "https://get.gov/contact/"
error_message = ""
if active_requests_count > 0:
# If they have any in progress requests
error_message = mark_safe( # nosec
f"This member has an active domain request and can't be removed from the organization. "
f"<a href='{support_url}' target='_blank'>Contact the .gov team</a> to remove them."
)
elif member.is_only_admin_of_portfolio(portfolio_member_permission.portfolio):
# If they are the last manager of a domain
error_message = (
"There must be at least one admin in your organization. Give another member admin "
"permissions, make sure they log into the registrar, and then remove this member."
)
# From the Members Table page Else the Member Page
if error_message:
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
return JsonResponse(
{"error": error_message},
status=400,
)
else:
messages.error(request, error_message)
return redirect(reverse("member", kwargs={"pk": pk}))
# passed all error conditions
portfolio_member_permission.delete()
# From the Members Table page Else the Member Page
success_message = f"You've removed {member.email} from the organization."
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
return JsonResponse({"success": success_message}, status=200)
else:
messages.success(request, success_message)
return redirect(reverse("members"))
class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View):
template_name = "portfolio_member_permissions.html"
@ -179,6 +233,26 @@ class PortfolioInvitedMemberView(PortfolioMemberPermissionView, View):
)
class PortfolioInvitedMemberDeleteView(PortfolioMemberPermission, View):
def post(self, request, pk):
"""
Find and delete the portfolio invited member using the provided primary key (pk).
Redirect to a success page after deletion (or any other appropriate page).
"""
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
portfolio_invitation.delete()
success_message = f"You've removed {portfolio_invitation.email} from the organization."
# From the Members Table page Else the Member Page
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
return JsonResponse({"success": success_message}, status=200)
else:
messages.success(request, success_message)
return redirect(reverse("members"))
class PortfolioInvitedMemberEditView(PortfolioMemberEditPermissionView, View):
template_name = "portfolio_member_permissions.html"

View file

@ -169,6 +169,34 @@ class ExportDataTypeUser(View):
return response
class ExportMembersPortfolio(View):
"""Returns a members report for a given portfolio"""
def get(self, request, *args, **kwargs):
"""Returns the members report"""
portfolio = request.session.get("portfolio")
# Check if the user has organization access
if not request.user.is_org_user(request):
return render(request, "403.html", status=403)
# Check if the user has member permissions
if not request.user.has_view_members_portfolio_permission(
portfolio
) and not request.user.has_edit_members_portfolio_permission(portfolio):
return render(request, "403.html", status=403)
# Swap the spaces for dashes to make the formatted name look prettier
portfolio_display = "organization"
if portfolio:
portfolio_display = str(portfolio).lower().replace(" ", "-")
response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = f'attachment; filename="members-for-{portfolio_display}.csv"'
csv_export.MemberExport.export_data_to_csv(response, request=request)
return response
class ExportDataTypeRequests(View):
"""Returns a domain requests report for a given user on the request"""

View file

@ -53,7 +53,7 @@ class UserProfileView(UserProfilePermissionView, FormMixin):
context = super().get_context_data(**kwargs)
# Set the profile_back_button_text based on the redirect parameter
if kwargs.get("redirect") == "domain-request:":
if kwargs.get("redirect") == "domain-request:start":
context["profile_back_button_text"] = "Go back to your domain request"
else:
context["profile_back_button_text"] = "Go to manage your domains"

View file

@ -5,9 +5,9 @@ from .permission_views import (
DomainPermissionView,
DomainRequestPermissionView,
DomainRequestPermissionWithdrawView,
DomainInvitationPermissionDeleteView,
DomainRequestWizardPermissionView,
PortfolioMembersPermission,
DomainRequestPortfolioViewonlyView,
DomainInvitationPermissionCancelView,
)
from .api_views import get_senior_official_from_federal_agency_json

View file

@ -39,6 +39,86 @@ def get_senior_official_from_federal_agency_json(request):
return JsonResponse({"error": "Senior Official not found"}, status=404)
@login_required
@staff_member_required
def get_portfolio_json(request):
"""Returns portfolio information as a JSON"""
# This API is only accessible to admins and analysts
superuser_perm = request.user.has_perm("registrar.full_access_permission")
analyst_perm = request.user.has_perm("registrar.analyst_access_permission")
if not request.user.is_authenticated or not any([analyst_perm, superuser_perm]):
return JsonResponse({"error": "You do not have access to this resource"}, status=403)
portfolio_id = request.GET.get("id")
try:
portfolio = Portfolio.objects.get(id=portfolio_id)
except Portfolio.DoesNotExist:
return JsonResponse({"error": "Portfolio not found"}, status=404)
# Convert the portfolio to a dictionary
portfolio_dict = model_to_dict(portfolio)
portfolio_dict["id"] = portfolio.id
# map portfolio federal type
portfolio_dict["federal_type"] = (
BranchChoices.get_branch_label(portfolio.federal_type) if portfolio.federal_type else "-"
)
# map portfolio organization type
portfolio_dict["organization_type"] = (
DomainRequest.OrganizationChoices.get_org_label(portfolio.organization_type)
if portfolio.organization_type
else "-"
)
# Add senior official information if it exists
if portfolio.senior_official:
senior_official = model_to_dict(
portfolio.senior_official, fields=["id", "first_name", "last_name", "title", "phone", "email"]
)
# The phone number field isn't json serializable, so we
# convert this to a string first if it exists.
if "phone" in senior_official and senior_official.get("phone"):
senior_official["phone"] = str(senior_official["phone"])
portfolio_dict["senior_official"] = senior_official
else:
portfolio_dict["senior_official"] = None
# Add federal agency information if it exists
if portfolio.federal_agency:
federal_agency = model_to_dict(portfolio.federal_agency, fields=["agency", "id"])
portfolio_dict["federal_agency"] = federal_agency
else:
portfolio_dict["federal_agency"] = "-"
return JsonResponse(portfolio_dict)
@login_required
@staff_member_required
def get_suborganization_list_json(request):
"""Returns suborganization list information for a portfolio as a JSON"""
# This API is only accessible to admins and analysts
superuser_perm = request.user.has_perm("registrar.full_access_permission")
analyst_perm = request.user.has_perm("registrar.analyst_access_permission")
if not request.user.is_authenticated or not any([analyst_perm, superuser_perm]):
return JsonResponse({"error": "You do not have access to this resource"}, status=403)
portfolio_id = request.GET.get("portfolio_id")
try:
portfolio = Portfolio.objects.get(id=portfolio_id)
except Portfolio.DoesNotExist:
return JsonResponse({"error": "Portfolio not found"}, status=404)
# Add suborganizations related to this portfolio
suborganizations = portfolio.portfolio_suborganizations.all().values("id", "name")
results = [{"id": sub["id"], "text": sub["name"]} for sub in suborganizations]
return JsonResponse({"results": results, "pagination": {"more": False}})
@login_required
@staff_member_required
def get_federal_and_portfolio_types_from_federal_agency_json(request):

View file

@ -430,7 +430,6 @@ class DomainInvitationPermission(PermissionsLoginMixin):
id=self.kwargs["pk"], domain__permissions__user=self.request.user
).exists():
return False
return True

View file

@ -2,7 +2,7 @@
import abc # abstract base class
from django.views.generic import DetailView, DeleteView, TemplateView
from django.views.generic import DetailView, DeleteView, TemplateView, UpdateView
from registrar.models import Domain, DomainRequest, DomainInvitation, Portfolio
from registrar.models.user import User
from registrar.models.user_domain_role import UserDomainRole
@ -156,17 +156,11 @@ class DomainRequestWizardPermissionView(DomainRequestWizardPermission, TemplateV
raise NotImplementedError
class DomainInvitationPermissionDeleteView(DomainInvitationPermission, DeleteView, abc.ABC):
"""Abstract view for deleting a domain invitation.
This one is fairly specialized, but this is the only thing that we do
right now with domain invitations. We still have the full
`DomainInvitationPermission` class, but here we just pair it with a
DeleteView.
"""
class DomainInvitationPermissionCancelView(DomainInvitationPermission, UpdateView, abc.ABC):
"""Abstract view for cancelling a DomainInvitation."""
model = DomainInvitation
object: DomainInvitation # workaround for type mismatch in DeleteView
object: DomainInvitation
class DomainRequestPermissionDeleteView(DomainRequestPermission, DeleteView, abc.ABC):