mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-22 10:46:06 +02:00
Merge branch 'main' into za/2737-members-csv-report
This commit is contained in:
commit
43bc9695fb
35 changed files with 1240 additions and 130 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:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
|
@ -8,18 +13,20 @@ on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DESTINATION_ENVIRONMENT: ms
|
# sandbox receiving the cloned db
|
||||||
SOURCE_ENVIRONMENT: staging
|
DESTINATION_ENVIRONMENT: staging
|
||||||
|
# sandbox we are cloning
|
||||||
|
SOURCE_ENVIRONMENT: stable
|
||||||
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
clone-database:
|
clone-database:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
env:
|
env:
|
||||||
CF_USERNAME: ${{ secrets.CF_MS_USERNAME }}
|
# must be the github secrets for the receiving sandbox
|
||||||
CF_PASSWORD: ${{ secrets.CF_MS_PASSWORD }}
|
CF_USERNAME: ${{ secrets.CF_STAGING_USERNAME }}
|
||||||
|
CF_PASSWORD: ${{ secrets.CF_STAGING_PASSWORD }}
|
||||||
steps:
|
steps:
|
||||||
- uses: GitHubSecurityLab/actions-permissions/monitor@v1
|
|
||||||
- name: Clone Database
|
- name: Clone Database
|
||||||
run: |
|
run: |
|
||||||
# install cf cli and other tools
|
# install cf cli and other tools
|
||||||
|
@ -43,6 +50,16 @@ jobs:
|
||||||
# clone from source to destination
|
# clone from source to destination
|
||||||
cf target -s $SOURCE_ENVIRONMENT
|
cf target -s $SOURCE_ENVIRONMENT
|
||||||
cg-manage-rds clone getgov-$SOURCE_ENVIRONMENT-database getgov-$DESTINATION_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
|
- name: Cleanup
|
||||||
if: always()
|
if: always()
|
||||||
run: cf unshare-service getgov-$DESTINATION_ENVIRONMENT-database -s $SOURCE_ENVIRONMENT -f
|
run: cf unshare-service getgov-$DESTINATION_ENVIRONMENT-database -s $SOURCE_ENVIRONMENT -f
|
1
.github/workflows/createcachetable.yaml
vendored
1
.github/workflows/createcachetable.yaml
vendored
|
@ -37,7 +37,6 @@ jobs:
|
||||||
CF_USERNAME: CF_${{ github.event.inputs.environment }}_USERNAME
|
CF_USERNAME: CF_${{ github.event.inputs.environment }}_USERNAME
|
||||||
CF_PASSWORD: CF_${{ github.event.inputs.environment }}_PASSWORD
|
CF_PASSWORD: CF_${{ github.event.inputs.environment }}_PASSWORD
|
||||||
steps:
|
steps:
|
||||||
- uses: GitHubSecurityLab/actions-permissions/monitor@v1
|
|
||||||
- name: Create cache table for ${{ github.event.inputs.environment }}
|
- name: Create cache table for ${{ github.event.inputs.environment }}
|
||||||
uses: cloud-gov/cg-cli-tools@main
|
uses: cloud-gov/cg-cli-tools@main
|
||||||
with:
|
with:
|
||||||
|
|
1
.github/workflows/daily-csv-upload.yaml
vendored
1
.github/workflows/daily-csv-upload.yaml
vendored
|
@ -13,7 +13,6 @@ jobs:
|
||||||
CF_USERNAME: CF_${{ secrets.CF_REPORT_ENV }}_USERNAME
|
CF_USERNAME: CF_${{ secrets.CF_REPORT_ENV }}_USERNAME
|
||||||
CF_PASSWORD: CF_${{ secrets.CF_REPORT_ENV }}_PASSWORD
|
CF_PASSWORD: CF_${{ secrets.CF_REPORT_ENV }}_PASSWORD
|
||||||
steps:
|
steps:
|
||||||
- uses: GitHubSecurityLab/actions-permissions/monitor@v1
|
|
||||||
- name: Generate current-federal.csv
|
- name: Generate current-federal.csv
|
||||||
uses: cloud-gov/cg-cli-tools@main
|
uses: cloud-gov/cg-cli-tools@main
|
||||||
with:
|
with:
|
||||||
|
|
6
.github/workflows/deploy-development.yaml
vendored
6
.github/workflows/deploy-development.yaml
vendored
|
@ -16,9 +16,7 @@ on:
|
||||||
jobs:
|
jobs:
|
||||||
deploy-development:
|
deploy-development:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: GitHubSecurityLab/actions-permissions/monitor@v1
|
|
||||||
|
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Compile USWDS assets
|
- name: Compile USWDS assets
|
||||||
|
@ -48,4 +46,4 @@ jobs:
|
||||||
cf_password: ${{ secrets.CF_DEVELOPMENT_PASSWORD }}
|
cf_password: ${{ secrets.CF_DEVELOPMENT_PASSWORD }}
|
||||||
cf_org: cisa-dotgov
|
cf_org: cisa-dotgov
|
||||||
cf_space: development
|
cf_space: development
|
||||||
cf_command: "run-task getgov-development --command 'python manage.py migrate' --name migrate"
|
cf_command: "run-task getgov-development --command 'python manage.py migrate' --name migrate"
|
||||||
|
|
2
.github/workflows/deploy-manual.yaml
vendored
2
.github/workflows/deploy-manual.yaml
vendored
|
@ -44,7 +44,6 @@ jobs:
|
||||||
variables:
|
variables:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: GitHubSecurityLab/actions-permissions/monitor@v1
|
|
||||||
- name: Setting global variables
|
- name: Setting global variables
|
||||||
uses: actions/github-script@v6
|
uses: actions/github-script@v6
|
||||||
id: var
|
id: var
|
||||||
|
@ -54,7 +53,6 @@ jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: GitHubSecurityLab/actions-permissions/monitor@v1
|
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- name: Compile USWDS assets
|
- name: Compile USWDS assets
|
||||||
working-directory: ./src
|
working-directory: ./src
|
||||||
|
|
6
.github/workflows/deploy-sandbox.yaml
vendored
6
.github/workflows/deploy-sandbox.yaml
vendored
|
@ -35,7 +35,6 @@ jobs:
|
||||||
environment: ${{ steps.var.outputs.environment}}
|
environment: ${{ steps.var.outputs.environment}}
|
||||||
runs-on: "ubuntu-latest"
|
runs-on: "ubuntu-latest"
|
||||||
steps:
|
steps:
|
||||||
- uses: GitHubSecurityLab/actions-permissions/monitor@v1
|
|
||||||
- name: Setting global variables
|
- name: Setting global variables
|
||||||
uses: actions/github-script@v6
|
uses: actions/github-script@v6
|
||||||
id: var
|
id: var
|
||||||
|
@ -46,7 +45,6 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [variables]
|
needs: [variables]
|
||||||
steps:
|
steps:
|
||||||
- uses: GitHubSecurityLab/actions-permissions/monitor@v1
|
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- name: Compile USWDS assets
|
- name: Compile USWDS assets
|
||||||
working-directory: ./src
|
working-directory: ./src
|
||||||
|
@ -73,6 +71,8 @@ jobs:
|
||||||
comment:
|
comment:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [variables, deploy]
|
needs: [variables, deploy]
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/github-script@v6
|
- uses: actions/github-script@v6
|
||||||
env:
|
env:
|
||||||
|
@ -85,4 +85,4 @@ jobs:
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
body: '🥳 Successfully deployed to developer sandbox **[${{ env.ENVIRONMENT }}](https://getgov-${{ env.ENVIRONMENT }}.app.cloud.gov/)**.'
|
body: '🥳 Successfully deployed to developer sandbox **[${{ env.ENVIRONMENT }}](https://getgov-${{ env.ENVIRONMENT }}.app.cloud.gov/)**.'
|
||||||
})
|
})
|
||||||
|
|
3
.github/workflows/deploy-stable.yaml
vendored
3
.github/workflows/deploy-stable.yaml
vendored
|
@ -18,7 +18,6 @@ jobs:
|
||||||
if: ${{ github.ref_type == 'tag' }}
|
if: ${{ github.ref_type == 'tag' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: GitHubSecurityLab/actions-permissions/monitor@v1
|
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Compile USWDS assets
|
- name: Compile USWDS assets
|
||||||
|
@ -45,4 +44,4 @@ jobs:
|
||||||
cf_password: ${{ secrets.CF_STABLE_PASSWORD }}
|
cf_password: ${{ secrets.CF_STABLE_PASSWORD }}
|
||||||
cf_org: cisa-dotgov
|
cf_org: cisa-dotgov
|
||||||
cf_space: stable
|
cf_space: stable
|
||||||
cf_command: "run-task getgov-stable --command 'python manage.py migrate' --name migrate"
|
cf_command: "run-task getgov-stable --command 'python manage.py migrate' --name migrate"
|
||||||
|
|
3
.github/workflows/deploy-staging.yaml
vendored
3
.github/workflows/deploy-staging.yaml
vendored
|
@ -18,7 +18,6 @@ jobs:
|
||||||
if: ${{ github.ref_type == 'tag' }}
|
if: ${{ github.ref_type == 'tag' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: GitHubSecurityLab/actions-permissions/monitor@v1
|
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Compile USWDS assets
|
- name: Compile USWDS assets
|
||||||
|
@ -45,4 +44,4 @@ jobs:
|
||||||
cf_password: ${{ secrets.CF_STAGING_PASSWORD }}
|
cf_password: ${{ secrets.CF_STAGING_PASSWORD }}
|
||||||
cf_org: cisa-dotgov
|
cf_org: cisa-dotgov
|
||||||
cf_space: staging
|
cf_space: staging
|
||||||
cf_command: "run-task getgov-staging --command 'python manage.py migrate' --name migrate"
|
cf_command: "run-task getgov-staging --command 'python manage.py migrate' --name migrate"
|
||||||
|
|
3
.github/workflows/issue-label-notifier.yaml
vendored
3
.github/workflows/issue-label-notifier.yaml
vendored
|
@ -9,8 +9,9 @@ on:
|
||||||
jobs:
|
jobs:
|
||||||
notify:
|
notify:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
- uses: GitHubSecurityLab/actions-permissions/monitor@v1
|
|
||||||
- uses: jenschelkopf/issue-label-notification-action@1.3
|
- uses: jenschelkopf/issue-label-notification-action@1.3
|
||||||
with:
|
with:
|
||||||
recipients: |
|
recipients: |
|
||||||
|
|
1
.github/workflows/migrate.yaml
vendored
1
.github/workflows/migrate.yaml
vendored
|
@ -45,7 +45,6 @@ jobs:
|
||||||
CF_USERNAME: CF_${{ github.event.inputs.environment }}_USERNAME
|
CF_USERNAME: CF_${{ github.event.inputs.environment }}_USERNAME
|
||||||
CF_PASSWORD: CF_${{ github.event.inputs.environment }}_PASSWORD
|
CF_PASSWORD: CF_${{ github.event.inputs.environment }}_PASSWORD
|
||||||
steps:
|
steps:
|
||||||
- uses: GitHubSecurityLab/actions-permissions/monitor@v1
|
|
||||||
- name: Run Django migrations for ${{ github.event.inputs.environment }}
|
- name: Run Django migrations for ${{ github.event.inputs.environment }}
|
||||||
uses: cloud-gov/cg-cli-tools@main
|
uses: cloud-gov/cg-cli-tools@main
|
||||||
with:
|
with:
|
||||||
|
|
1
.github/workflows/reset-db.yaml
vendored
1
.github/workflows/reset-db.yaml
vendored
|
@ -45,7 +45,6 @@ jobs:
|
||||||
CF_USERNAME: CF_${{ github.event.inputs.environment }}_USERNAME
|
CF_USERNAME: CF_${{ github.event.inputs.environment }}_USERNAME
|
||||||
CF_PASSWORD: CF_${{ github.event.inputs.environment }}_PASSWORD
|
CF_PASSWORD: CF_${{ github.event.inputs.environment }}_PASSWORD
|
||||||
steps:
|
steps:
|
||||||
- uses: GitHubSecurityLab/actions-permissions/monitor@v1
|
|
||||||
- name: Delete existing data for ${{ github.event.inputs.environment }}
|
- name: Delete existing data for ${{ github.event.inputs.environment }}
|
||||||
uses: cloud-gov/cg-cli-tools@main
|
uses: cloud-gov/cg-cli-tools@main
|
||||||
with:
|
with:
|
||||||
|
|
1
.github/workflows/security-check.yaml
vendored
1
.github/workflows/security-check.yaml
vendored
|
@ -54,7 +54,6 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: GitHubSecurityLab/actions-permissions/monitor@v1
|
|
||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
- name: MockUserLogin should not be in settings.MIDDLEWARE
|
- name: MockUserLogin should not be in settings.MIDDLEWARE
|
||||||
|
|
3
.github/workflows/test.yaml
vendored
3
.github/workflows/test.yaml
vendored
|
@ -21,7 +21,6 @@ jobs:
|
||||||
python-linting:
|
python-linting:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: GitHubSecurityLab/actions-permissions/monitor@v1
|
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Linting
|
- name: Linting
|
||||||
|
@ -33,7 +32,6 @@ jobs:
|
||||||
python-test:
|
python-test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: GitHubSecurityLab/actions-permissions/monitor@v1
|
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
|
@ -43,7 +41,6 @@ jobs:
|
||||||
django-migrations-complete:
|
django-migrations-complete:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: GitHubSecurityLab/actions-permissions/monitor@v1
|
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Check for complete migrations
|
- name: Check for complete migrations
|
||||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -171,6 +171,9 @@ node_modules
|
||||||
# Vim
|
# Vim
|
||||||
*.swp
|
*.swp
|
||||||
|
|
||||||
|
# VS Code
|
||||||
|
.vscode
|
||||||
|
|
||||||
# Compliance/trestle related
|
# Compliance/trestle related
|
||||||
docs/compliance/.trestle/cache
|
docs/compliance/.trestle/cache
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
from datetime import date
|
from datetime import date
|
||||||
import logging
|
import logging
|
||||||
import copy
|
import copy
|
||||||
|
from typing import Optional
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.db.models import Value, CharField, Q
|
from django.db.models import Value, CharField, Q
|
||||||
from django.db.models.functions import Concat, Coalesce
|
from django.db.models.functions import Concat, Coalesce
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect
|
||||||
from registrar.models.federal_agency import FederalAgency
|
from registrar.models.federal_agency import FederalAgency
|
||||||
from registrar.utility.admin_helpers import (
|
from registrar.utility.admin_helpers import (
|
||||||
|
AutocompleteSelectWithPlaceholder,
|
||||||
get_action_needed_reason_default_email,
|
get_action_needed_reason_default_email,
|
||||||
get_rejection_reason_default_email,
|
get_rejection_reason_default_email,
|
||||||
get_field_links_as_list,
|
get_field_links_as_list,
|
||||||
|
@ -236,6 +238,14 @@ class DomainRequestAdminForm(forms.ModelForm):
|
||||||
"current_websites": NoAutocompleteFilteredSelectMultiple("current_websites", False),
|
"current_websites": NoAutocompleteFilteredSelectMultiple("current_websites", False),
|
||||||
"alternative_domains": NoAutocompleteFilteredSelectMultiple("alternative_domains", False),
|
"alternative_domains": NoAutocompleteFilteredSelectMultiple("alternative_domains", False),
|
||||||
"other_contacts": NoAutocompleteFilteredSelectMultiple("other_contacts", 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 = {
|
labels = {
|
||||||
"action_needed_reason_email": "Email",
|
"action_needed_reason_email": "Email",
|
||||||
|
@ -1816,6 +1826,70 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
custom_election_board.admin_order_field = "is_election_board" # type: ignore
|
custom_election_board.admin_order_field = "is_election_board" # type: ignore
|
||||||
custom_election_board.short_description = "Election office" # 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 just a placeholder. This field will be populated in the detail_table_fieldset view.
|
||||||
# This is not a field that exists on the model.
|
# This is not a field that exists on the model.
|
||||||
def status_history(self, obj):
|
def status_history(self, obj):
|
||||||
|
@ -1847,30 +1921,38 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
None,
|
None,
|
||||||
{
|
{
|
||||||
"fields": [
|
"fields": [
|
||||||
"portfolio",
|
|
||||||
"sub_organization",
|
|
||||||
"requested_suborganization",
|
|
||||||
"suborganization_city",
|
|
||||||
"suborganization_state_territory",
|
|
||||||
"status_history",
|
"status_history",
|
||||||
"status",
|
"status",
|
||||||
"rejection_reason",
|
"rejection_reason",
|
||||||
"rejection_reason_email",
|
"rejection_reason_email",
|
||||||
"action_needed_reason",
|
"action_needed_reason",
|
||||||
"action_needed_reason_email",
|
"action_needed_reason_email",
|
||||||
"investigator",
|
|
||||||
"creator",
|
|
||||||
"approved_domain",
|
"approved_domain",
|
||||||
|
"investigator",
|
||||||
"notes",
|
"notes",
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"Requested by",
|
||||||
|
{
|
||||||
|
"fields": [
|
||||||
|
"portfolio",
|
||||||
|
"sub_organization",
|
||||||
|
"requested_suborganization",
|
||||||
|
"suborganization_city",
|
||||||
|
"suborganization_state_territory",
|
||||||
|
"creator",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
),
|
||||||
(".gov domain", {"fields": ["requested_domain", "alternative_domains"]}),
|
(".gov domain", {"fields": ["requested_domain", "alternative_domains"]}),
|
||||||
(
|
(
|
||||||
"Contacts",
|
"Contacts",
|
||||||
{
|
{
|
||||||
"fields": [
|
"fields": [
|
||||||
"senior_official",
|
"senior_official",
|
||||||
|
"portfolio_senior_official",
|
||||||
"other_contacts",
|
"other_contacts",
|
||||||
"no_other_contacts_rationale",
|
"no_other_contacts_rationale",
|
||||||
"cisa_representative_first_name",
|
"cisa_representative_first_name",
|
||||||
|
@ -1927,10 +2009,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 for analysts and superusers
|
||||||
readonly_fields = (
|
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",
|
"other_contacts",
|
||||||
"current_websites",
|
"current_websites",
|
||||||
"alternative_domains",
|
"alternative_domains",
|
||||||
|
@ -1979,10 +2106,12 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
def get_fieldsets(self, request, obj=None):
|
def get_fieldsets(self, request, obj=None):
|
||||||
fieldsets = super().get_fieldsets(request, obj)
|
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 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 = [
|
excluded_fields = [
|
||||||
|
"portfolio",
|
||||||
|
"sub_organization",
|
||||||
"requested_suborganization",
|
"requested_suborganization",
|
||||||
"suborganization_city",
|
"suborganization_city",
|
||||||
"suborganization_state_territory",
|
"suborganization_state_territory",
|
||||||
|
|
|
@ -86,6 +86,506 @@ function handleSuborganizationFields(
|
||||||
portfolioDropdown.on("change", toggleSuborganizationFields);
|
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.
|
// Initialization code.
|
||||||
|
|
||||||
|
@ -797,6 +1297,63 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
customEmail.loadRejectedEmail()
|
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)
|
/** An IIFE for copy summary button (appears in DomainRegistry models)
|
||||||
*/
|
*/
|
||||||
|
@ -844,10 +1401,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
if (contacts) {
|
if (contacts) {
|
||||||
contacts.forEach(contact => {
|
contacts.forEach(contact => {
|
||||||
// Check if the <dl> element is not empty
|
// Check if the <dl> element is not empty
|
||||||
const name = contact.querySelector('a#contact_info_name')?.innerText;
|
const name = contact.querySelector('a.contact_info_name')?.innerText;
|
||||||
const title = contact.querySelector('span#contact_info_title')?.innerText;
|
const title = contact.querySelector('span.contact_info_title')?.innerText;
|
||||||
const email = contact.querySelector('span#contact_info_email')?.innerText;
|
const email = contact.querySelector('span.contact_info_email')?.innerText;
|
||||||
const phone = contact.querySelector('span#contact_info_phone')?.innerText;
|
const phone = contact.querySelector('span.contact_info_phone')?.innerText;
|
||||||
const url = nameToUrlMap[name] || '#';
|
const url = nameToUrlMap[name] || '#';
|
||||||
// Format the contact information
|
// Format the contact information
|
||||||
const listItem = document.createElement('li');
|
const listItem = document.createElement('li');
|
||||||
|
@ -898,9 +1455,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
const seniorOfficialDiv = document.querySelector('.form-row.field-senior_official');
|
const seniorOfficialDiv = document.querySelector('.form-row.field-senior_official');
|
||||||
const seniorOfficialElement = document.getElementById('id_senior_official');
|
const seniorOfficialElement = document.getElementById('id_senior_official');
|
||||||
const seniorOfficialName = seniorOfficialElement.options[seniorOfficialElement.selectedIndex].text;
|
const seniorOfficialName = seniorOfficialElement.options[seniorOfficialElement.selectedIndex].text;
|
||||||
const seniorOfficialTitle = extractTextById('contact_info_title', seniorOfficialDiv);
|
const seniorOfficialTitle = seniorOfficialDiv.querySelector('.contact_info_title');
|
||||||
const seniorOfficialEmail = extractTextById('contact_info_email', seniorOfficialDiv);
|
const seniorOfficialEmail = seniorOfficialDiv.querySelector('.contact_info_email');
|
||||||
const seniorOfficialPhone = extractTextById('contact_info_phone', seniorOfficialDiv);
|
const seniorOfficialPhone = seniorOfficialDiv.querySelector('.contact_info_phone');
|
||||||
let seniorOfficialInfo = `${seniorOfficialName}${seniorOfficialTitle}${seniorOfficialEmail}${seniorOfficialPhone}`;
|
let seniorOfficialInfo = `${seniorOfficialName}${seniorOfficialTitle}${seniorOfficialEmail}${seniorOfficialPhone}`;
|
||||||
|
|
||||||
const html_summary = `<strong>Recommendation:</strong></br>` +
|
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
|
/** An IIFE for dynamically changing some fields on the portfolio admin model
|
||||||
|
* IMPORTANT NOTE: The logic in this IIFE is paired handlePortfolioSelection
|
||||||
*/
|
*/
|
||||||
(function dynamicPortfolioFields(){
|
(function dynamicPortfolioFields(){
|
||||||
|
|
||||||
|
@ -1184,9 +1742,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
function updateContactInfo(data) {
|
function updateContactInfo(data) {
|
||||||
if (!contactList) return;
|
if (!contactList) return;
|
||||||
|
|
||||||
const titleSpan = contactList.querySelector("#contact_info_title");
|
const titleSpan = contactList.querySelector(".contact_info_title");
|
||||||
const emailSpan = contactList.querySelector("#contact_info_email");
|
const emailSpan = contactList.querySelector(".contact_info_email");
|
||||||
const phoneSpan = contactList.querySelector("#contact_info_phone");
|
const phoneSpan = contactList.querySelector(".contact_info_phone");
|
||||||
|
|
||||||
if (titleSpan) {
|
if (titleSpan) {
|
||||||
titleSpan.textContent = data.title || "None";
|
titleSpan.textContent = data.title || "None";
|
||||||
|
@ -1218,7 +1776,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
(function dynamicDomainRequestFields(){
|
(function dynamicDomainRequestFields(){
|
||||||
const domainRequestPage = document.getElementById("domainrequest_form");
|
const domainRequestPage = document.getElementById("domainrequest_form");
|
||||||
if (domainRequestPage) {
|
if (domainRequestPage) {
|
||||||
handleSuborganizationFields();
|
handlePortfolioSelection();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
|
@ -29,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.domains_json import get_domains_json
|
||||||
from registrar.views.utility.api_views import (
|
from registrar.views.utility.api_views import (
|
||||||
get_senior_official_from_federal_agency_json,
|
get_senior_official_from_federal_agency_json,
|
||||||
|
get_portfolio_json,
|
||||||
|
get_suborganization_list_json,
|
||||||
get_federal_and_portfolio_types_from_federal_agency_json,
|
get_federal_and_portfolio_types_from_federal_agency_json,
|
||||||
get_action_needed_email_for_user_json,
|
get_action_needed_email_for_user_json,
|
||||||
get_rejection_email_for_user_json,
|
get_rejection_email_for_user_json,
|
||||||
|
@ -202,6 +204,16 @@ urlpatterns = [
|
||||||
get_senior_official_from_federal_agency_json,
|
get_senior_official_from_federal_agency_json,
|
||||||
name="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(
|
path(
|
||||||
"admin/api/get-federal-and-portfolio-types-from-federal-agency-json/",
|
"admin/api/get-federal-and-portfolio-types-from-federal-agency-json/",
|
||||||
get_federal_and_portfolio_types_from_federal_agency_json,
|
get_federal_and_portfolio_types_from_federal_agency_json,
|
||||||
|
@ -333,9 +345,9 @@ urlpatterns = [
|
||||||
name="user-profile",
|
name="user-profile",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"invitation/<int:pk>/delete",
|
"invitation/<int:pk>/cancel",
|
||||||
views.DomainInvitationDeleteView.as_view(http_method_names=["post"]),
|
views.DomainInvitationCancelView.as_view(http_method_names=["post"]),
|
||||||
name="invitation-delete",
|
name="invitation-cancel",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"domain-request/<int:pk>/delete",
|
"domain-request/<int:pk>/delete",
|
||||||
|
|
|
@ -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):
|
class DomainInvitationStatus(models.TextChoices):
|
||||||
INVITED = "invited", "Invited"
|
INVITED = "invited", "Invited"
|
||||||
RETRIEVED = "retrieved", "Retrieved"
|
RETRIEVED = "retrieved", "Retrieved"
|
||||||
|
CANCELED = "canceled", "Canceled"
|
||||||
|
|
||||||
email = models.EmailField(
|
email = models.EmailField(
|
||||||
null=False,
|
null=False,
|
||||||
|
@ -73,3 +74,13 @@ class DomainInvitation(TimeStampedModel):
|
||||||
# something strange happened and this role already existed when
|
# something strange happened and this role already existed when
|
||||||
# the invitation was retrieved. Log that this occurred.
|
# the invitation was retrieved. Log that this occurred.
|
||||||
logger.warn("Invitation %s was retrieved for a role that already exists.", self)
|
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
|
||||||
|
|
|
@ -4,7 +4,25 @@
|
||||||
Template for an input field with a clipboard
|
Template for an input field with a clipboard
|
||||||
{% endcomment %}
|
{% 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">
|
<div class="admin-icon-group">
|
||||||
{{ field }}
|
{{ field }}
|
||||||
<button
|
<button
|
||||||
|
|
|
@ -2,6 +2,13 @@
|
||||||
{% load custom_filters %}
|
{% load custom_filters %}
|
||||||
{% load i18n static %}
|
{% 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 %}
|
{% block field_sets %}
|
||||||
{# Create an invisible <a> tag so that we can use a click event to toggle the modal. #}
|
{# 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>
|
<a id="invisible-ineligible-modal-toggler" class="display-none" href="#toggle-set-ineligible" aria-controls="toggle-set-ineligible" data-open-modal></a>
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
{% if show_formatted_name %}
|
{% if show_formatted_name %}
|
||||||
{% if user.get_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 %}
|
{% else %}
|
||||||
None
|
None
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -16,7 +16,7 @@
|
||||||
{% if user|has_contact_info %}
|
{% if user|has_contact_info %}
|
||||||
{# Title #}
|
{# Title #}
|
||||||
{% if user.title %}
|
{% if user.title %}
|
||||||
<span id="contact_info_title">{{ user.title }}</span>
|
<span class="contact_info_title">{{ user.title }}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
None
|
None
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -24,7 +24,7 @@
|
||||||
|
|
||||||
{# Email #}
|
{# Email #}
|
||||||
{% if user.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 %}
|
{% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %}
|
||||||
<br>
|
<br>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
@ -33,17 +33,24 @@
|
||||||
|
|
||||||
{# Phone #}
|
{# Phone #}
|
||||||
{% if user.phone %}
|
{% if user.phone %}
|
||||||
<span id="contact_info_phone">{{ user.phone }}</span>
|
<span class="contact_info_phone">{{ user.phone }}</span>
|
||||||
<br>
|
<br>
|
||||||
{% else %}
|
{% else %}
|
||||||
None<br>
|
None<br>
|
||||||
{% endif %}
|
{% 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 %}
|
{% elif not hide_no_contact_info_message %}
|
||||||
No additional contact information found.<br>
|
No additional contact information found.<br>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if user_verification_type and not skip_additional_contact_info %}
|
{% 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 %}
|
{% endif %}
|
||||||
</address>
|
</address>
|
||||||
|
|
|
@ -66,6 +66,14 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
||||||
No changelog to display.
|
No changelog to display.
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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" %}
|
{% elif field.field.name == "other_contacts" %}
|
||||||
{% if all_contacts.count > 2 %}
|
{% if all_contacts.count > 2 %}
|
||||||
<div class="readonly">
|
<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>
|
<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 %}
|
{% include "django/admin/includes/contact_detail_list.html" with user=original_object.senior_official no_title_top_padding=field.is_readonly %}
|
||||||
</div>
|
</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 %}
|
{% elif field.field.name == "other_contacts" and original_object.other_contacts.all %}
|
||||||
{% with all_contacts=original_object.other_contacts.all %}
|
{% with all_contacts=original_object.other_contacts.all %}
|
||||||
{% if all_contacts.count > 2 %}
|
{% if all_contacts.count > 2 %}
|
||||||
|
|
|
@ -6,21 +6,30 @@
|
||||||
{% block domain_content %}
|
{% block domain_content %}
|
||||||
<h1>Domain managers</h1>
|
<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>
|
<p>
|
||||||
Domain managers can update all information related to a domain within the
|
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>
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<ul class="usa-list">
|
<ul class="usa-list">
|
||||||
<li>There is no limit to the number of domain managers you can add.</li>
|
<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
|
<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>
|
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 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>
|
{% 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.</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.
|
||||||
|
{% if portfolio %} Add another domain manager before you remove yourself from this domain.{% endif %}</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{% if domain.permissions %}
|
{% if domain_manager_roles %}
|
||||||
<section class="section-outlined">
|
<section class="section-outlined">
|
||||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table">
|
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table">
|
||||||
<h2 class> Domain managers </h2>
|
<h2 class> Domain managers </h2>
|
||||||
|
@ -28,17 +37,18 @@
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th data-sortable scope="col" role="columnheader">Email</th>
|
<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>
|
<th class="grid-col-1" scope="col" role="columnheader"><span class="sr-only">Action</span></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for permission in domain.permissions.all %}
|
{% for item in domain_manager_roles %}
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row" role="rowheader" data-sort-value="{{ permission.user.email }}" data-label="Email">
|
<th scope="row" role="rowheader" data-sort-value="{{ item.permission.user.email }}" data-label="Email">
|
||||||
{{ permission.user.email }}
|
{{ item.permission.user.email }}
|
||||||
|
{% if item.has_admin_flag %}<span class="usa-tag margin-left-1 bg-primary">Admin</span>{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<td data-label="Role">{{ permission.role|title }}</td>
|
{% if not portfolio %}<td data-label="Role">{{ item.permission.role|title }}</td>{% endif %}
|
||||||
<td>
|
<td>
|
||||||
{% if can_delete_users %}
|
{% if can_delete_users %}
|
||||||
<a
|
<a
|
||||||
|
@ -52,7 +62,7 @@
|
||||||
Remove
|
Remove
|
||||||
</a>
|
</a>
|
||||||
{# Display a custom message if the user is trying to delete themselves #}
|
{# 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
|
<div
|
||||||
class="usa-modal"
|
class="usa-modal"
|
||||||
id="toggle-user-alert-{{ forloop.counter }}"
|
id="toggle-user-alert-{{ forloop.counter }}"
|
||||||
|
@ -60,7 +70,7 @@
|
||||||
aria-describedby="You will be removed from this domain"
|
aria-describedby="You will be removed from this domain"
|
||||||
data-force-action
|
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 %}
|
{% 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 %}
|
{% 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 %}
|
{% endwith %}
|
||||||
|
@ -71,11 +81,11 @@
|
||||||
class="usa-modal"
|
class="usa-modal"
|
||||||
id="toggle-user-alert-{{ forloop.counter }}"
|
id="toggle-user-alert-{{ forloop.counter }}"
|
||||||
aria-labelledby="Are you sure you want to continue?"
|
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
|
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 email=permission.user.email|default:permission.user|force_escape domain_name=domain.name|force_escape %}
|
{% 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 %}
|
{% 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 %}
|
{% endwith %}
|
||||||
</form>
|
</form>
|
||||||
|
@ -111,7 +121,7 @@
|
||||||
</a>
|
</a>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if domain.invitations.exists %}
|
{% if invitations %}
|
||||||
<section class="section-outlined">
|
<section class="section-outlined">
|
||||||
<h2>Invitations</h2>
|
<h2>Invitations</h2>
|
||||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table">
|
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table">
|
||||||
|
@ -120,21 +130,22 @@
|
||||||
<tr>
|
<tr>
|
||||||
<th data-sortable scope="col" role="columnheader">Email</th>
|
<th data-sortable scope="col" role="columnheader">Email</th>
|
||||||
<th data-sortable scope="col" role="columnheader">Date created</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>
|
<th class="grid-col-1" scope="col" role="columnheader"><span class="sr-only">Action</span></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for invitation in domain.invitations.all %}
|
{% for invitation in invitations %}
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row" role="rowheader" data-sort-value="{{ invitation.user.email }}" data-label="Email">
|
<th scope="row" role="rowheader" data-sort-value="{{ invitation.domain_invitation.user.email }}" data-label="Email">
|
||||||
{{ invitation.email }}
|
{{ invitation.domain_invitation.email }}
|
||||||
|
{% if invitation.has_admin_flag %}<span class="usa-tag margin-left-1 bg-primary">Admin</span>{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<td data-sort-value="{{ invitation.created_at|date:"U" }}" data-label="Date created">{{ invitation.created_at|date }} </td>
|
<td data-sort-value="{{ invitation.domain_invitation.created_at|date:"U" }}" data-label="Date created">{{ invitation.domain_invitation.created_at|date }} </td>
|
||||||
<td data-label="Status">{{ invitation.status|title }}</td>
|
{% if not portfolio %}<td data-label="Status">{{ invitation.domain_invitation.status|title }}</td>{% endif %}
|
||||||
<td>
|
<td>
|
||||||
{% if invitation.status == invitation.DomainInvitationStatus.INVITED %}
|
{% if invitation.domain_invitation.status == invitation.domain_invitation.DomainInvitationStatus.INVITED %}
|
||||||
<form method="POST" action="{% url "invitation-delete" pk=invitation.id %}">
|
<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">
|
{% csrf_token %}<input type="submit" class="usa-button--unstyled text-no-underline cursor-pointer" value="Cancel">
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -200,7 +200,7 @@ def is_domain_subpage(path):
|
||||||
"domain-users-add",
|
"domain-users-add",
|
||||||
"domain-request-delete",
|
"domain-request-delete",
|
||||||
"domain-user-delete",
|
"domain-user-delete",
|
||||||
"invitation-delete",
|
"invitation-cancel",
|
||||||
]
|
]
|
||||||
return get_url_name(path) in url_names
|
return get_url_name(path) in url_names
|
||||||
|
|
||||||
|
|
|
@ -1526,7 +1526,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields)
|
self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields)
|
||||||
|
|
||||||
# Test for the copy link
|
# 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
|
# Test that Creator counts display properly
|
||||||
self.assertNotContains(response, "Approved domains")
|
self.assertNotContains(response, "Approved domains")
|
||||||
|
@ -1626,6 +1626,17 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
readonly_fields = self.admin.get_readonly_fields(request, domain_request)
|
readonly_fields = self.admin.get_readonly_fields(request, domain_request)
|
||||||
|
|
||||||
expected_fields = [
|
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",
|
"other_contacts",
|
||||||
"current_websites",
|
"current_websites",
|
||||||
"alternative_domains",
|
"alternative_domains",
|
||||||
|
@ -1691,6 +1702,17 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
readonly_fields = self.admin.get_readonly_fields(request)
|
readonly_fields = self.admin.get_readonly_fields(request)
|
||||||
self.maxDiff = None
|
self.maxDiff = None
|
||||||
expected_fields = [
|
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",
|
"other_contacts",
|
||||||
"current_websites",
|
"current_websites",
|
||||||
"alternative_domains",
|
"alternative_domains",
|
||||||
|
@ -1723,6 +1745,17 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
readonly_fields = self.admin.get_readonly_fields(request)
|
readonly_fields = self.admin.get_readonly_fields(request)
|
||||||
|
|
||||||
expected_fields = [
|
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",
|
"other_contacts",
|
||||||
"current_websites",
|
"current_websites",
|
||||||
"alternative_domains",
|
"alternative_domains",
|
||||||
|
|
|
@ -2,7 +2,8 @@ from django.urls import reverse
|
||||||
from django.test import TestCase, Client
|
from django.test import TestCase, Client
|
||||||
from registrar.models import FederalAgency, SeniorOfficial, User, DomainRequest
|
from registrar.models import FederalAgency, SeniorOfficial, User, DomainRequest
|
||||||
from django.contrib.auth import get_user_model
|
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 api.tests.common import less_console_noise_decorator
|
||||||
from registrar.utility.constants import BranchChoices
|
from registrar.utility.constants import BranchChoices
|
||||||
|
@ -74,6 +75,79 @@ class GetSeniorOfficialJsonTest(TestCase):
|
||||||
self.assertEqual(data["error"], "Senior Official not found")
|
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):
|
class GetFederalPortfolioTypeJsonTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.client = Client()
|
self.client = Client()
|
||||||
|
|
|
@ -370,6 +370,17 @@ class TestDomainManagers(TestDomainOverview):
|
||||||
]
|
]
|
||||||
AllowedEmail.objects.bulk_create(allowed_emails)
|
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
|
@classmethod
|
||||||
def tearDownClass(cls):
|
def tearDownClass(cls):
|
||||||
super().tearDownClass()
|
super().tearDownClass()
|
||||||
|
@ -383,13 +394,22 @@ class TestDomainManagers(TestDomainOverview):
|
||||||
def test_domain_managers(self):
|
def test_domain_managers(self):
|
||||||
response = self.client.get(reverse("domain-users", kwargs={"pk": self.domain.id}))
|
response = self.client.get(reverse("domain-users", kwargs={"pk": self.domain.id}))
|
||||||
self.assertContains(response, "Domain managers")
|
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
|
@less_console_noise_decorator
|
||||||
def test_domain_managers_add_link(self):
|
@override_flag("organization_feature", active=True)
|
||||||
"""Button to get to user add page works."""
|
def test_domain_managers_portfolio_view(self):
|
||||||
management_page = self.app.get(reverse("domain-users", kwargs={"pk": self.domain.id}))
|
response = self.client.get(reverse("domain-users", kwargs={"pk": self.domain.id}))
|
||||||
add_page = management_page.click("Add a domain manager")
|
self.assertContains(response, "Domain managers")
|
||||||
self.assertContains(add_page, "Add a domain manager")
|
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
|
@less_console_noise_decorator
|
||||||
def test_domain_user_add(self):
|
def test_domain_user_add(self):
|
||||||
|
@ -706,21 +726,18 @@ class TestDomainManagers(TestDomainOverview):
|
||||||
"""Posting to the delete view deletes an invitation."""
|
"""Posting to the delete view deletes an invitation."""
|
||||||
email_address = "mayor@igorville.gov"
|
email_address = "mayor@igorville.gov"
|
||||||
invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address)
|
invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address)
|
||||||
mock_client = MockSESClient()
|
self.client.post(reverse("invitation-cancel", kwargs={"pk": invitation.id}))
|
||||||
with boto3_mocking.clients.handler_for("sesv2", mock_client):
|
invitation = DomainInvitation.objects.get(id=invitation.id)
|
||||||
self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id}))
|
self.assertEqual(invitation.status, DomainInvitation.DomainInvitationStatus.CANCELED)
|
||||||
mock_client.EMAILS_SENT.clear()
|
|
||||||
with self.assertRaises(DomainInvitation.DoesNotExist):
|
|
||||||
DomainInvitation.objects.get(id=invitation.id)
|
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
def test_domain_invitation_cancel_retrieved_invitation(self):
|
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"
|
email_address = "mayor@igorville.gov"
|
||||||
invitation, _ = DomainInvitation.objects.get_or_create(
|
invitation, _ = DomainInvitation.objects.get_or_create(
|
||||||
domain=self.domain, email=email_address, status=DomainInvitation.DomainInvitationStatus.RETRIEVED
|
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
|
# Assert that an error message is displayed to the user
|
||||||
self.assertContains(response, f"Invitation to {email_address} has already been retrieved.")
|
self.assertContains(response, f"Invitation to {email_address} has already been retrieved.")
|
||||||
# Assert that the Cancel link is not displayed
|
# Assert that the Cancel link is not displayed
|
||||||
|
@ -731,7 +748,7 @@ class TestDomainManagers(TestDomainOverview):
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
def test_domain_invitation_cancel_no_permissions(self):
|
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"
|
email_address = "mayor@igorville.gov"
|
||||||
invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address)
|
invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address)
|
||||||
|
|
||||||
|
@ -740,7 +757,7 @@ class TestDomainManagers(TestDomainOverview):
|
||||||
self.client.force_login(other_user)
|
self.client.force_login(other_user)
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
with boto3_mocking.clients.handler_for("sesv2", mock_client):
|
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)
|
self.assertEqual(result.status_code, 403)
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ from django.utils.html import format_html
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
from registrar.models.utility.generic_helper import value_of_attribute
|
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):
|
def get_action_needed_reason_default_email(domain_request, action_needed_reason):
|
||||||
|
@ -94,3 +95,26 @@ def get_field_links_as_list(
|
||||||
else:
|
else:
|
||||||
links = "".join(links)
|
links = "".join(links)
|
||||||
return format_html(f'<ul class="add-list-reset">{links}</ul>') if links else msg_for_none
|
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)
|
||||||
|
|
|
@ -11,7 +11,7 @@ from .domain import (
|
||||||
DomainSecurityEmailView,
|
DomainSecurityEmailView,
|
||||||
DomainUsersView,
|
DomainUsersView,
|
||||||
DomainAddUserView,
|
DomainAddUserView,
|
||||||
DomainInvitationDeleteView,
|
DomainInvitationCancelView,
|
||||||
DomainDeleteUserView,
|
DomainDeleteUserView,
|
||||||
)
|
)
|
||||||
from .user_profile import UserProfileView, FinishProfileSetupView
|
from .user_profile import UserProfileView, FinishProfileSetupView
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
Authorization is handled by the `DomainPermissionView`. To ensure that only
|
Authorization is handled by the `DomainPermissionView`. To ensure that only
|
||||||
authorized users can see information on a domain, every view here should
|
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
|
from datetime import date
|
||||||
|
@ -28,6 +28,7 @@ from registrar.models import (
|
||||||
UserPortfolioPermission,
|
UserPortfolioPermission,
|
||||||
PublicContact,
|
PublicContact,
|
||||||
)
|
)
|
||||||
|
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
||||||
from registrar.utility.enums import DefaultEmail
|
from registrar.utility.enums import DefaultEmail
|
||||||
from registrar.utility.errors import (
|
from registrar.utility.errors import (
|
||||||
GenericError,
|
GenericError,
|
||||||
|
@ -62,7 +63,7 @@ from epplibwrapper import (
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..utility.email import send_templated_email, EmailSendingError
|
from ..utility.email import send_templated_email, EmailSendingError
|
||||||
from .utility import DomainPermissionView, DomainInvitationPermissionDeleteView
|
from .utility import DomainPermissionView, DomainInvitationPermissionCancelView
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -841,11 +842,88 @@ class DomainUsersView(DomainBaseView):
|
||||||
# Add modal buttons to the context (such as for delete)
|
# Add modal buttons to the context (such as for delete)
|
||||||
context = self._add_modal_buttons_to_context(context)
|
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
|
# Get the email of the current user
|
||||||
context["current_user_email"] = self.request.user.email
|
context["current_user_email"] = self.request.user.email
|
||||||
|
|
||||||
return context
|
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):
|
def _add_booleans_to_context(self, context):
|
||||||
# Determine if the current user can delete managers
|
# Determine if the current user can delete managers
|
||||||
domain_pk = None
|
domain_pk = None
|
||||||
|
@ -909,6 +987,23 @@ class DomainAddUserView(DomainFormBaseView):
|
||||||
existing_org_invitation and existing_org_invitation.portfolio != requestor_org
|
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):
|
def _send_domain_invitation_email(self, email: str, requestor: User, requested_user=None, add_success=True):
|
||||||
"""Performs the sending of the domain invitation email,
|
"""Performs the sending of the domain invitation email,
|
||||||
does not make a domain information object
|
does not make a domain information object
|
||||||
|
@ -944,17 +1039,8 @@ class DomainAddUserView(DomainFormBaseView):
|
||||||
# Check to see if an invite has already been sent
|
# Check to see if an invite has already been sent
|
||||||
try:
|
try:
|
||||||
invite = DomainInvitation.objects.get(email=email, domain=self.object)
|
invite = DomainInvitation.objects.get(email=email, domain=self.object)
|
||||||
# check if the invite has already been accepted
|
# check if the invite has already been accepted or has a canceled invite
|
||||||
if invite.status == DomainInvitation.DomainInvitationStatus.RETRIEVED:
|
add_success = self._check_invite_status(invite, email)
|
||||||
add_success = False
|
|
||||||
messages.warning(
|
|
||||||
self.request,
|
|
||||||
f"{email} is already a manager for this domain.",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
add_success = False
|
|
||||||
# else if it has been sent but not accepted
|
|
||||||
messages.warning(self.request, f"{email} has already been invited to this domain")
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.error("An error occured")
|
logger.error("An error occured")
|
||||||
|
|
||||||
|
@ -976,6 +1062,7 @@ class DomainAddUserView(DomainFormBaseView):
|
||||||
self.object,
|
self.object,
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
|
logger.info(exc)
|
||||||
raise EmailSendingError("Could not send email invitation.") from exc
|
raise EmailSendingError("Could not send email invitation.") from exc
|
||||||
else:
|
else:
|
||||||
if add_success:
|
if add_success:
|
||||||
|
@ -1051,11 +1138,9 @@ class DomainAddUserView(DomainFormBaseView):
|
||||||
return redirect(self.get_success_url())
|
return redirect(self.get_success_url())
|
||||||
|
|
||||||
|
|
||||||
# The order of the superclasses matters here. BaseDeleteView has a bug where the
|
class DomainInvitationCancelView(SuccessMessageMixin, DomainInvitationPermissionCancelView):
|
||||||
# "form_valid" function does not call super, so it cannot use SuccessMessageMixin.
|
object: DomainInvitation
|
||||||
# The workaround is to use SuccessMessageMixin first.
|
fields = []
|
||||||
class DomainInvitationDeleteView(SuccessMessageMixin, DomainInvitationPermissionDeleteView):
|
|
||||||
object: DomainInvitation # workaround for type mismatch in DeleteView
|
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
"""Override post method in order to error in the case when the
|
"""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()
|
self.object = self.get_object()
|
||||||
form = self.get_form()
|
form = self.get_form()
|
||||||
if form.is_valid() and self.object.status == self.object.DomainInvitationStatus.INVITED:
|
if form.is_valid() and self.object.status == self.object.DomainInvitationStatus.INVITED:
|
||||||
|
self.object.cancel_invitation()
|
||||||
|
self.object.save()
|
||||||
return self.form_valid(form)
|
return self.form_valid(form)
|
||||||
else:
|
else:
|
||||||
# Produce an error message if the domain invatation status is RETRIEVED
|
# Produce an error message if the domain invatation status is RETRIEVED
|
||||||
|
|
|
@ -5,9 +5,9 @@ from .permission_views import (
|
||||||
DomainPermissionView,
|
DomainPermissionView,
|
||||||
DomainRequestPermissionView,
|
DomainRequestPermissionView,
|
||||||
DomainRequestPermissionWithdrawView,
|
DomainRequestPermissionWithdrawView,
|
||||||
DomainInvitationPermissionDeleteView,
|
|
||||||
DomainRequestWizardPermissionView,
|
DomainRequestWizardPermissionView,
|
||||||
PortfolioMembersPermission,
|
PortfolioMembersPermission,
|
||||||
DomainRequestPortfolioViewonlyView,
|
DomainRequestPortfolioViewonlyView,
|
||||||
|
DomainInvitationPermissionCancelView,
|
||||||
)
|
)
|
||||||
from .api_views import get_senior_official_from_federal_agency_json
|
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)
|
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
|
@login_required
|
||||||
@staff_member_required
|
@staff_member_required
|
||||||
def get_federal_and_portfolio_types_from_federal_agency_json(request):
|
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
|
id=self.kwargs["pk"], domain__permissions__user=self.request.user
|
||||||
).exists():
|
).exists():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import abc # abstract base class
|
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 import Domain, DomainRequest, DomainInvitation, Portfolio
|
||||||
from registrar.models.user import User
|
from registrar.models.user import User
|
||||||
from registrar.models.user_domain_role import UserDomainRole
|
from registrar.models.user_domain_role import UserDomainRole
|
||||||
|
@ -156,17 +156,11 @@ class DomainRequestWizardPermissionView(DomainRequestWizardPermission, TemplateV
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
class DomainInvitationPermissionDeleteView(DomainInvitationPermission, DeleteView, abc.ABC):
|
class DomainInvitationPermissionCancelView(DomainInvitationPermission, UpdateView, abc.ABC):
|
||||||
"""Abstract view for deleting a domain invitation.
|
"""Abstract view for cancelling a DomainInvitation."""
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
model = DomainInvitation
|
model = DomainInvitation
|
||||||
object: DomainInvitation # workaround for type mismatch in DeleteView
|
object: DomainInvitation
|
||||||
|
|
||||||
|
|
||||||
class DomainRequestPermissionDeleteView(DomainRequestPermission, DeleteView, abc.ABC):
|
class DomainRequestPermissionDeleteView(DomainRequestPermission, DeleteView, abc.ABC):
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue