mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-30 22:46:30 +02:00
resolved merge
This commit is contained in:
commit
bf34df52dd
96 changed files with 4278 additions and 1324 deletions
|
@ -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
|
2
.github/workflows/deploy-sandbox.yaml
vendored
2
.github/workflows/deploy-sandbox.yaml
vendored
|
@ -71,6 +71,8 @@ jobs:
|
|||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [variables, deploy]
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/github-script@v6
|
||||
env:
|
||||
|
|
2
.github/workflows/issue-label-notifier.yaml
vendored
2
.github/workflows/issue-label-notifier.yaml
vendored
|
@ -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
3
.gitignore
vendored
|
@ -171,6 +171,9 @@ node_modules
|
|||
# Vim
|
||||
*.swp
|
||||
|
||||
# VS Code
|
||||
.vscode
|
||||
|
||||
# Compliance/trestle related
|
||||
docs/compliance/.trestle/cache
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
51
ops/scripts/rotate_login_certs.sh
Executable file
51
ops/scripts/rotate_login_certs.sh
Executable 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"
|
|
@ -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/",
|
||||
|
|
|
@ -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",
|
||||
|
|
1
src/registrar/assets/css/select2.min.css
vendored
Normal file
1
src/registrar/assets/css/select2.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
|
@ -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, it’s 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 aren’t 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
2
src/registrar/assets/js/select2.min.js
vendored
Normal file
2
src/registrar/assets/js/select2.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -119,7 +119,7 @@ in the form $setting: value,
|
|||
/*---------------------------
|
||||
## Emergency state
|
||||
----------------------------*/
|
||||
$theme-color-emergency: #FFC3F9,
|
||||
$theme-color-emergency: "red-warm-60v",
|
||||
|
||||
/*---------------------------
|
||||
# Input settings
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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.")
|
||||
|
||||
|
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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}"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
8
src/registrar/models/utility/orm_helper.py
Normal file
8
src/registrar/models/utility/orm_helper.py
Normal 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)"
|
|
@ -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"
|
||||
|
|
|
@ -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)):
|
||||
|
|
|
@ -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 %}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
<th>Title</th>
|
||||
<th>Email</th>
|
||||
<th>Phone</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
<th>Email</th>
|
||||
<th>Phone</th>
|
||||
<th>Roles</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
|
@ -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 %}
|
||||
|
||||
|
||||
|
|
|
@ -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> We’ll email you if we have questions. We’ll 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> We’ll email you if we have questions. We’ll 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>
|
||||
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 we’ll reach you</h2>
|
||||
<p>While reviewing your domain request, we may need to reach out with questions. We’ll 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. We’ll 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%}
|
||||
|
||||
|
||||
|
|
|
@ -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 you’re 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' %}">.gov’s public data</a>. If you don’t 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' %}">.gov’s public data</a>. If you don’t 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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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 can’t remove yourself as a domain manager if you’re 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 can’t remove yourself as a domain manager if you’re 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 %}
|
||||
|
|
|
@ -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>
|
||||
|
|
12
src/registrar/templates/includes/banner-error.html
Normal file
12
src/registrar/templates/includes/banner-error.html
Normal 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>
|
12
src/registrar/templates/includes/banner-info.html
Normal file
12
src/registrar/templates/includes/banner-info.html
Normal 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>
|
|
@ -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>
|
|
@ -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>
|
12
src/registrar/templates/includes/banner-site-alert.html
Normal file
12
src/registrar/templates/includes/banner-site-alert.html
Normal 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>
|
12
src/registrar/templates/includes/banner-system-outage.html
Normal file
12
src/registrar/templates/includes/banner-system-outage.html
Normal 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>
|
12
src/registrar/templates/includes/banner-warning.html
Normal file
12
src/registrar/templates/includes/banner-warning.html
Normal 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>
|
|
@ -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
|
||||
|
|
|
@ -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 ---------- -->
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}">
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
<h2 id="domains-header" class="display-inline-block">You aren’t managing any domains.</h2>
|
||||
{% if portfolio_administrators %}
|
||||
<p>If you believe you should have access to a domain, reach out to your organization’s 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 %}
|
||||
|
|
|
@ -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 don’t have access to domain requests.</h2>
|
||||
{% if portfolio_administrators %}
|
||||
<p>If you believe you should have access to a request, reach out to your organization’s administrators.</p>
|
||||
<p>Your organizations administrators:</p>
|
||||
<p>If you believe you should have access to requests, reach out to your organization’s administrators.</p>
|
||||
<p>Your organization's administrators:</p>
|
||||
<ul class="margin-top-0">
|
||||
{% for administrator in portfolio_administrators %}
|
||||
{% if administrator.email %}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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."""
|
||||
|
||||
|
|
|
@ -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,
|
||||
"You’re 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")
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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, "You’re 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}")
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -11,7 +11,7 @@ from .domain import (
|
|||
DomainSecurityEmailView,
|
||||
DomainUsersView,
|
||||
DomainAddUserView,
|
||||
DomainInvitationDeleteView,
|
||||
DomainInvitationCancelView,
|
||||
DomainDeleteUserView,
|
||||
)
|
||||
from .user_profile import UserProfileView, FinishProfileSetupView
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"""
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -430,7 +430,6 @@ class DomainInvitationPermission(PermissionsLoginMixin):
|
|||
id=self.kwargs["pk"], domain__permissions__user=self.request.user
|
||||
).exists():
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue