mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-26 04:28:39 +02:00
Merge branch 'main' into rjm/3400-search-ui
This commit is contained in:
commit
1401eeba82
84 changed files with 2971 additions and 441 deletions
61
.github/ISSUE_TEMPLATE/story.yml
vendored
61
.github/ISSUE_TEMPLATE/story.yml
vendored
|
@ -1,61 +0,0 @@
|
||||||
name: Story
|
|
||||||
description: Capture actionable sprint work
|
|
||||||
labels: ["story"]
|
|
||||||
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
id: help
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
> **Note**
|
|
||||||
> GitHub Issues use [GitHub Flavored Markdown](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax) for formatting.
|
|
||||||
- type: textarea
|
|
||||||
id: story
|
|
||||||
attributes:
|
|
||||||
label: Story
|
|
||||||
description: |
|
|
||||||
Please add the "as a, I want, so that" details that describe the story.
|
|
||||||
If more than one "as a, I want, so that" describes the story, add multiple.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
As an analyst
|
|
||||||
I want the ability to approve a domain request
|
|
||||||
so that a request can be fulfilled and a new .gov domain can be provisioned
|
|
||||||
value: |
|
|
||||||
As a
|
|
||||||
I want
|
|
||||||
so that
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: acceptance-criteria
|
|
||||||
attributes:
|
|
||||||
label: Acceptance Criteria
|
|
||||||
description: |
|
|
||||||
Please add the acceptance criteria that best describe the desired outcomes when this work is completed
|
|
||||||
|
|
||||||
Example:
|
|
||||||
- Application sends an email when analysts approve domain requests
|
|
||||||
- Domain request status is "approved"
|
|
||||||
|
|
||||||
Example ("given, when, then" format):
|
|
||||||
Given that I am an analyst who has finished reviewing a domain request
|
|
||||||
When I click to approve a domain request
|
|
||||||
Then the domain provisioning process should be initiated, and the applicant should receive an email update.
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: additional-context
|
|
||||||
attributes:
|
|
||||||
label: Additional Context
|
|
||||||
description: "Please include additional references (screenshots, design links, documentation, etc.) that are relevant"
|
|
||||||
- type: textarea
|
|
||||||
id: issue-links
|
|
||||||
attributes:
|
|
||||||
label: Issue Links
|
|
||||||
description: |
|
|
||||||
What other issues does this story relate to and how?
|
|
||||||
|
|
||||||
Example:
|
|
||||||
- 🚧 Blocked by: #123
|
|
||||||
- 🔄 Relates to: #234
|
|
90
.github/workflows/delete-and-recreate-db.yaml
vendored
Normal file
90
.github/workflows/delete-and-recreate-db.yaml
vendored
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
# This workflow can be run from the CLI
|
||||||
|
# gh workflow run reset-db.yaml -f environment=ENVIRONMENT
|
||||||
|
|
||||||
|
name: Reset database
|
||||||
|
run-name: Reset database for ${{ github.event.inputs.environment }}
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
environment:
|
||||||
|
type: choice
|
||||||
|
description: Which environment should we flush and re-load data for?
|
||||||
|
options:
|
||||||
|
- el
|
||||||
|
- ad
|
||||||
|
- ms
|
||||||
|
- ag
|
||||||
|
- litterbox
|
||||||
|
- hotgov
|
||||||
|
- cb
|
||||||
|
- bob
|
||||||
|
- meoward
|
||||||
|
- backup
|
||||||
|
- ky
|
||||||
|
- es
|
||||||
|
- nl
|
||||||
|
- rh
|
||||||
|
- za
|
||||||
|
- gd
|
||||||
|
- rb
|
||||||
|
- ko
|
||||||
|
- ab
|
||||||
|
- rjm
|
||||||
|
- dk
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
reset-db:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
CF_USERNAME: CF_${{ github.event.inputs.environment }}_USERNAME
|
||||||
|
CF_PASSWORD: CF_${{ github.event.inputs.environment }}_PASSWORD
|
||||||
|
DESTINATION_ENVIRONMENT: ${{ github.event.inputs.environment}}
|
||||||
|
steps:
|
||||||
|
- name: Delete and Recreate Database
|
||||||
|
env:
|
||||||
|
cf_username: ${{ secrets[env.CF_USERNAME] }}
|
||||||
|
cf_password: ${{ secrets[env.CF_PASSWORD] }}
|
||||||
|
run: |
|
||||||
|
# install cf cli and other tools
|
||||||
|
wget -q -O - https://packages.cloudfoundry.org/debian/cli.cloudfoundry.org.key | sudo gpg --dearmor -o /usr/share/keyrings/cli.cloudfoundry.org.gpg
|
||||||
|
echo "deb [signed-by=/usr/share/keyrings/cli.cloudfoundry.org.gpg] https://packages.cloudfoundry.org/debian stable main" | sudo tee /etc/apt/sources.list.d/cloudfoundry-cli.list
|
||||||
|
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install cf8-cli
|
||||||
|
cf api api.fr.cloud.gov
|
||||||
|
cf auth "$CF_USERNAME" "$CF_PASSWORD"
|
||||||
|
cf target -o cisa-dotgov -s $DESTINATION_ENVIRONMENT
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# unbind the service
|
||||||
|
cf unbind-service getgov-$DESTINATION_ENVIRONMENT getgov-$DESTINATION_ENVIRONMENT-database
|
||||||
|
#delete the service key
|
||||||
|
yes Y | cf delete-service-key getgov-$DESTINATION_ENVIRONMENT-database SERVICE_CONNECT
|
||||||
|
# delete the service
|
||||||
|
yes Y | cf delete-service getgov-$DESTINATION_ENVIRONMENT-database
|
||||||
|
# create it again
|
||||||
|
cf create-service aws-rds micro-psql getgov-$DESTINATION_ENVIRONMENT-database
|
||||||
|
# wait for it be created (up to 5 mins)
|
||||||
|
# this checks the creation cf service getgov-$DESTINATION_ENVIRONMENT-database
|
||||||
|
# the below command with check “status” line using cf service command mentioned above. if it says “create in progress” it will keep waiting otherwise the next steps fail
|
||||||
|
|
||||||
|
timeout 480 bash -c "until cf service getgov-$DESTINATION_ENVIRONMENT-database | grep -q 'The service instance status is succeeded'
|
||||||
|
do
|
||||||
|
echo 'Database not up yet, waiting...'
|
||||||
|
sleep 30
|
||||||
|
done"
|
||||||
|
|
||||||
|
# rebind the service
|
||||||
|
cf bind-service getgov-$DESTINATION_ENVIRONMENT getgov-$DESTINATION_ENVIRONMENT-database
|
||||||
|
#restage the app or it will not connect to the database right for the next commands
|
||||||
|
cf restage getgov-$DESTINATION_ENVIRONMENT
|
||||||
|
# wait for the above command to finish
|
||||||
|
# if it is taking way to long and the annoying “instance starting” line that keeps repeating, then run following two commands in a separate window. This will interrupt the death loop where it keeps hitting an error with it failing health checks
|
||||||
|
# create the cache table and run migrations
|
||||||
|
cf run-task getgov-$DESTINATION_ENVIRONMENT --command 'python manage.py createcachetable' --name createcachetable
|
||||||
|
cf run-task getgov-$DESTINATION_ENVIRONMENT --wait --command 'python manage.py migrate' --name migrate
|
||||||
|
|
||||||
|
# load fixtures
|
||||||
|
cf run-task getgov-$DESTINATION_ENVIRONMENT --wait --command 'python manage.py load' --name loaddata
|
7
docs/developer/workflows/README.md
Normal file
7
docs/developer/workflows/README.md
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
# Workflows Docs
|
||||||
|
|
||||||
|
========================
|
||||||
|
|
||||||
|
This directory contains files related to workflows
|
||||||
|
|
||||||
|
Delete And Recreate Database is in [docs/ops](../workflows/delete-and-recreate-db.md/).
|
13
docs/developer/workflows/delete-and-recreate-db.md
Normal file
13
docs/developer/workflows/delete-and-recreate-db.md
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
## Delete And Recreate Database
|
||||||
|
|
||||||
|
This script destroys and recreates a database. This is another troubleshooting tool for issues with the database.
|
||||||
|
|
||||||
|
1. unbinds the database
|
||||||
|
2. deletes it
|
||||||
|
3. recreates it
|
||||||
|
4. binds it back to the sandbox
|
||||||
|
5. runs migrations
|
||||||
|
|
||||||
|
Addition Info in this slack thread:
|
||||||
|
|
||||||
|
- [Slack thread](https://cisa-corp.slack.com/archives/C05BGB4L5NF/p1725495150772119)
|
|
@ -914,7 +914,8 @@ Example (only requests): `./manage.py create_federal_portfolio --branch "executi
|
||||||
| 3 | **both** | If True, runs parse_requests and parse_domains. |
|
| 3 | **both** | If True, runs parse_requests and parse_domains. |
|
||||||
| 4 | **parse_requests** | If True, then the created portfolio is added to all related DomainRequests. |
|
| 4 | **parse_requests** | If True, then the created portfolio is added to all related DomainRequests. |
|
||||||
| 5 | **parse_domains** | If True, then the created portfolio is added to all related Domains. |
|
| 5 | **parse_domains** | If True, then the created portfolio is added to all related Domains. |
|
||||||
| 6 | **skip_existing_portfolios** | If True, then the script will only create suborganizations, modify DomainRequest, and modify DomainInformation records only when creating a new portfolio. Use this flag when you do not want to modify existing records. |
|
| 6 | **add_managers** | If True, then the created portfolio will add all managers of the portfolio domains as members of the portfolio, including invited managers. |
|
||||||
|
| 7 | **skip_existing_portfolios** | If True, then the script will only create suborganizations, modify DomainRequest, and modify DomainInformation records only when creating a new portfolio. Use this flag when you do not want to modify existing records. |
|
||||||
|
|
||||||
- Parameters #1-#2: Either `--agency_name` or `--branch` must be specified. Not both.
|
- Parameters #1-#2: Either `--agency_name` or `--branch` must be specified. Not both.
|
||||||
- Parameters #2-#3, you cannot use `--both` while using these. You must specify either `--parse_requests` or `--parse_domains` seperately. While all of these parameters are optional in that you do not need to specify all of them,
|
- Parameters #2-#3, you cannot use `--both` while using these. You must specify either `--parse_requests` or `--parse_domains` seperately. While all of these parameters are optional in that you do not need to specify all of them,
|
||||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
||||||
DJANGO_BASE_URL: https://getgov-ab.app.cloud.gov
|
DJANGO_BASE_URL: https://getgov-ab.app.cloud.gov
|
||||||
# Tell Django how much stuff to log
|
# Tell Django how much stuff to log
|
||||||
DJANGO_LOG_LEVEL: INFO
|
DJANGO_LOG_LEVEL: INFO
|
||||||
|
# tell django what log format to use: console or json. See settings.py for more details.
|
||||||
|
DJANGO_LOG_FORMAT: console
|
||||||
# default public site location
|
# default public site location
|
||||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||||
# Flag to disable/enable features in prod environments
|
# Flag to disable/enable features in prod environments
|
||||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
||||||
DJANGO_BASE_URL: https://getgov-ad.app.cloud.gov
|
DJANGO_BASE_URL: https://getgov-ad.app.cloud.gov
|
||||||
# Tell Django how much stuff to log
|
# Tell Django how much stuff to log
|
||||||
DJANGO_LOG_LEVEL: INFO
|
DJANGO_LOG_LEVEL: INFO
|
||||||
|
# tell django what log format to use: console or json. See settings.py for more details.
|
||||||
|
DJANGO_LOG_FORMAT: console
|
||||||
# default public site location
|
# default public site location
|
||||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||||
# Flag to disable/enable features in prod environments
|
# Flag to disable/enable features in prod environments
|
||||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
||||||
DJANGO_BASE_URL: https://getgov-ag.app.cloud.gov
|
DJANGO_BASE_URL: https://getgov-ag.app.cloud.gov
|
||||||
# Tell Django how much stuff to log
|
# Tell Django how much stuff to log
|
||||||
DJANGO_LOG_LEVEL: INFO
|
DJANGO_LOG_LEVEL: INFO
|
||||||
|
# tell django what log format to use: console or json. See settings.py for more details.
|
||||||
|
DJANGO_LOG_FORMAT: console
|
||||||
# default public site location
|
# default public site location
|
||||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||||
# Flag to disable/enable features in prod environments
|
# Flag to disable/enable features in prod environments
|
||||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
||||||
DJANGO_BASE_URL: https://getgov-backup.app.cloud.gov
|
DJANGO_BASE_URL: https://getgov-backup.app.cloud.gov
|
||||||
# Tell Django how much stuff to log
|
# Tell Django how much stuff to log
|
||||||
DJANGO_LOG_LEVEL: INFO
|
DJANGO_LOG_LEVEL: INFO
|
||||||
|
# tell django what log format to use: console or json. See settings.py for more details.
|
||||||
|
DJANGO_LOG_FORMAT: console
|
||||||
# default public site location
|
# default public site location
|
||||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||||
# Flag to disable/enable features in prod environments
|
# Flag to disable/enable features in prod environments
|
||||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
||||||
DJANGO_BASE_URL: https://getgov-bob.app.cloud.gov
|
DJANGO_BASE_URL: https://getgov-bob.app.cloud.gov
|
||||||
# Tell Django how much stuff to log
|
# Tell Django how much stuff to log
|
||||||
DJANGO_LOG_LEVEL: INFO
|
DJANGO_LOG_LEVEL: INFO
|
||||||
|
# tell django what log format to use: console or json. See settings.py for more details.
|
||||||
|
DJANGO_LOG_FORMAT: console
|
||||||
# default public site location
|
# default public site location
|
||||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||||
# Flag to disable/enable features in prod environments
|
# Flag to disable/enable features in prod environments
|
||||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
||||||
DJANGO_BASE_URL: https://getgov-cb.app.cloud.gov
|
DJANGO_BASE_URL: https://getgov-cb.app.cloud.gov
|
||||||
# Tell Django how much stuff to log
|
# Tell Django how much stuff to log
|
||||||
DJANGO_LOG_LEVEL: INFO
|
DJANGO_LOG_LEVEL: INFO
|
||||||
|
# tell django what log format to use: console or json. See settings.py for more details.
|
||||||
|
DJANGO_LOG_FORMAT: console
|
||||||
# default public site location
|
# default public site location
|
||||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||||
# Flag to disable/enable features in prod environments
|
# Flag to disable/enable features in prod environments
|
||||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
||||||
DJANGO_BASE_URL: https://getgov-development.app.cloud.gov
|
DJANGO_BASE_URL: https://getgov-development.app.cloud.gov
|
||||||
# Tell Django how much stuff to log
|
# Tell Django how much stuff to log
|
||||||
DJANGO_LOG_LEVEL: INFO
|
DJANGO_LOG_LEVEL: INFO
|
||||||
|
# tell django what log format to use: console or json. See settings.py for more details.
|
||||||
|
DJANGO_LOG_FORMAT: console
|
||||||
# default public site location
|
# default public site location
|
||||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||||
# Flag to disable/enable features in prod environments
|
# Flag to disable/enable features in prod environments
|
||||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
||||||
DJANGO_BASE_URL: https://getgov-dk.app.cloud.gov
|
DJANGO_BASE_URL: https://getgov-dk.app.cloud.gov
|
||||||
# Tell Django how much stuff to log
|
# Tell Django how much stuff to log
|
||||||
DJANGO_LOG_LEVEL: INFO
|
DJANGO_LOG_LEVEL: INFO
|
||||||
|
# tell django what log format to use: console or json. See settings.py for more details.
|
||||||
|
DJANGO_LOG_FORMAT: console
|
||||||
# default public site location
|
# default public site location
|
||||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||||
# Flag to disable/enable features in prod environments
|
# Flag to disable/enable features in prod environments
|
||||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
||||||
DJANGO_BASE_URL: https://getgov-el.app.cloud.gov
|
DJANGO_BASE_URL: https://getgov-el.app.cloud.gov
|
||||||
# Tell Django how much stuff to log
|
# Tell Django how much stuff to log
|
||||||
DJANGO_LOG_LEVEL: INFO
|
DJANGO_LOG_LEVEL: INFO
|
||||||
|
# tell django what log format to use: console or json. See settings.py for more details.
|
||||||
|
DJANGO_LOG_FORMAT: console
|
||||||
# default public site location
|
# default public site location
|
||||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||||
# Flag to disable/enable features in prod environments
|
# Flag to disable/enable features in prod environments
|
||||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
||||||
DJANGO_BASE_URL: https://getgov-es.app.cloud.gov
|
DJANGO_BASE_URL: https://getgov-es.app.cloud.gov
|
||||||
# Tell Django how much stuff to log
|
# Tell Django how much stuff to log
|
||||||
DJANGO_LOG_LEVEL: INFO
|
DJANGO_LOG_LEVEL: INFO
|
||||||
|
# tell django what log format to use: console or json. See settings.py for more details.
|
||||||
|
DJANGO_LOG_FORMAT: console
|
||||||
# default public site location
|
# default public site location
|
||||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||||
# Flag to disable/enable features in prod environments
|
# Flag to disable/enable features in prod environments
|
||||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
||||||
DJANGO_BASE_URL: https://getgov-gd.app.cloud.gov
|
DJANGO_BASE_URL: https://getgov-gd.app.cloud.gov
|
||||||
# Tell Django how much stuff to log
|
# Tell Django how much stuff to log
|
||||||
DJANGO_LOG_LEVEL: INFO
|
DJANGO_LOG_LEVEL: INFO
|
||||||
|
# tell django what log format to use: console or json. See settings.py for more details.
|
||||||
|
DJANGO_LOG_FORMAT: console
|
||||||
# default public site location
|
# default public site location
|
||||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||||
# Flag to disable/enable features in prod environments
|
# Flag to disable/enable features in prod environments
|
||||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
||||||
DJANGO_BASE_URL: https://getgov-hotgov.app.cloud.gov
|
DJANGO_BASE_URL: https://getgov-hotgov.app.cloud.gov
|
||||||
# Tell Django how much stuff to log
|
# Tell Django how much stuff to log
|
||||||
DJANGO_LOG_LEVEL: INFO
|
DJANGO_LOG_LEVEL: INFO
|
||||||
|
# tell django what log format to use: console or json. See settings.py for more details.
|
||||||
|
DJANGO_LOG_FORMAT: console
|
||||||
# default public site location
|
# default public site location
|
||||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||||
# Flag to disable/enable features in prod environments
|
# Flag to disable/enable features in prod environments
|
||||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
||||||
DJANGO_BASE_URL: https://getgov-ko.app.cloud.gov
|
DJANGO_BASE_URL: https://getgov-ko.app.cloud.gov
|
||||||
# Tell Django how much stuff to log
|
# Tell Django how much stuff to log
|
||||||
DJANGO_LOG_LEVEL: INFO
|
DJANGO_LOG_LEVEL: INFO
|
||||||
|
# tell django what log format to use: console or json. See settings.py for more details.
|
||||||
|
DJANGO_LOG_FORMAT: console
|
||||||
# default public site location
|
# default public site location
|
||||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||||
# Flag to disable/enable features in prod environments
|
# Flag to disable/enable features in prod environments
|
||||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
||||||
DJANGO_BASE_URL: https://getgov-ky.app.cloud.gov
|
DJANGO_BASE_URL: https://getgov-ky.app.cloud.gov
|
||||||
# Tell Django how much stuff to log
|
# Tell Django how much stuff to log
|
||||||
DJANGO_LOG_LEVEL: INFO
|
DJANGO_LOG_LEVEL: INFO
|
||||||
|
# tell django what log format to use: console or json. See settings.py for more details.
|
||||||
|
DJANGO_LOG_FORMAT: console
|
||||||
# default public site location
|
# default public site location
|
||||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||||
# Flag to disable/enable features in prod environments
|
# Flag to disable/enable features in prod environments
|
||||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
||||||
DJANGO_BASE_URL: https://getgov-litterbox.app.cloud.gov
|
DJANGO_BASE_URL: https://getgov-litterbox.app.cloud.gov
|
||||||
# Tell Django how much stuff to log
|
# Tell Django how much stuff to log
|
||||||
DJANGO_LOG_LEVEL: INFO
|
DJANGO_LOG_LEVEL: INFO
|
||||||
|
# tell django what log format to use: console or json. See settings.py for more details.
|
||||||
|
DJANGO_LOG_FORMAT: console
|
||||||
# default public site location
|
# default public site location
|
||||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||||
# Flag to disable/enable features in prod environments
|
# Flag to disable/enable features in prod environments
|
||||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
||||||
DJANGO_BASE_URL: https://getgov-meoward.app.cloud.gov
|
DJANGO_BASE_URL: https://getgov-meoward.app.cloud.gov
|
||||||
# Tell Django how much stuff to log
|
# Tell Django how much stuff to log
|
||||||
DJANGO_LOG_LEVEL: INFO
|
DJANGO_LOG_LEVEL: INFO
|
||||||
|
# tell django what log format to use: console or json. See settings.py for more details.
|
||||||
|
DJANGO_LOG_FORMAT: console
|
||||||
# default public site location
|
# default public site location
|
||||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||||
# Flag to disable/enable features in prod environments
|
# Flag to disable/enable features in prod environments
|
||||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
||||||
DJANGO_BASE_URL: https://getgov-ms.app.cloud.gov
|
DJANGO_BASE_URL: https://getgov-ms.app.cloud.gov
|
||||||
# Tell Django how much stuff to log
|
# Tell Django how much stuff to log
|
||||||
DJANGO_LOG_LEVEL: DEBUG
|
DJANGO_LOG_LEVEL: DEBUG
|
||||||
|
# tell django what log format to use: console or json. See settings.py for more details.
|
||||||
|
DJANGO_LOG_FORMAT: console
|
||||||
# default public site location
|
# default public site location
|
||||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||||
# Flag to disable/enable features in prod environments
|
# Flag to disable/enable features in prod environments
|
||||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
||||||
DJANGO_BASE_URL: https://getgov-nl.app.cloud.gov
|
DJANGO_BASE_URL: https://getgov-nl.app.cloud.gov
|
||||||
# Tell Django how much stuff to log
|
# Tell Django how much stuff to log
|
||||||
DJANGO_LOG_LEVEL: INFO
|
DJANGO_LOG_LEVEL: INFO
|
||||||
|
# tell django what log format to use: console or json. See settings.py for more details.
|
||||||
|
DJANGO_LOG_FORMAT: console
|
||||||
# default public site location
|
# default public site location
|
||||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||||
# Flag to disable/enable features in prod environments
|
# Flag to disable/enable features in prod environments
|
||||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
||||||
DJANGO_BASE_URL: https://getgov-rb.app.cloud.gov
|
DJANGO_BASE_URL: https://getgov-rb.app.cloud.gov
|
||||||
# Tell Django how much stuff to log
|
# Tell Django how much stuff to log
|
||||||
DJANGO_LOG_LEVEL: INFO
|
DJANGO_LOG_LEVEL: INFO
|
||||||
|
# tell django what log format to use: console or json. See settings.py for more details.
|
||||||
|
DJANGO_LOG_FORMAT: console
|
||||||
# default public site location
|
# default public site location
|
||||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||||
# Flag to disable/enable features in prod environments
|
# Flag to disable/enable features in prod environments
|
||||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
||||||
DJANGO_BASE_URL: https://getgov-rh.app.cloud.gov
|
DJANGO_BASE_URL: https://getgov-rh.app.cloud.gov
|
||||||
# Tell Django how much stuff to log
|
# Tell Django how much stuff to log
|
||||||
DJANGO_LOG_LEVEL: INFO
|
DJANGO_LOG_LEVEL: INFO
|
||||||
|
# tell django what log format to use: console or json. See settings.py for more details.
|
||||||
|
DJANGO_LOG_FORMAT: console
|
||||||
# default public site location
|
# default public site location
|
||||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||||
# Flag to disable/enable features in prod environments
|
# Flag to disable/enable features in prod environments
|
||||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
||||||
DJANGO_BASE_URL: https://getgov-rjm.app.cloud.gov
|
DJANGO_BASE_URL: https://getgov-rjm.app.cloud.gov
|
||||||
# Tell Django how much stuff to log
|
# Tell Django how much stuff to log
|
||||||
DJANGO_LOG_LEVEL: INFO
|
DJANGO_LOG_LEVEL: INFO
|
||||||
|
# tell django what log format to use: console or json. See settings.py for more details.
|
||||||
|
DJANGO_LOG_FORMAT: console
|
||||||
# default public site location
|
# default public site location
|
||||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||||
# Flag to disable/enable features in prod environments
|
# Flag to disable/enable features in prod environments
|
||||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
||||||
DJANGO_BASE_URL: https://manage.get.gov
|
DJANGO_BASE_URL: https://manage.get.gov
|
||||||
# Tell Django how much stuff to log
|
# Tell Django how much stuff to log
|
||||||
DJANGO_LOG_LEVEL: INFO
|
DJANGO_LOG_LEVEL: INFO
|
||||||
|
# tell django what log format to use: console or json. See settings.py for more details.
|
||||||
|
DJANGO_LOG_FORMAT: json
|
||||||
# default public site location
|
# default public site location
|
||||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||||
# Which OIDC provider to use
|
# Which OIDC provider to use
|
||||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
||||||
DJANGO_BASE_URL: https://getgov-staging.app.cloud.gov
|
DJANGO_BASE_URL: https://getgov-staging.app.cloud.gov
|
||||||
# Tell Django how much stuff to log
|
# Tell Django how much stuff to log
|
||||||
DJANGO_LOG_LEVEL: INFO
|
DJANGO_LOG_LEVEL: INFO
|
||||||
|
# tell django what log format to use: console or json. See settings.py for more details.
|
||||||
|
DJANGO_LOG_FORMAT: json
|
||||||
# default public site location
|
# default public site location
|
||||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||||
# Flag to disable/enable features in prod environments
|
# Flag to disable/enable features in prod environments
|
||||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
||||||
DJANGO_BASE_URL: https://getgov-ENVIRONMENT.app.cloud.gov
|
DJANGO_BASE_URL: https://getgov-ENVIRONMENT.app.cloud.gov
|
||||||
# Tell Django how much stuff to log
|
# Tell Django how much stuff to log
|
||||||
DJANGO_LOG_LEVEL: INFO
|
DJANGO_LOG_LEVEL: INFO
|
||||||
|
# tell django what log format to use: console or json. See settings.py for more details.
|
||||||
|
DJANGO_LOG_FORMAT: console
|
||||||
# default public site location
|
# default public site location
|
||||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||||
# Flag to disable/enable features in prod environments
|
# Flag to disable/enable features in prod environments
|
||||||
|
|
|
@ -79,6 +79,8 @@ services:
|
||||||
- POSTGRES_DB=app
|
- POSTGRES_DB=app
|
||||||
- POSTGRES_USER=user
|
- POSTGRES_USER=user
|
||||||
- POSTGRES_PASSWORD=feedabee
|
- POSTGRES_PASSWORD=feedabee
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
|
||||||
node:
|
node:
|
||||||
build:
|
build:
|
||||||
|
|
|
@ -28,7 +28,11 @@ from django.shortcuts import redirect
|
||||||
from django_fsm import get_available_FIELD_transitions, FSMField
|
from django_fsm import get_available_FIELD_transitions, FSMField
|
||||||
from registrar.models import DomainInformation, Portfolio, UserPortfolioPermission, DomainInvitation
|
from registrar.models import DomainInformation, Portfolio, UserPortfolioPermission, DomainInvitation
|
||||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||||
from registrar.utility.email_invitations import send_domain_invitation_email, send_portfolio_invitation_email
|
from registrar.utility.email_invitations import (
|
||||||
|
send_domain_invitation_email,
|
||||||
|
send_portfolio_admin_addition_emails,
|
||||||
|
send_portfolio_invitation_email,
|
||||||
|
)
|
||||||
from registrar.views.utility.invitation_helper import (
|
from registrar.views.utility.invitation_helper import (
|
||||||
get_org_membership,
|
get_org_membership,
|
||||||
get_requested_user,
|
get_requested_user,
|
||||||
|
@ -1377,9 +1381,13 @@ class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
|
|
||||||
change_form_template = "django/admin/user_domain_role_change_form.html"
|
change_form_template = "django/admin/user_domain_role_change_form.html"
|
||||||
|
|
||||||
|
# Override for the delete confirmation page on the domain table (bulk delete action)
|
||||||
|
delete_selected_confirmation_template = "django/admin/user_domain_role_delete_selected_confirmation.html"
|
||||||
|
|
||||||
# Fixes a bug where non-superusers are redirected to the main page
|
# Fixes a bug where non-superusers are redirected to the main page
|
||||||
def delete_view(self, request, object_id, extra_context=None):
|
def delete_view(self, request, object_id, extra_context=None):
|
||||||
"""Custom delete_view implementation that specifies redirect behaviour"""
|
"""Custom delete_view implementation that specifies redirect behaviour"""
|
||||||
|
self.delete_confirmation_template = "django/admin/user_domain_role_delete_confirmation.html"
|
||||||
response = super().delete_view(request, object_id, extra_context)
|
response = super().delete_view(request, object_id, extra_context)
|
||||||
|
|
||||||
if isinstance(response, HttpResponseRedirect) and not request.user.has_perm("registrar.full_access_permission"):
|
if isinstance(response, HttpResponseRedirect) and not request.user.has_perm("registrar.full_access_permission"):
|
||||||
|
@ -1514,6 +1522,8 @@ class DomainInvitationAdmin(BaseInvitationAdmin):
|
||||||
autocomplete_fields = ["domain"]
|
autocomplete_fields = ["domain"]
|
||||||
|
|
||||||
change_form_template = "django/admin/domain_invitation_change_form.html"
|
change_form_template = "django/admin/domain_invitation_change_form.html"
|
||||||
|
# Override for the delete confirmation page on the domain table (bulk delete action)
|
||||||
|
delete_selected_confirmation_template = "django/admin/domain_invitation_delete_selected_confirmation.html"
|
||||||
|
|
||||||
# Select domain invitations to change -> Domain invitations
|
# Select domain invitations to change -> Domain invitations
|
||||||
def changelist_view(self, request, extra_context=None):
|
def changelist_view(self, request, extra_context=None):
|
||||||
|
@ -1523,6 +1533,16 @@ class DomainInvitationAdmin(BaseInvitationAdmin):
|
||||||
# Get the filtered values
|
# Get the filtered values
|
||||||
return super().changelist_view(request, extra_context=extra_context)
|
return super().changelist_view(request, extra_context=extra_context)
|
||||||
|
|
||||||
|
def delete_view(self, request, object_id, extra_context=None):
|
||||||
|
"""
|
||||||
|
Custom delete_view to perform additional actions or customize the template.
|
||||||
|
"""
|
||||||
|
# Set the delete template to a custom one
|
||||||
|
self.delete_confirmation_template = "django/admin/domain_invitation_delete_confirmation.html"
|
||||||
|
response = super().delete_view(request, object_id, extra_context=extra_context)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
def save_model(self, request, obj, form, change):
|
def save_model(self, request, obj, form, change):
|
||||||
"""
|
"""
|
||||||
Override the save_model method.
|
Override the save_model method.
|
||||||
|
@ -1551,7 +1571,9 @@ class DomainInvitationAdmin(BaseInvitationAdmin):
|
||||||
and not member_of_this_org
|
and not member_of_this_org
|
||||||
and not member_of_a_different_org
|
and not member_of_a_different_org
|
||||||
):
|
):
|
||||||
send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=domain_org)
|
send_portfolio_invitation_email(
|
||||||
|
email=requested_email, requestor=requestor, portfolio=domain_org, is_admin_invitation=False
|
||||||
|
)
|
||||||
portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(
|
portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(
|
||||||
email=requested_email,
|
email=requested_email,
|
||||||
portfolio=domain_org,
|
portfolio=domain_org,
|
||||||
|
@ -1642,30 +1664,57 @@ class PortfolioInvitationAdmin(BaseInvitationAdmin):
|
||||||
Emails sent to requested user / email.
|
Emails sent to requested user / email.
|
||||||
When exceptions are raised, return without saving model.
|
When exceptions are raised, return without saving model.
|
||||||
"""
|
"""
|
||||||
if not change: # Only send email if this is a new PortfolioInvitation (creation)
|
try:
|
||||||
portfolio = obj.portfolio
|
portfolio = obj.portfolio
|
||||||
requested_email = obj.email
|
requested_email = obj.email
|
||||||
requestor = request.user
|
requestor = request.user
|
||||||
# Look up a user with that email
|
is_admin_invitation = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in obj.roles
|
||||||
requested_user = get_requested_user(requested_email)
|
if not change: # Only send email if this is a new PortfolioInvitation (creation)
|
||||||
|
# Look up a user with that email
|
||||||
|
requested_user = get_requested_user(requested_email)
|
||||||
|
|
||||||
permission_exists = UserPortfolioPermission.objects.filter(
|
permission_exists = UserPortfolioPermission.objects.filter(
|
||||||
user__email=requested_email, portfolio=portfolio, user__email__isnull=False
|
user__email=requested_email, portfolio=portfolio, user__email__isnull=False
|
||||||
).exists()
|
).exists()
|
||||||
try:
|
|
||||||
if not permission_exists:
|
if not permission_exists:
|
||||||
# if permission does not exist for a user with requested_email, send email
|
# if permission does not exist for a user with requested_email, send email
|
||||||
send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=portfolio)
|
if not send_portfolio_invitation_email(
|
||||||
|
email=requested_email,
|
||||||
|
requestor=requestor,
|
||||||
|
portfolio=portfolio,
|
||||||
|
is_admin_invitation=is_admin_invitation,
|
||||||
|
):
|
||||||
|
messages.warning(
|
||||||
|
self.request, "Could not send email notification to existing organization admins."
|
||||||
|
)
|
||||||
# if user exists for email, immediately retrieve portfolio invitation upon creation
|
# if user exists for email, immediately retrieve portfolio invitation upon creation
|
||||||
if requested_user is not None:
|
if requested_user is not None:
|
||||||
obj.retrieve()
|
obj.retrieve()
|
||||||
messages.success(request, f"{requested_email} has been invited.")
|
messages.success(request, f"{requested_email} has been invited.")
|
||||||
else:
|
else:
|
||||||
messages.warning(request, "User is already a member of this portfolio.")
|
messages.warning(request, "User is already a member of this portfolio.")
|
||||||
except Exception as e:
|
else: # Handle the case when updating an existing PortfolioInvitation
|
||||||
# when exception is raised, handle and do not save the model
|
# Retrieve the existing object from the database
|
||||||
handle_invitation_exceptions(request, e, requested_email)
|
existing_obj = PortfolioInvitation.objects.get(pk=obj.pk)
|
||||||
return
|
|
||||||
|
# Check if the previous roles did NOT include ORGANIZATION_ADMIN
|
||||||
|
# and the new roles DO include ORGANIZATION_ADMIN
|
||||||
|
was_not_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN not in existing_obj.roles
|
||||||
|
# Check also if status is INVITED, ignore role changes for other statuses
|
||||||
|
is_invited = obj.status == PortfolioInvitation.PortfolioInvitationStatus.INVITED
|
||||||
|
|
||||||
|
if was_not_admin and is_admin_invitation and is_invited:
|
||||||
|
# send email to existing portfolio admins if new admin
|
||||||
|
if not send_portfolio_admin_addition_emails(
|
||||||
|
email=requested_email,
|
||||||
|
requestor=requestor,
|
||||||
|
portfolio=portfolio,
|
||||||
|
):
|
||||||
|
messages.warning(request, "Could not send email notification to existing organization admins.")
|
||||||
|
except Exception as e:
|
||||||
|
# when exception is raised, handle and do not save the model
|
||||||
|
handle_invitation_exceptions(request, e, requested_email)
|
||||||
|
return
|
||||||
# Call the parent save method to save the object
|
# Call the parent save method to save the object
|
||||||
super().save_model(request, obj, form, change)
|
super().save_model(request, obj, form, change)
|
||||||
|
|
||||||
|
|
|
@ -116,10 +116,10 @@ export class DomainRequestsTable extends BaseTable {
|
||||||
<td data-label="Status">
|
<td data-label="Status">
|
||||||
${request.status}
|
${request.status}
|
||||||
</td>
|
</td>
|
||||||
<td class="${ this.portfolioValue ? '' : "width-quarter"}">
|
<td class="width--action-column">
|
||||||
<div class="tablet:display-flex tablet:flex-row">
|
<div class="tablet:display-flex tablet:flex-row flex-wrap">
|
||||||
<a href="${actionUrl}" ${customTableOptions.hasAdditionalActions ? "class='margin-right-2'" : ''}>
|
<a href="${actionUrl}" ${customTableOptions.hasAdditionalActions ? "class='margin-right-2'" : ''}>
|
||||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
<svg class="usa-icon top-1px" aria-hidden="true" focusable="false" role="img" width="24">
|
||||||
<use xlink:href="/public/img/sprite.svg#${request.svg_icon}"></use>
|
<use xlink:href="/public/img/sprite.svg#${request.svg_icon}"></use>
|
||||||
</svg>
|
</svg>
|
||||||
${actionLabel} <span class="usa-sr-only">${request.requested_domain ? request.requested_domain : 'New domain request'}</span>
|
${actionLabel} <span class="usa-sr-only">${request.requested_domain ? request.requested_domain : 'New domain request'}</span>
|
||||||
|
|
|
@ -56,13 +56,15 @@ export class DomainsTable extends BaseTable {
|
||||||
</svg>
|
</svg>
|
||||||
</td>
|
</td>
|
||||||
${markupForSuborganizationRow}
|
${markupForSuborganizationRow}
|
||||||
<td class="${ this.portfolioValue ? '' : "width-quarter"}">
|
<td class="width--action-column">
|
||||||
<a href="${actionUrl}">
|
<div class="tablet:display-flex tablet:flex-row flex-align-center margin-right-2">
|
||||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
<a href="${actionUrl}">
|
||||||
<use xlink:href="/public/img/sprite.svg#${domain.svg_icon}"></use>
|
<svg class="usa-icon top-1px" aria-hidden="true" focusable="false" role="img" width="24">
|
||||||
</svg>
|
<use xlink:href="/public/img/sprite.svg#${domain.svg_icon}"></use>
|
||||||
${domain.action_label} <span class="usa-sr-only">${domain.name}</span>
|
</svg>
|
||||||
</a>
|
${domain.action_label} <span class="usa-sr-only">${domain.name}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
`;
|
`;
|
||||||
tbody.appendChild(row);
|
tbody.appendChild(row);
|
||||||
|
|
|
@ -48,18 +48,6 @@ export class MembersTable extends BaseTable {
|
||||||
// Get whether the logged in user has edit members permission
|
// Get whether the logged in user has edit members permission
|
||||||
const hasEditPermission = this.portfolioElement ? this.portfolioElement.getAttribute('data-has-edit-permission')==='True' : null;
|
const hasEditPermission = this.portfolioElement ? this.portfolioElement.getAttribute('data-has-edit-permission')==='True' : null;
|
||||||
|
|
||||||
let existingExtraActionsHeader = document.querySelector('.extra-actions-header');
|
|
||||||
|
|
||||||
if (hasEditPermission && !existingExtraActionsHeader) {
|
|
||||||
const extraActionsHeader = document.createElement('th');
|
|
||||||
extraActionsHeader.setAttribute('id', 'extra-actions');
|
|
||||||
extraActionsHeader.setAttribute('role', 'columnheader');
|
|
||||||
extraActionsHeader.setAttribute('class', 'extra-actions-header width-5');
|
|
||||||
extraActionsHeader.innerHTML = `
|
|
||||||
<span class="usa-sr-only">Extra Actions</span>`;
|
|
||||||
let tableHeaderRow = this.tableWrapper.querySelector('thead tr');
|
|
||||||
tableHeaderRow.appendChild(extraActionsHeader);
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
'hasAdditionalActions': hasEditPermission,
|
'hasAdditionalActions': hasEditPermission,
|
||||||
'UserPortfolioPermissionChoices' : data.UserPortfolioPermissionChoices
|
'UserPortfolioPermissionChoices' : data.UserPortfolioPermissionChoices
|
||||||
|
@ -121,15 +109,17 @@ export class MembersTable extends BaseTable {
|
||||||
<td headers="header-last-active row-header-${unique_id}" data-sort-value="${last_active.sort_value}" data-label="last_active">
|
<td headers="header-last-active row-header-${unique_id}" data-sort-value="${last_active.sort_value}" data-label="last_active">
|
||||||
${last_active.display_value}
|
${last_active.display_value}
|
||||||
</td>
|
</td>
|
||||||
<td headers="header-action row-header-${unique_id}">
|
<td headers="header-action row-header-${unique_id}" class="width--action-column">
|
||||||
<a href="${member.action_url}">
|
<div class="tablet:display-flex tablet:flex-row flex-align-center">
|
||||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
<a href="${member.action_url}">
|
||||||
<use xlink:href="/public/img/sprite.svg#${member.svg_icon}"></use>
|
<svg class="usa-icon top-1px" aria-hidden="true" focusable="false" role="img" width="24">
|
||||||
</svg>
|
<use xlink:href="/public/img/sprite.svg#${member.svg_icon}"></use>
|
||||||
${member.action_label} <span class="usa-sr-only">${member.name}</span>
|
</svg>
|
||||||
</a>
|
${member.action_label} <span class="usa-sr-only">${member.name}</span>
|
||||||
|
</a>
|
||||||
|
<span class="padding-left-1">${customTableOptions.hasAdditionalActions ? kebabHTML : ''}</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
${customTableOptions.hasAdditionalActions ? '<td>'+kebabHTML+'</td>' : ''}
|
|
||||||
`;
|
`;
|
||||||
tbody.appendChild(row);
|
tbody.appendChild(row);
|
||||||
if (domainsHTML || permissionsHTML) {
|
if (domainsHTML || permissionsHTML) {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
@use "uswds-core" as *;
|
@use "uswds-core" as *;
|
||||||
@use "cisa_colors" as *;
|
@use "cisa_colors" as *;
|
||||||
|
|
||||||
$widescreen-max-width: 1920px;
|
$widescreen-max-width: 1536px;
|
||||||
$widescreen-x-padding: 4.5rem;
|
$widescreen-x-padding: 4.5rem;
|
||||||
|
|
||||||
$hot-pink: #FFC3F9;
|
$hot-pink: #FFC3F9;
|
||||||
|
@ -51,7 +51,7 @@ body {
|
||||||
background-color: color('white');
|
background-color: color('white');
|
||||||
border: 1px solid color('base-lighter');
|
border: 1px solid color('base-lighter');
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 0 units(4) units(3) units(2);
|
padding: 0 units(2) units(3) units(2);
|
||||||
margin-top: units(3);
|
margin-top: units(3);
|
||||||
|
|
||||||
&.margin-top-0 {
|
&.margin-top-0 {
|
||||||
|
@ -275,3 +275,12 @@ abbr[title] {
|
||||||
.width-quarter {
|
.width-quarter {
|
||||||
width: 25%;
|
width: 25%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
NOTE: width: 3% basically forces a fit-content effect in the table.
|
||||||
|
Fit-content itself does not work.
|
||||||
|
*/
|
||||||
|
.width--action-column {
|
||||||
|
width: 3%;
|
||||||
|
padding-right: 0px !important;
|
||||||
|
}
|
||||||
|
|
|
@ -61,6 +61,7 @@ env_db_url = env.dj_db_url("DATABASE_URL")
|
||||||
env_debug = env.bool("DJANGO_DEBUG", default=False)
|
env_debug = env.bool("DJANGO_DEBUG", default=False)
|
||||||
env_is_production = env.bool("IS_PRODUCTION", default=False)
|
env_is_production = env.bool("IS_PRODUCTION", default=False)
|
||||||
env_log_level = env.str("DJANGO_LOG_LEVEL", "DEBUG")
|
env_log_level = env.str("DJANGO_LOG_LEVEL", "DEBUG")
|
||||||
|
env_log_format = env.str("DJANGO_LOG_FORMAT", "console")
|
||||||
env_base_url: str = env.str("DJANGO_BASE_URL")
|
env_base_url: str = env.str("DJANGO_BASE_URL")
|
||||||
env_getgov_public_site_url = env.str("GETGOV_PUBLIC_SITE_URL", "")
|
env_getgov_public_site_url = env.str("GETGOV_PUBLIC_SITE_URL", "")
|
||||||
env_oidc_active_provider = env.str("OIDC_ACTIVE_PROVIDER", "identity sandbox")
|
env_oidc_active_provider = env.str("OIDC_ACTIVE_PROVIDER", "identity sandbox")
|
||||||
|
@ -492,12 +493,18 @@ class JsonServerFormatter(ServerFormatter):
|
||||||
return json.dumps(log_entry)
|
return json.dumps(log_entry)
|
||||||
|
|
||||||
|
|
||||||
# default to json formatted logs
|
# If we're running locally we don't want json formatting
|
||||||
server_formatter, console_formatter = "json.server", "json"
|
|
||||||
|
|
||||||
# don't use json format locally, it makes logs hard to read in console
|
|
||||||
if "localhost" in env_base_url:
|
if "localhost" in env_base_url:
|
||||||
server_formatter, console_formatter = "django.server", "verbose"
|
django_handlers = ["console"]
|
||||||
|
elif env_log_format == "json":
|
||||||
|
# in production we need everything to be logged as json so that log levels are parsed correctly
|
||||||
|
django_handlers = ["json"]
|
||||||
|
else:
|
||||||
|
# for non-production non-local environments:
|
||||||
|
# - send ERROR and above to json handler
|
||||||
|
# - send below ERROR to console handler with verbose formatting
|
||||||
|
# yes this is janky but it's the best we can do for now
|
||||||
|
django_handlers = ["split_console", "split_json"]
|
||||||
|
|
||||||
LOGGING = {
|
LOGGING = {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
|
@ -531,29 +538,52 @@ LOGGING = {
|
||||||
"console": {
|
"console": {
|
||||||
"level": env_log_level,
|
"level": env_log_level,
|
||||||
"class": "logging.StreamHandler",
|
"class": "logging.StreamHandler",
|
||||||
"formatter": console_formatter,
|
"formatter": "verbose",
|
||||||
|
},
|
||||||
|
# Special handlers for split logging case
|
||||||
|
"split_console": {
|
||||||
|
"level": env_log_level,
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"formatter": "verbose",
|
||||||
|
"filters": ["below_error"],
|
||||||
|
},
|
||||||
|
"split_json": {
|
||||||
|
"level": "ERROR",
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"formatter": "json",
|
||||||
},
|
},
|
||||||
"django.server": {
|
"django.server": {
|
||||||
"level": "INFO",
|
"level": "INFO",
|
||||||
"class": "logging.StreamHandler",
|
"class": "logging.StreamHandler",
|
||||||
"formatter": server_formatter,
|
"formatter": "django.server",
|
||||||
|
},
|
||||||
|
"json": {
|
||||||
|
"level": env_log_level,
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"formatter": "json",
|
||||||
},
|
},
|
||||||
# No file logger is configured,
|
# No file logger is configured,
|
||||||
# because containerized apps
|
# because containerized apps
|
||||||
# do not log to the file system.
|
# do not log to the file system.
|
||||||
},
|
},
|
||||||
|
"filters": {
|
||||||
|
"below_error": {
|
||||||
|
"()": "django.utils.log.CallbackFilter",
|
||||||
|
"callback": lambda record: record.levelno < logging.ERROR,
|
||||||
|
}
|
||||||
|
},
|
||||||
# define loggers: these are "sinks" into which
|
# define loggers: these are "sinks" into which
|
||||||
# messages are sent for processing
|
# messages are sent for processing
|
||||||
"loggers": {
|
"loggers": {
|
||||||
# Django's generic logger
|
# Django's generic logger
|
||||||
"django": {
|
"django": {
|
||||||
"handlers": ["console"],
|
"handlers": django_handlers,
|
||||||
"level": "INFO",
|
"level": "INFO",
|
||||||
"propagate": False,
|
"propagate": False,
|
||||||
},
|
},
|
||||||
# Django's template processor
|
# Django's template processor
|
||||||
"django.template": {
|
"django.template": {
|
||||||
"handlers": ["console"],
|
"handlers": django_handlers,
|
||||||
"level": "INFO",
|
"level": "INFO",
|
||||||
"propagate": False,
|
"propagate": False,
|
||||||
},
|
},
|
||||||
|
@ -571,19 +601,19 @@ LOGGING = {
|
||||||
},
|
},
|
||||||
# OpenID Connect logger
|
# OpenID Connect logger
|
||||||
"oic": {
|
"oic": {
|
||||||
"handlers": ["console"],
|
"handlers": django_handlers,
|
||||||
"level": "INFO",
|
"level": "INFO",
|
||||||
"propagate": False,
|
"propagate": False,
|
||||||
},
|
},
|
||||||
# Django wrapper for OpenID Connect
|
# Django wrapper for OpenID Connect
|
||||||
"djangooidc": {
|
"djangooidc": {
|
||||||
"handlers": ["console"],
|
"handlers": django_handlers,
|
||||||
"level": "INFO",
|
"level": "INFO",
|
||||||
"propagate": False,
|
"propagate": False,
|
||||||
},
|
},
|
||||||
# Our app!
|
# Our app!
|
||||||
"registrar": {
|
"registrar": {
|
||||||
"handlers": ["console"],
|
"handlers": django_handlers,
|
||||||
"level": "DEBUG",
|
"level": "DEBUG",
|
||||||
"propagate": False,
|
"propagate": False,
|
||||||
},
|
},
|
||||||
|
@ -591,7 +621,7 @@ LOGGING = {
|
||||||
# root logger catches anything, unless
|
# root logger catches anything, unless
|
||||||
# defined by a more specific logger
|
# defined by a more specific logger
|
||||||
"root": {
|
"root": {
|
||||||
"handlers": ["console"],
|
"handlers": django_handlers,
|
||||||
"level": "INFO",
|
"level": "INFO",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -56,12 +56,11 @@ def add_path_to_context(request):
|
||||||
def portfolio_permissions(request):
|
def portfolio_permissions(request):
|
||||||
"""Make portfolio permissions for the request user available in global context"""
|
"""Make portfolio permissions for the request user available in global context"""
|
||||||
portfolio_context = {
|
portfolio_context = {
|
||||||
"has_base_portfolio_permission": False,
|
"has_view_portfolio_permission": False,
|
||||||
|
"has_edit_portfolio_permission": False,
|
||||||
"has_any_domains_portfolio_permission": False,
|
"has_any_domains_portfolio_permission": False,
|
||||||
"has_any_requests_portfolio_permission": False,
|
"has_any_requests_portfolio_permission": False,
|
||||||
"has_edit_request_portfolio_permission": False,
|
"has_edit_request_portfolio_permission": False,
|
||||||
"has_view_suborganization_portfolio_permission": False,
|
|
||||||
"has_edit_suborganization_portfolio_permission": False,
|
|
||||||
"has_view_members_portfolio_permission": False,
|
"has_view_members_portfolio_permission": False,
|
||||||
"has_edit_members_portfolio_permission": False,
|
"has_edit_members_portfolio_permission": False,
|
||||||
"portfolio": None,
|
"portfolio": None,
|
||||||
|
@ -82,15 +81,11 @@ def portfolio_permissions(request):
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Linting: line too long
|
|
||||||
view_suborg = request.user.has_view_suborganization_portfolio_permission(portfolio)
|
|
||||||
edit_suborg = request.user.has_edit_suborganization_portfolio_permission(portfolio)
|
|
||||||
if portfolio:
|
if portfolio:
|
||||||
return {
|
return {
|
||||||
"has_base_portfolio_permission": request.user.has_base_portfolio_permission(portfolio),
|
"has_view_portfolio_permission": request.user.has_view_portfolio_permission(portfolio),
|
||||||
|
"has_edit_portfolio_permission": request.user.has_edit_portfolio_permission(portfolio),
|
||||||
"has_edit_request_portfolio_permission": request.user.has_edit_request_portfolio_permission(portfolio),
|
"has_edit_request_portfolio_permission": request.user.has_edit_request_portfolio_permission(portfolio),
|
||||||
"has_view_suborganization_portfolio_permission": view_suborg,
|
|
||||||
"has_edit_suborganization_portfolio_permission": edit_suborg,
|
|
||||||
"has_any_domains_portfolio_permission": request.user.has_any_domains_portfolio_permission(portfolio),
|
"has_any_domains_portfolio_permission": request.user.has_any_domains_portfolio_permission(portfolio),
|
||||||
"has_any_requests_portfolio_permission": request.user.has_any_requests_portfolio_permission(portfolio),
|
"has_any_requests_portfolio_permission": request.user.has_any_requests_portfolio_permission(portfolio),
|
||||||
"has_view_members_portfolio_permission": request.user.has_view_members_portfolio_permission(portfolio),
|
"has_view_members_portfolio_permission": request.user.has_view_members_portfolio_permission(portfolio),
|
||||||
|
|
|
@ -3,7 +3,6 @@ from django.utils import timezone
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
from faker import Faker
|
from faker import Faker
|
||||||
from django.db import transaction
|
|
||||||
|
|
||||||
from registrar.fixtures.fixtures_requests import DomainRequestFixture
|
from registrar.fixtures.fixtures_requests import DomainRequestFixture
|
||||||
from registrar.fixtures.fixtures_users import UserFixture
|
from registrar.fixtures.fixtures_users import UserFixture
|
||||||
|
@ -29,19 +28,18 @@ class DomainFixture(DomainRequestFixture):
|
||||||
def load(cls):
|
def load(cls):
|
||||||
# Lumped under .atomic to ensure we don't make redundant DB calls.
|
# Lumped under .atomic to ensure we don't make redundant DB calls.
|
||||||
# This bundles them all together, and then saves it in a single call.
|
# This bundles them all together, and then saves it in a single call.
|
||||||
with transaction.atomic():
|
try:
|
||||||
try:
|
# Get the usernames of users created in the UserFixture
|
||||||
# Get the usernames of users created in the UserFixture
|
created_usernames = [user_data["username"] for user_data in UserFixture.ADMINS + UserFixture.STAFF]
|
||||||
created_usernames = [user_data["username"] for user_data in UserFixture.ADMINS + UserFixture.STAFF]
|
|
||||||
|
|
||||||
# Filter users to only include those created by the fixture
|
# Filter users to only include those created by the fixture
|
||||||
users = list(User.objects.filter(username__in=created_usernames))
|
users = list(User.objects.filter(username__in=created_usernames))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(e)
|
logger.warning(e)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Approve each user associated with `in review` status domains
|
# Approve each user associated with `in review` status domains
|
||||||
cls._approve_domain_requests(users)
|
cls._approve_domain_requests(users)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _generate_fake_expiration_date(days_in_future=365):
|
def _generate_fake_expiration_date(days_in_future=365):
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
from faker import Faker
|
from faker import Faker
|
||||||
from django.db import transaction
|
|
||||||
|
|
||||||
from registrar.models import User, DomainRequest, FederalAgency
|
from registrar.models import User, DomainRequest, FederalAgency
|
||||||
from registrar.models.portfolio import Portfolio
|
from registrar.models.portfolio import Portfolio
|
||||||
|
@ -84,42 +83,38 @@ class PortfolioFixture:
|
||||||
def load(cls):
|
def load(cls):
|
||||||
"""Creates portfolios."""
|
"""Creates portfolios."""
|
||||||
logger.info("Going to load %s portfolios" % len(cls.PORTFOLIOS))
|
logger.info("Going to load %s portfolios" % len(cls.PORTFOLIOS))
|
||||||
|
try:
|
||||||
|
user = User.objects.all().last()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(e)
|
||||||
|
return
|
||||||
|
|
||||||
# Lumped under .atomic to ensure we don't make redundant DB calls.
|
portfolios_to_create = []
|
||||||
# This bundles them all together, and then saves it in a single call.
|
for portfolio_data in cls.PORTFOLIOS:
|
||||||
with transaction.atomic():
|
organization_name = portfolio_data["organization_name"]
|
||||||
|
|
||||||
|
# Check if portfolio with the organization name already exists
|
||||||
|
if Portfolio.objects.filter(organization_name=organization_name).exists():
|
||||||
|
logger.info(
|
||||||
|
f"Portfolio with organization name '{organization_name}' already exists, skipping creation."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
portfolio = Portfolio(
|
||||||
|
creator=user,
|
||||||
|
organization_name=portfolio_data["organization_name"],
|
||||||
|
)
|
||||||
|
cls._set_non_foreign_key_fields(portfolio, portfolio_data)
|
||||||
|
cls._set_foreign_key_fields(portfolio, portfolio_data, user)
|
||||||
|
portfolios_to_create.append(portfolio)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(e)
|
||||||
|
|
||||||
|
# Bulk create portfolios
|
||||||
|
if len(portfolios_to_create) > 0:
|
||||||
try:
|
try:
|
||||||
user = User.objects.all().last()
|
Portfolio.objects.bulk_create(portfolios_to_create)
|
||||||
|
logger.info(f"Successfully created {len(portfolios_to_create)} portfolios")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(e)
|
logger.warning(f"Error bulk creating portfolios: {e}")
|
||||||
return
|
|
||||||
|
|
||||||
portfolios_to_create = []
|
|
||||||
for portfolio_data in cls.PORTFOLIOS:
|
|
||||||
organization_name = portfolio_data["organization_name"]
|
|
||||||
|
|
||||||
# Check if portfolio with the organization name already exists
|
|
||||||
if Portfolio.objects.filter(organization_name=organization_name).exists():
|
|
||||||
logger.info(
|
|
||||||
f"Portfolio with organization name '{organization_name}' already exists, skipping creation."
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
portfolio = Portfolio(
|
|
||||||
creator=user,
|
|
||||||
organization_name=portfolio_data["organization_name"],
|
|
||||||
)
|
|
||||||
cls._set_non_foreign_key_fields(portfolio, portfolio_data)
|
|
||||||
cls._set_foreign_key_fields(portfolio, portfolio_data, user)
|
|
||||||
portfolios_to_create.append(portfolio)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(e)
|
|
||||||
|
|
||||||
# Bulk create domain requests
|
|
||||||
if len(portfolios_to_create) > 0:
|
|
||||||
try:
|
|
||||||
Portfolio.objects.bulk_create(portfolios_to_create)
|
|
||||||
logger.info(f"Successfully created {len(portfolios_to_create)} portfolios")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Error bulk creating portfolios: {e}")
|
|
||||||
|
|
|
@ -3,7 +3,6 @@ from django.utils import timezone
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
from faker import Faker
|
from faker import Faker
|
||||||
from django.db import transaction
|
|
||||||
|
|
||||||
from registrar.fixtures.fixtures_portfolios import PortfolioFixture
|
from registrar.fixtures.fixtures_portfolios import PortfolioFixture
|
||||||
from registrar.fixtures.fixtures_suborganizations import SuborganizationFixture
|
from registrar.fixtures.fixtures_suborganizations import SuborganizationFixture
|
||||||
|
@ -303,24 +302,17 @@ class DomainRequestFixture:
|
||||||
def load(cls):
|
def load(cls):
|
||||||
"""Creates domain requests for each user in the database."""
|
"""Creates domain requests for each user in the database."""
|
||||||
logger.info("Going to load %s domain requests" % len(cls.DOMAINREQUESTS))
|
logger.info("Going to load %s domain requests" % len(cls.DOMAINREQUESTS))
|
||||||
|
try:
|
||||||
|
# Get the usernames of users created in the UserFixture
|
||||||
|
created_usernames = [user_data["username"] for user_data in UserFixture.ADMINS + UserFixture.STAFF]
|
||||||
|
|
||||||
# Lumped under .atomic to ensure we don't make redundant DB calls.
|
# Filter users to only include those created by the fixture
|
||||||
# This bundles them all together, and then saves it in a single call.
|
users = list(User.objects.filter(username__in=created_usernames))
|
||||||
# The atomic block will cause the code to stop executing if one instance in the
|
except Exception as e:
|
||||||
# nested iteration fails, which will cause an early exit and make it hard to debug.
|
logger.warning(e)
|
||||||
# Comment out with transaction.atomic() when debugging.
|
return
|
||||||
with transaction.atomic():
|
|
||||||
try:
|
|
||||||
# Get the usernames of users created in the UserFixture
|
|
||||||
created_usernames = [user_data["username"] for user_data in UserFixture.ADMINS + UserFixture.STAFF]
|
|
||||||
|
|
||||||
# Filter users to only include those created by the fixture
|
cls._create_domain_requests(users)
|
||||||
users = list(User.objects.filter(username__in=created_usernames))
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(e)
|
|
||||||
return
|
|
||||||
|
|
||||||
cls._create_domain_requests(users)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _create_domain_requests(cls, users): # noqa: C901
|
def _create_domain_requests(cls, users): # noqa: C901
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import logging
|
import logging
|
||||||
from faker import Faker
|
from faker import Faker
|
||||||
from django.db import transaction
|
|
||||||
|
|
||||||
from registrar.models.portfolio import Portfolio
|
from registrar.models.portfolio import Portfolio
|
||||||
from registrar.models.suborganization import Suborganization
|
from registrar.models.suborganization import Suborganization
|
||||||
|
@ -34,14 +33,12 @@ class SuborganizationFixture:
|
||||||
def load(cls):
|
def load(cls):
|
||||||
"""Creates suborganizations."""
|
"""Creates suborganizations."""
|
||||||
logger.info(f"Going to load {len(cls.SUBORGS)} suborgs")
|
logger.info(f"Going to load {len(cls.SUBORGS)} suborgs")
|
||||||
|
portfolios = cls._get_portfolios()
|
||||||
|
if not portfolios:
|
||||||
|
return
|
||||||
|
|
||||||
with transaction.atomic():
|
suborgs_to_create = cls._prepare_suborgs_to_create(portfolios)
|
||||||
portfolios = cls._get_portfolios()
|
cls._bulk_create_suborgs(suborgs_to_create)
|
||||||
if not portfolios:
|
|
||||||
return
|
|
||||||
|
|
||||||
suborgs_to_create = cls._prepare_suborgs_to_create(portfolios)
|
|
||||||
cls._bulk_create_suborgs(suborgs_to_create)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _get_portfolios(cls):
|
def _get_portfolios(cls):
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
from faker import Faker
|
from faker import Faker
|
||||||
from django.db import transaction
|
|
||||||
|
|
||||||
from registrar.fixtures.fixtures_portfolios import PortfolioFixture
|
from registrar.fixtures.fixtures_portfolios import PortfolioFixture
|
||||||
from registrar.fixtures.fixtures_users import UserFixture
|
from registrar.fixtures.fixtures_users import UserFixture
|
||||||
|
@ -26,56 +25,55 @@ class UserPortfolioPermissionFixture:
|
||||||
|
|
||||||
# Lumped under .atomic to ensure we don't make redundant DB calls.
|
# Lumped under .atomic to ensure we don't make redundant DB calls.
|
||||||
# This bundles them all together, and then saves it in a single call.
|
# This bundles them all together, and then saves it in a single call.
|
||||||
with transaction.atomic():
|
try:
|
||||||
try:
|
# Get the usernames of users created in the UserFixture
|
||||||
# Get the usernames of users created in the UserFixture
|
created_usernames = [user_data["username"] for user_data in UserFixture.ADMINS + UserFixture.STAFF]
|
||||||
created_usernames = [user_data["username"] for user_data in UserFixture.ADMINS + UserFixture.STAFF]
|
|
||||||
|
|
||||||
# Filter users to only include those created by the fixture
|
# Filter users to only include those created by the fixture
|
||||||
users = list(User.objects.filter(username__in=created_usernames))
|
users = list(User.objects.filter(username__in=created_usernames))
|
||||||
|
|
||||||
organization_names = [portfolio["organization_name"] for portfolio in PortfolioFixture.PORTFOLIOS]
|
organization_names = [portfolio["organization_name"] for portfolio in PortfolioFixture.PORTFOLIOS]
|
||||||
|
|
||||||
portfolios = list(Portfolio.objects.filter(organization_name__in=organization_names))
|
portfolios = list(Portfolio.objects.filter(organization_name__in=organization_names))
|
||||||
|
|
||||||
if not users:
|
if not users:
|
||||||
logger.warning("User fixtures missing.")
|
logger.warning("User fixtures missing.")
|
||||||
return
|
|
||||||
|
|
||||||
if not portfolios:
|
|
||||||
logger.warning("Portfolio fixtures missing.")
|
|
||||||
return
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(e)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
user_portfolio_permissions_to_create = []
|
if not portfolios:
|
||||||
for user in users:
|
logger.warning("Portfolio fixtures missing.")
|
||||||
# Assign a random portfolio to a user
|
return
|
||||||
portfolio = random.choice(portfolios) # nosec
|
|
||||||
try:
|
|
||||||
if not UserPortfolioPermission.objects.filter(user=user, portfolio=portfolio).exists():
|
|
||||||
user_portfolio_permission = UserPortfolioPermission(
|
|
||||||
user=user,
|
|
||||||
portfolio=portfolio,
|
|
||||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
|
||||||
additional_permissions=[
|
|
||||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
|
||||||
UserPortfolioPermissionChoices.EDIT_REQUESTS,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
user_portfolio_permissions_to_create.append(user_portfolio_permission)
|
|
||||||
else:
|
|
||||||
logger.info(
|
|
||||||
f"Permission exists for user '{user.username}' "
|
|
||||||
f"on portfolio '{portfolio.organization_name}'."
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(e)
|
|
||||||
|
|
||||||
# Bulk create permissions
|
except Exception as e:
|
||||||
cls._bulk_create_permissions(user_portfolio_permissions_to_create)
|
logger.warning(e)
|
||||||
|
return
|
||||||
|
|
||||||
|
user_portfolio_permissions_to_create = []
|
||||||
|
for user in users:
|
||||||
|
# Assign a random portfolio to a user
|
||||||
|
portfolio = random.choice(portfolios) # nosec
|
||||||
|
try:
|
||||||
|
if not UserPortfolioPermission.objects.filter(user=user, portfolio=portfolio).exists():
|
||||||
|
user_portfolio_permission = UserPortfolioPermission(
|
||||||
|
user=user,
|
||||||
|
portfolio=portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||||
|
additional_permissions=[
|
||||||
|
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||||
|
UserPortfolioPermissionChoices.EDIT_REQUESTS,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
user_portfolio_permissions_to_create.append(user_portfolio_permission)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
f"Permission exists for user '{user.username}' "
|
||||||
|
f"on portfolio '{portfolio.organization_name}'."
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(e)
|
||||||
|
|
||||||
|
# Bulk create permissions
|
||||||
|
cls._bulk_create_permissions(user_portfolio_permissions_to_create)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _bulk_create_permissions(cls, user_portfolio_permissions_to_create):
|
def _bulk_create_permissions(cls, user_portfolio_permissions_to_create):
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import logging
|
import logging
|
||||||
from faker import Faker
|
from faker import Faker
|
||||||
from django.db import transaction
|
|
||||||
|
|
||||||
from registrar.models import (
|
from registrar.models import (
|
||||||
User,
|
User,
|
||||||
|
@ -455,10 +454,9 @@ class UserFixture:
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load(cls):
|
def load(cls):
|
||||||
with transaction.atomic():
|
cls.load_users(cls.ADMINS, "full_access_group", are_superusers=True)
|
||||||
cls.load_users(cls.ADMINS, "full_access_group", are_superusers=True)
|
cls.load_users(cls.STAFF, "cisa_analysts_group")
|
||||||
cls.load_users(cls.STAFF, "cisa_analysts_group")
|
|
||||||
|
|
||||||
# Combine ADMINS and STAFF lists
|
# Combine ADMINS and STAFF lists
|
||||||
all_users = cls.ADMINS + cls.STAFF
|
all_users = cls.ADMINS + cls.STAFF
|
||||||
cls.load_allowed_emails(cls, all_users, additional_emails=cls.ADDITIONAL_ALLOWED_EMAILS)
|
cls.load_allowed_emails(cls, all_users, additional_emails=cls.ADDITIONAL_ALLOWED_EMAILS)
|
||||||
|
|
|
@ -312,6 +312,32 @@ class BasePortfolioMemberForm(forms.ModelForm):
|
||||||
self.initial["domain_permissions"] = selected_domain_permission
|
self.initial["domain_permissions"] = selected_domain_permission
|
||||||
self.initial["member_permissions"] = selected_member_permission
|
self.initial["member_permissions"] = selected_member_permission
|
||||||
|
|
||||||
|
def is_change_from_member_to_admin(self) -> bool:
|
||||||
|
"""
|
||||||
|
Checks if the roles have changed from not containing ORGANIZATION_ADMIN
|
||||||
|
to containing ORGANIZATION_ADMIN.
|
||||||
|
"""
|
||||||
|
previous_roles = set(self.initial.get("roles", [])) # Initial roles before change
|
||||||
|
new_roles = set(self.cleaned_data.get("roles", [])) # New roles after change
|
||||||
|
|
||||||
|
return (
|
||||||
|
UserPortfolioRoleChoices.ORGANIZATION_ADMIN not in previous_roles
|
||||||
|
and UserPortfolioRoleChoices.ORGANIZATION_ADMIN in new_roles
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_change_from_admin_to_member(self) -> bool:
|
||||||
|
"""
|
||||||
|
Checks if the roles have changed from containing ORGANIZATION_ADMIN
|
||||||
|
to not containing ORGANIZATION_ADMIN.
|
||||||
|
"""
|
||||||
|
previous_roles = set(self.initial.get("roles", [])) # Initial roles before change
|
||||||
|
new_roles = set(self.cleaned_data.get("roles", [])) # New roles after change
|
||||||
|
|
||||||
|
return (
|
||||||
|
UserPortfolioRoleChoices.ORGANIZATION_ADMIN in previous_roles
|
||||||
|
and UserPortfolioRoleChoices.ORGANIZATION_ADMIN not in new_roles
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PortfolioMemberForm(BasePortfolioMemberForm):
|
class PortfolioMemberForm(BasePortfolioMemberForm):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -5,9 +5,16 @@ import logging
|
||||||
from django.core.management import BaseCommand, CommandError
|
from django.core.management import BaseCommand, CommandError
|
||||||
from registrar.management.commands.utility.terminal_helper import TerminalColors, TerminalHelper
|
from registrar.management.commands.utility.terminal_helper import TerminalColors, TerminalHelper
|
||||||
from registrar.models import DomainInformation, DomainRequest, FederalAgency, Suborganization, Portfolio, User
|
from registrar.models import DomainInformation, DomainRequest, FederalAgency, Suborganization, Portfolio, User
|
||||||
|
from registrar.models.domain import Domain
|
||||||
|
from registrar.models.domain_invitation import DomainInvitation
|
||||||
|
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||||
|
from registrar.models.user_domain_role import UserDomainRole
|
||||||
|
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||||
from registrar.models.utility.generic_helper import normalize_string
|
from registrar.models.utility.generic_helper import normalize_string
|
||||||
from django.db.models import F, Q
|
from django.db.models import F, Q
|
||||||
|
|
||||||
|
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -21,6 +28,10 @@ class Command(BaseCommand):
|
||||||
self.updated_portfolios = set()
|
self.updated_portfolios = set()
|
||||||
self.skipped_portfolios = set()
|
self.skipped_portfolios = set()
|
||||||
self.failed_portfolios = set()
|
self.failed_portfolios = set()
|
||||||
|
self.added_managers = set()
|
||||||
|
self.added_invitations = set()
|
||||||
|
self.skipped_invitations = set()
|
||||||
|
self.failed_managers = set()
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
"""Add command line arguments to create federal portfolios.
|
"""Add command line arguments to create federal portfolios.
|
||||||
|
@ -38,6 +49,9 @@ class Command(BaseCommand):
|
||||||
Optional (mutually exclusive with parse options):
|
Optional (mutually exclusive with parse options):
|
||||||
--both: Shorthand for using both --parse_requests and --parse_domains
|
--both: Shorthand for using both --parse_requests and --parse_domains
|
||||||
Cannot be used with --parse_requests or --parse_domains
|
Cannot be used with --parse_requests or --parse_domains
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
--add_managers: Add all domain managers of the portfolio's domains to the organization.
|
||||||
"""
|
"""
|
||||||
group = parser.add_mutually_exclusive_group(required=True)
|
group = parser.add_mutually_exclusive_group(required=True)
|
||||||
group.add_argument(
|
group.add_argument(
|
||||||
|
@ -64,23 +78,31 @@ class Command(BaseCommand):
|
||||||
action=argparse.BooleanOptionalAction,
|
action=argparse.BooleanOptionalAction,
|
||||||
help="Adds portfolio to both requests and domains",
|
help="Adds portfolio to both requests and domains",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--add_managers",
|
||||||
|
action=argparse.BooleanOptionalAction,
|
||||||
|
help="Add all domain managers of the portfolio's domains to the organization.",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--skip_existing_portfolios",
|
"--skip_existing_portfolios",
|
||||||
action=argparse.BooleanOptionalAction,
|
action=argparse.BooleanOptionalAction,
|
||||||
help="Only add suborganizations to newly created portfolios, skip existing ones.",
|
help="Only add suborganizations to newly created portfolios, skip existing ones.",
|
||||||
)
|
)
|
||||||
|
|
||||||
def handle(self, **options):
|
def handle(self, **options): # noqa: C901
|
||||||
agency_name = options.get("agency_name")
|
agency_name = options.get("agency_name")
|
||||||
branch = options.get("branch")
|
branch = options.get("branch")
|
||||||
parse_requests = options.get("parse_requests")
|
parse_requests = options.get("parse_requests")
|
||||||
parse_domains = options.get("parse_domains")
|
parse_domains = options.get("parse_domains")
|
||||||
both = options.get("both")
|
both = options.get("both")
|
||||||
|
add_managers = options.get("add_managers")
|
||||||
skip_existing_portfolios = options.get("skip_existing_portfolios")
|
skip_existing_portfolios = options.get("skip_existing_portfolios")
|
||||||
|
|
||||||
if not both:
|
if not both:
|
||||||
if not parse_requests and not parse_domains:
|
if not (parse_requests or parse_domains or add_managers):
|
||||||
raise CommandError("You must specify at least one of --parse_requests or --parse_domains.")
|
raise CommandError(
|
||||||
|
"You must specify at least one of --parse_requests, --parse_domains, or --add_managers."
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
if parse_requests or parse_domains:
|
if parse_requests or parse_domains:
|
||||||
raise CommandError("You cannot pass --parse_requests or --parse_domains when passing --both.")
|
raise CommandError("You cannot pass --parse_requests or --parse_domains when passing --both.")
|
||||||
|
@ -96,7 +118,6 @@ class Command(BaseCommand):
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise CommandError(f"Cannot find '{branch}' federal agencies in our database.")
|
raise CommandError(f"Cannot find '{branch}' federal agencies in our database.")
|
||||||
|
|
||||||
portfolios = []
|
portfolios = []
|
||||||
for federal_agency in agencies:
|
for federal_agency in agencies:
|
||||||
message = f"Processing federal agency '{federal_agency.agency}'..."
|
message = f"Processing federal agency '{federal_agency.agency}'..."
|
||||||
|
@ -107,6 +128,8 @@ class Command(BaseCommand):
|
||||||
federal_agency, parse_domains, parse_requests, both, skip_existing_portfolios
|
federal_agency, parse_domains, parse_requests, both, skip_existing_portfolios
|
||||||
)
|
)
|
||||||
portfolios.append(portfolio)
|
portfolios.append(portfolio)
|
||||||
|
if add_managers:
|
||||||
|
self.add_managers_to_portfolio(portfolio)
|
||||||
except Exception as exec:
|
except Exception as exec:
|
||||||
self.failed_portfolios.add(federal_agency)
|
self.failed_portfolios.add(federal_agency)
|
||||||
logger.error(exec)
|
logger.error(exec)
|
||||||
|
@ -127,6 +150,26 @@ class Command(BaseCommand):
|
||||||
display_as_str=True,
|
display_as_str=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if add_managers:
|
||||||
|
TerminalHelper.log_script_run_summary(
|
||||||
|
self.added_managers,
|
||||||
|
self.failed_managers,
|
||||||
|
[], # can't skip managers, can only add or fail
|
||||||
|
log_header="----- MANAGERS ADDED -----",
|
||||||
|
debug=False,
|
||||||
|
display_as_str=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
TerminalHelper.log_script_run_summary(
|
||||||
|
self.added_invitations,
|
||||||
|
[],
|
||||||
|
self.skipped_invitations,
|
||||||
|
log_header="----- INVITATIONS ADDED -----",
|
||||||
|
debug=False,
|
||||||
|
skipped_header="----- INVITATIONS SKIPPED (ALREADY EXISTED) -----",
|
||||||
|
display_as_str=True,
|
||||||
|
)
|
||||||
|
|
||||||
# POST PROCESSING STEP: Remove the federal agency if it matches the portfolio name.
|
# POST PROCESSING STEP: Remove the federal agency if it matches the portfolio name.
|
||||||
# We only do this for started domain requests.
|
# We only do this for started domain requests.
|
||||||
if parse_requests or both:
|
if parse_requests or both:
|
||||||
|
@ -147,6 +190,73 @@ class Command(BaseCommand):
|
||||||
)
|
)
|
||||||
self.post_process_started_domain_requests(agencies, portfolios)
|
self.post_process_started_domain_requests(agencies, portfolios)
|
||||||
|
|
||||||
|
def add_managers_to_portfolio(self, portfolio: Portfolio):
|
||||||
|
"""
|
||||||
|
Add all domain managers of the portfolio's domains to the organization.
|
||||||
|
This includes adding them to the correct group and creating portfolio invitations.
|
||||||
|
"""
|
||||||
|
logger.info(f"Adding managers for portfolio {portfolio}")
|
||||||
|
|
||||||
|
# Fetch all domains associated with the portfolio
|
||||||
|
domains = Domain.objects.filter(domain_info__portfolio=portfolio)
|
||||||
|
domain_managers: set[UserDomainRole] = set()
|
||||||
|
|
||||||
|
# Fetch all users with manager roles for the domains
|
||||||
|
# select_related means that a db query will not be occur when you do user_domain_role.user
|
||||||
|
# Its similar to a set or dict in that it costs slightly more upfront in exchange for perf later
|
||||||
|
user_domain_roles = UserDomainRole.objects.select_related("user").filter(
|
||||||
|
domain__in=domains, role=UserDomainRole.Roles.MANAGER
|
||||||
|
)
|
||||||
|
domain_managers.update(user_domain_roles)
|
||||||
|
|
||||||
|
invited_managers: set[str] = set()
|
||||||
|
|
||||||
|
# Get the emails of invited managers
|
||||||
|
domain_invitations = DomainInvitation.objects.filter(
|
||||||
|
domain__in=domains, status=DomainInvitation.DomainInvitationStatus.INVITED
|
||||||
|
).values_list("email", flat=True)
|
||||||
|
invited_managers.update(domain_invitations)
|
||||||
|
|
||||||
|
for user_domain_role in domain_managers:
|
||||||
|
try:
|
||||||
|
# manager is a user id
|
||||||
|
user = user_domain_role.user
|
||||||
|
_, created = UserPortfolioPermission.objects.get_or_create(
|
||||||
|
portfolio=portfolio,
|
||||||
|
user=user,
|
||||||
|
defaults={"roles": [UserPortfolioRoleChoices.ORGANIZATION_MEMBER]},
|
||||||
|
)
|
||||||
|
self.added_managers.add(user)
|
||||||
|
if created:
|
||||||
|
logger.info(f"Added manager '{user}' to portfolio '{portfolio}'")
|
||||||
|
else:
|
||||||
|
logger.info(f"Manager '{user}' already exists in portfolio '{portfolio}'")
|
||||||
|
except User.DoesNotExist:
|
||||||
|
self.failed_managers.add(user)
|
||||||
|
logger.debug(f"User '{user}' does not exist")
|
||||||
|
|
||||||
|
for email in invited_managers:
|
||||||
|
self.create_portfolio_invitation(portfolio, email)
|
||||||
|
|
||||||
|
def create_portfolio_invitation(self, portfolio: Portfolio, email: str):
|
||||||
|
"""
|
||||||
|
Create a portfolio invitation for the given email.
|
||||||
|
"""
|
||||||
|
_, created = PortfolioInvitation.objects.get_or_create(
|
||||||
|
portfolio=portfolio,
|
||||||
|
email=email,
|
||||||
|
defaults={
|
||||||
|
"status": PortfolioInvitation.PortfolioInvitationStatus.INVITED,
|
||||||
|
"roles": [UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
self.added_invitations.add(email)
|
||||||
|
logger.info(f"Created portfolio invitation for '{email}' to portfolio '{portfolio}'")
|
||||||
|
else:
|
||||||
|
self.skipped_invitations.add(email)
|
||||||
|
logger.info(f"Found existing portfolio invitation for '{email}' to portfolio '{portfolio}'")
|
||||||
|
|
||||||
def post_process_started_domain_requests(self, agencies, portfolios):
|
def post_process_started_domain_requests(self, agencies, portfolios):
|
||||||
"""
|
"""
|
||||||
Removes duplicate organization data by clearing federal_agency when it matches the portfolio name.
|
Removes duplicate organization data by clearing federal_agency when it matches the portfolio name.
|
||||||
|
@ -160,6 +270,7 @@ class Command(BaseCommand):
|
||||||
# 2. Said portfolio (or portfolios) are only the ones specified at the start of the script.
|
# 2. Said portfolio (or portfolios) are only the ones specified at the start of the script.
|
||||||
# 3. The domain request is in status "started".
|
# 3. The domain request is in status "started".
|
||||||
# Note: Both names are normalized so excess spaces are stripped and the string is lowercased.
|
# Note: Both names are normalized so excess spaces are stripped and the string is lowercased.
|
||||||
|
|
||||||
domain_requests_to_update = DomainRequest.objects.filter(
|
domain_requests_to_update = DomainRequest.objects.filter(
|
||||||
federal_agency__in=agencies,
|
federal_agency__in=agencies,
|
||||||
federal_agency__agency__isnull=False,
|
federal_agency__agency__isnull=False,
|
||||||
|
|
|
@ -149,9 +149,9 @@ class Command(BaseCommand):
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
with transaction.atomic():
|
# Try to delete the portfolios
|
||||||
# Try to delete the portfolios
|
try:
|
||||||
try:
|
with transaction.atomic():
|
||||||
summary = []
|
summary = []
|
||||||
for portfolio in portfolios_to_delete:
|
for portfolio in portfolios_to_delete:
|
||||||
portfolio_summary = [f"---- CASCADE SUMMARY for {portfolio.organization_name} -----"]
|
portfolio_summary = [f"---- CASCADE SUMMARY for {portfolio.organization_name} -----"]
|
||||||
|
@ -222,14 +222,14 @@ class Command(BaseCommand):
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
except IntegrityError as e:
|
except IntegrityError as e:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"""{TerminalColors.FAIL}
|
f"""{TerminalColors.FAIL}
|
||||||
Could not delete some portfolios due to integrity constraints:
|
Could not delete some portfolios due to integrity constraints:
|
||||||
{e}
|
{e}
|
||||||
{TerminalColors.ENDC}
|
{TerminalColors.ENDC}
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
# Get all Portfolio entries not in the allowed portfolios list
|
# Get all Portfolio entries not in the allowed portfolios list
|
||||||
|
|
|
@ -0,0 +1,60 @@
|
||||||
|
# Generated by Django 4.2.10 on 2025-02-04 11:18
|
||||||
|
|
||||||
|
import django.contrib.postgres.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("registrar", "0139_alter_domainrequest_action_needed_reason"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="portfolioinvitation",
|
||||||
|
name="additional_permissions",
|
||||||
|
field=django.contrib.postgres.fields.ArrayField(
|
||||||
|
base_field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("view_all_domains", "View all domains and domain reports"),
|
||||||
|
("view_managed_domains", "View managed domains"),
|
||||||
|
("view_members", "View members"),
|
||||||
|
("edit_members", "Create and edit members"),
|
||||||
|
("view_all_requests", "View all requests"),
|
||||||
|
("edit_requests", "Create and edit requests"),
|
||||||
|
("view_portfolio", "View organization"),
|
||||||
|
("edit_portfolio", "Edit organization"),
|
||||||
|
],
|
||||||
|
max_length=50,
|
||||||
|
),
|
||||||
|
blank=True,
|
||||||
|
help_text="Select one or more additional permissions.",
|
||||||
|
null=True,
|
||||||
|
size=None,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userportfoliopermission",
|
||||||
|
name="additional_permissions",
|
||||||
|
field=django.contrib.postgres.fields.ArrayField(
|
||||||
|
base_field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("view_all_domains", "View all domains and domain reports"),
|
||||||
|
("view_managed_domains", "View managed domains"),
|
||||||
|
("view_members", "View members"),
|
||||||
|
("edit_members", "Create and edit members"),
|
||||||
|
("view_all_requests", "View all requests"),
|
||||||
|
("edit_requests", "Create and edit requests"),
|
||||||
|
("view_portfolio", "View organization"),
|
||||||
|
("edit_portfolio", "Edit organization"),
|
||||||
|
],
|
||||||
|
max_length=50,
|
||||||
|
),
|
||||||
|
blank=True,
|
||||||
|
help_text="Select one or more additional permissions.",
|
||||||
|
null=True,
|
||||||
|
size=None,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -9,6 +9,7 @@ from django_fsm import FSMField, transition, TransitionNotAllowed # type: ignor
|
||||||
from django.db import models, IntegrityError
|
from django.db import models, IntegrityError
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from registrar.models.domain_invitation import DomainInvitation
|
||||||
from registrar.models.host import Host
|
from registrar.models.host import Host
|
||||||
from registrar.models.host_ip import HostIP
|
from registrar.models.host_ip import HostIP
|
||||||
from registrar.utility.enums import DefaultEmail
|
from registrar.utility.enums import DefaultEmail
|
||||||
|
@ -1177,6 +1178,10 @@ class Domain(TimeStampedModel, DomainHelper):
|
||||||
return "DNS needed"
|
return "DNS needed"
|
||||||
return self.state.capitalize()
|
return self.state.capitalize()
|
||||||
|
|
||||||
|
def active_invitations(self):
|
||||||
|
"""Returns only the active invitations (those with status 'invited')."""
|
||||||
|
return self.invitations.filter(status=DomainInvitation.DomainInvitationStatus.INVITED)
|
||||||
|
|
||||||
def map_epp_contact_to_public_contact(self, contact: eppInfo.InfoContactResultData, contact_id, contact_type):
|
def map_epp_contact_to_public_contact(self, contact: eppInfo.InfoContactResultData, contact_id, contact_type):
|
||||||
"""Maps the Epp contact representation to a PublicContact object.
|
"""Maps the Epp contact representation to a PublicContact object.
|
||||||
|
|
||||||
|
|
|
@ -946,7 +946,7 @@ class DomainRequest(TimeStampedModel):
|
||||||
try:
|
try:
|
||||||
if not context:
|
if not context:
|
||||||
has_organization_feature_flag = flag_is_active_for_user(recipient, "organization_feature")
|
has_organization_feature_flag = flag_is_active_for_user(recipient, "organization_feature")
|
||||||
is_org_user = has_organization_feature_flag and recipient.has_base_portfolio_permission(self.portfolio)
|
is_org_user = has_organization_feature_flag and recipient.has_view_portfolio_permission(self.portfolio)
|
||||||
context = {
|
context = {
|
||||||
"domain_request": self,
|
"domain_request": self,
|
||||||
# This is the user that we refer to in the email
|
# This is the user that we refer to in the email
|
||||||
|
|
|
@ -210,10 +210,10 @@ class User(AbstractUser):
|
||||||
|
|
||||||
return portfolio_permission in user_portfolio_perms._get_portfolio_permissions()
|
return portfolio_permission in user_portfolio_perms._get_portfolio_permissions()
|
||||||
|
|
||||||
def has_base_portfolio_permission(self, portfolio):
|
def has_view_portfolio_permission(self, portfolio):
|
||||||
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_PORTFOLIO)
|
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_PORTFOLIO)
|
||||||
|
|
||||||
def has_edit_org_portfolio_permission(self, portfolio):
|
def has_edit_portfolio_permission(self, portfolio):
|
||||||
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_PORTFOLIO)
|
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_PORTFOLIO)
|
||||||
|
|
||||||
def has_any_domains_portfolio_permission(self, portfolio):
|
def has_any_domains_portfolio_permission(self, portfolio):
|
||||||
|
@ -268,13 +268,6 @@ class User(AbstractUser):
|
||||||
def has_edit_request_portfolio_permission(self, portfolio):
|
def has_edit_request_portfolio_permission(self, portfolio):
|
||||||
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_REQUESTS)
|
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_REQUESTS)
|
||||||
|
|
||||||
# Field specific permission checks
|
|
||||||
def has_view_suborganization_portfolio_permission(self, portfolio):
|
|
||||||
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION)
|
|
||||||
|
|
||||||
def has_edit_suborganization_portfolio_permission(self, portfolio):
|
|
||||||
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION)
|
|
||||||
|
|
||||||
def is_portfolio_admin(self, portfolio):
|
def is_portfolio_admin(self, portfolio):
|
||||||
return "Admin" in self.portfolio_role_summary(portfolio)
|
return "Admin" in self.portfolio_role_summary(portfolio)
|
||||||
|
|
||||||
|
@ -293,7 +286,7 @@ class User(AbstractUser):
|
||||||
|
|
||||||
# Define the conditions and their corresponding roles
|
# Define the conditions and their corresponding roles
|
||||||
conditions_roles = [
|
conditions_roles = [
|
||||||
(self.has_edit_suborganization_portfolio_permission(portfolio), ["Admin"]),
|
(self.has_edit_portfolio_permission(portfolio), ["Admin"]),
|
||||||
(
|
(
|
||||||
self.has_view_all_domains_portfolio_permission(portfolio)
|
self.has_view_all_domains_portfolio_permission(portfolio)
|
||||||
and self.has_any_requests_portfolio_permission(portfolio)
|
and self.has_any_requests_portfolio_permission(portfolio)
|
||||||
|
@ -306,20 +299,20 @@ class User(AbstractUser):
|
||||||
["View-only admin"],
|
["View-only admin"],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
self.has_base_portfolio_permission(portfolio)
|
self.has_view_portfolio_permission(portfolio)
|
||||||
and self.has_edit_request_portfolio_permission(portfolio)
|
and self.has_edit_request_portfolio_permission(portfolio)
|
||||||
and self.has_any_domains_portfolio_permission(portfolio),
|
and self.has_any_domains_portfolio_permission(portfolio),
|
||||||
["Domain requestor", "Domain manager"],
|
["Domain requestor", "Domain manager"],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
self.has_base_portfolio_permission(portfolio) and self.has_edit_request_portfolio_permission(portfolio),
|
self.has_view_portfolio_permission(portfolio) and self.has_edit_request_portfolio_permission(portfolio),
|
||||||
["Domain requestor"],
|
["Domain requestor"],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
self.has_base_portfolio_permission(portfolio) and self.has_any_domains_portfolio_permission(portfolio),
|
self.has_view_portfolio_permission(portfolio) and self.has_any_domains_portfolio_permission(portfolio),
|
||||||
["Domain manager"],
|
["Domain manager"],
|
||||||
),
|
),
|
||||||
(self.has_base_portfolio_permission(portfolio), ["Member"]),
|
(self.has_view_portfolio_permission(portfolio), ["Member"]),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Evaluate conditions and add roles
|
# Evaluate conditions and add roles
|
||||||
|
@ -477,7 +470,7 @@ class User(AbstractUser):
|
||||||
def is_org_user(self, request):
|
def is_org_user(self, request):
|
||||||
has_organization_feature_flag = flag_is_active(request, "organization_feature")
|
has_organization_feature_flag = flag_is_active(request, "organization_feature")
|
||||||
portfolio = request.session.get("portfolio")
|
portfolio = request.session.get("portfolio")
|
||||||
return has_organization_feature_flag and self.has_base_portfolio_permission(portfolio)
|
return has_organization_feature_flag and self.has_view_portfolio_permission(portfolio)
|
||||||
|
|
||||||
def get_user_domain_ids(self, request):
|
def get_user_domain_ids(self, request):
|
||||||
"""Returns either the domains ids associated with this user on UserDomainRole or Portfolio"""
|
"""Returns either the domains ids associated with this user on UserDomainRole or Portfolio"""
|
||||||
|
|
|
@ -27,13 +27,10 @@ class UserPortfolioPermission(TimeStampedModel):
|
||||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||||
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
||||||
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
|
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
|
||||||
UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION,
|
|
||||||
UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION,
|
|
||||||
],
|
],
|
||||||
# NOTE: Check FORBIDDEN_PORTFOLIO_ROLE_PERMISSIONS before adding roles here.
|
# NOTE: Check FORBIDDEN_PORTFOLIO_ROLE_PERMISSIONS before adding roles here.
|
||||||
UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [
|
UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [
|
||||||
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
||||||
UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION,
|
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -43,7 +40,6 @@ class UserPortfolioPermission(TimeStampedModel):
|
||||||
UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [
|
UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [
|
||||||
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
|
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
|
||||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||||
UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION,
|
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
from registrar.utility import StrEnum
|
from registrar.utility import StrEnum
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models import Q
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.forms import ValidationError
|
from django.forms import ValidationError
|
||||||
from registrar.utility.waffle import flag_is_active_for_user
|
from registrar.utility.waffle import flag_is_active_for_user
|
||||||
|
@ -41,10 +42,6 @@ class UserPortfolioPermissionChoices(models.TextChoices):
|
||||||
VIEW_PORTFOLIO = "view_portfolio", "View organization"
|
VIEW_PORTFOLIO = "view_portfolio", "View organization"
|
||||||
EDIT_PORTFOLIO = "edit_portfolio", "Edit organization"
|
EDIT_PORTFOLIO = "edit_portfolio", "Edit organization"
|
||||||
|
|
||||||
# Domain: field specific permissions
|
|
||||||
VIEW_SUBORGANIZATION = "view_suborganization", "View suborganization"
|
|
||||||
EDIT_SUBORGANIZATION = "edit_suborganization", "Edit suborganization"
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_user_portfolio_permission_label(cls, user_portfolio_permission):
|
def get_user_portfolio_permission_label(cls, user_portfolio_permission):
|
||||||
return cls(user_portfolio_permission).label if user_portfolio_permission else None
|
return cls(user_portfolio_permission).label if user_portfolio_permission else None
|
||||||
|
@ -136,9 +133,10 @@ def validate_user_portfolio_permission(user_portfolio_permission):
|
||||||
"Based on current waffle flag settings, users cannot be assigned to multiple portfolios."
|
"Based on current waffle flag settings, users cannot be assigned to multiple portfolios."
|
||||||
)
|
)
|
||||||
|
|
||||||
existing_invitations = PortfolioInvitation.objects.exclude(
|
existing_invitations = PortfolioInvitation.objects.filter(email=user_portfolio_permission.user.email).exclude(
|
||||||
portfolio=user_portfolio_permission.portfolio
|
Q(portfolio=user_portfolio_permission.portfolio)
|
||||||
).filter(email=user_portfolio_permission.user.email)
|
| Q(status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED)
|
||||||
|
)
|
||||||
if existing_invitations.exists():
|
if existing_invitations.exists():
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
"This user is already assigned to a portfolio invitation. "
|
"This user is already assigned to a portfolio invitation. "
|
||||||
|
@ -195,8 +193,8 @@ def validate_portfolio_invitation(portfolio_invitation):
|
||||||
if not flag_is_active_for_user(user, "multiple_portfolios"):
|
if not flag_is_active_for_user(user, "multiple_portfolios"):
|
||||||
existing_permissions = UserPortfolioPermission.objects.filter(user=user)
|
existing_permissions = UserPortfolioPermission.objects.filter(user=user)
|
||||||
|
|
||||||
existing_invitations = PortfolioInvitation.objects.exclude(id=portfolio_invitation.id).filter(
|
existing_invitations = PortfolioInvitation.objects.filter(email=portfolio_invitation.email).exclude(
|
||||||
email=portfolio_invitation.email
|
Q(id=portfolio_invitation.id) | Q(status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED)
|
||||||
)
|
)
|
||||||
|
|
||||||
if existing_permissions.exists():
|
if existing_permissions.exists():
|
||||||
|
|
|
@ -1,8 +1,21 @@
|
||||||
{% extends "admin/base_site.html" %}
|
{% extends "admin/base_site.html" %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
{% block content_title %}<h1>Registrar Analytics</h1>{% endblock %}
|
{% block content_title %}<h1>Registrar Analytics</h1>{% endblock %}
|
||||||
|
|
||||||
|
{% block breadcrumbs %}
|
||||||
|
{% comment %}
|
||||||
|
Overrides the breadcrumb styles found in this file:
|
||||||
|
https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/base.html
|
||||||
|
{% endcomment %}
|
||||||
|
<div class="breadcrumbs">
|
||||||
|
<a href="{% url "admin:index" %}">{% trans "Home" %}</a>
|
||||||
|
›
|
||||||
|
<span>{% trans "Analytics Dashboard" %}</span>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div id="content-main" class="custom-admin-template">
|
<div id="content-main" class="custom-admin-template">
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
{% extends 'admin/delete_confirmation.html' %}
|
||||||
|
{% load i18n static %}
|
||||||
|
|
||||||
|
{% block content_subtitle %}
|
||||||
|
<div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-2" role="alert">
|
||||||
|
<div class="usa-alert__body margin-left-1 maxw-none">
|
||||||
|
<p class="usa-alert__text maxw-none">
|
||||||
|
If you cancel the domain invitation here, it won't trigger any emails. It also won't remove
|
||||||
|
their domain management privileges if they already have that role assigned. Go to the
|
||||||
|
<a class="usa-link" href="{% url 'admin:registrar_userdomainrole_changelist' %}">User Domain Roles table</a>
|
||||||
|
if you want to remove the user from a domain.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ block.super }}
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,16 @@
|
||||||
|
{% extends 'admin/delete_selected_confirmation.html' %}
|
||||||
|
{% load i18n static %}
|
||||||
|
|
||||||
|
{% block content_subtitle %}
|
||||||
|
<div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-2" role="alert">
|
||||||
|
<div class="usa-alert__body margin-left-1 maxw-none">
|
||||||
|
<p class="usa-alert__text maxw-none">
|
||||||
|
If you cancel the domain invitation here, it won't trigger any emails. It also won't remove
|
||||||
|
their domain management privileges if they already have that role assigned. Go to the
|
||||||
|
<a class="usa-link" href="{% url 'admin:registrar_userdomainrole_changelist' %}">User Domain Roles table</a>
|
||||||
|
if you want to remove the user from a domain.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ block.super }}
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,13 @@
|
||||||
|
{% extends 'admin/delete_confirmation.html' %}
|
||||||
|
{% load i18n static %}
|
||||||
|
|
||||||
|
{% block content_subtitle %}
|
||||||
|
<div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-2" role="alert">
|
||||||
|
<div class="usa-alert__body margin-left-1 maxw-none">
|
||||||
|
<p class="usa-alert__text maxw-none">
|
||||||
|
If you remove someone from a domain here, it won't trigger any emails when you click "save."
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ block.super }}
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,13 @@
|
||||||
|
{% extends 'admin/delete_selected_confirmation.html' %}
|
||||||
|
{% load i18n static %}
|
||||||
|
|
||||||
|
{% block content_subtitle %}
|
||||||
|
<div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-2" role="alert">
|
||||||
|
<div class="usa-alert__body margin-left-1 maxw-none">
|
||||||
|
<p class="usa-alert__text maxw-none">
|
||||||
|
If you remove someone from a domain here, it won't trigger any emails when you click "save."
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ block.super }}
|
||||||
|
{% endblock %}
|
|
@ -103,12 +103,12 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if portfolio %}
|
{% if portfolio %}
|
||||||
{% if has_any_domains_portfolio_permission and has_edit_suborganization_portfolio_permission %}
|
{% if has_any_domains_portfolio_permission and has_edit_portfolio_permission %}
|
||||||
{% url 'domain-suborganization' pk=domain.id as url %}
|
{% url 'domain-suborganization' pk=domain.id as url %}
|
||||||
{% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:has_edit_suborganization_portfolio_permission %}
|
{% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:has_edit_portfolio_permission %}
|
||||||
{% elif has_any_domains_portfolio_permission and has_view_suborganization_portfolio_permission %}
|
{% elif has_any_domains_portfolio_permission and has_view_portfolio_permission %}
|
||||||
{% url 'domain-suborganization' pk=domain.id as url %}
|
{% url 'domain-suborganization' pk=domain.id as url %}
|
||||||
{% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:has_view_suborganization_portfolio_permission view_button=True %}
|
{% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:has_view_portfolio_permission view_button=True %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% url 'domain-org-name-address' pk=domain.id as url %}
|
{% url 'domain-org-name-address' pk=domain.id as url %}
|
||||||
|
|
|
@ -61,7 +61,7 @@
|
||||||
|
|
||||||
{% if portfolio %}
|
{% if portfolio %}
|
||||||
{% comment %} Only show this menu option if the user has the perms to do so {% endcomment %}
|
{% comment %} Only show this menu option if the user has the perms to do so {% endcomment %}
|
||||||
{% if has_any_domains_portfolio_permission and has_view_suborganization_portfolio_permission %}
|
{% if has_any_domains_portfolio_permission and has_view_portfolio_permission %}
|
||||||
{% with url_name="domain-suborganization" %}
|
{% with url_name="domain-suborganization" %}
|
||||||
{% include "includes/domain_sidenav_item.html" with item_text="Suborganization" %}
|
{% include "includes/domain_sidenav_item.html" with item_text="Suborganization" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
|
@ -39,7 +39,7 @@
|
||||||
please contact <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
|
please contact <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% if has_any_domains_portfolio_permission and has_edit_suborganization_portfolio_permission %}
|
{% if has_any_domains_portfolio_permission and has_edit_portfolio_permission %}
|
||||||
<form class="usa-form usa-form--large" method="post" novalidate id="form-container">
|
<form class="usa-form usa-form--large" method="post" novalidate id="form-container">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% input_with_errors form.sub_organization %}
|
{% input_with_errors form.sub_organization %}
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
|
||||||
|
Hi,{% if domain_manager and domain_manager.first_name %} {{ domain_manager.first_name }}.{% endif %}
|
||||||
|
|
||||||
|
A domain manager was removed from {{ domain.name }}.
|
||||||
|
|
||||||
|
REMOVED BY: {{ removed_by.email }}
|
||||||
|
REMOVED ON: {{ date }}
|
||||||
|
MANAGER REMOVED: {{ manager_removed.email }}
|
||||||
|
|
||||||
|
----------------------------------------------------------------
|
||||||
|
|
||||||
|
WHY DID YOU RECEIVE THIS EMAIL?
|
||||||
|
You’re listed as a domain manager for {{ domain.name }}, so you’ll receive a notification whenever a domain manager is removed from that domain.
|
||||||
|
If you have questions or concerns, reach out to the person who removed the domain manager or reply to this email.
|
||||||
|
|
||||||
|
THANK YOU
|
||||||
|
.Gov helps the public identify official, trusted information. Thank you for using a .gov domain.
|
||||||
|
|
||||||
|
----------------------------------------------------------------
|
||||||
|
|
||||||
|
The .gov team
|
||||||
|
Contact us: <https://get.gov/contact/>
|
||||||
|
Learn about .gov <https://get.gov>
|
||||||
|
|
||||||
|
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency
|
||||||
|
(CISA) <https://cisa.gov/>
|
||||||
|
{% endautoescape %}
|
|
@ -0,0 +1 @@
|
||||||
|
A domain manager was removed from {{ domain.name }}
|
|
@ -0,0 +1,40 @@
|
||||||
|
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
|
||||||
|
Hi,{% if portfolio_admin and portfolio_admin.first_name %} {{ portfolio_admin.first_name }}.{% endif %}
|
||||||
|
|
||||||
|
An admin was invited to your .gov organization.
|
||||||
|
|
||||||
|
ORGANIZATION: {{ portfolio.organization_name }}
|
||||||
|
INVITED BY: {{ requestor_email }}
|
||||||
|
INVITED ON: {{date}}
|
||||||
|
ADMIN INVITED: {{ invited_email_address }}
|
||||||
|
|
||||||
|
----------------------------------------------------------------
|
||||||
|
|
||||||
|
NEXT STEPS
|
||||||
|
The person who received the invitation will become an admin once they log in to the
|
||||||
|
.gov registrar. They'll need to access the registrar using a Login.gov account that's
|
||||||
|
associated with the invited email address.
|
||||||
|
|
||||||
|
If you need to cancel this invitation or remove the admin, you can do that by going to
|
||||||
|
the Members section for your organization <https://manage.get.gov/>.
|
||||||
|
|
||||||
|
|
||||||
|
WHY DID YOU RECEIVE THIS EMAIL?
|
||||||
|
You’re listed as an admin for {{ portfolio.organization_name }}. That means you'll receive a notification
|
||||||
|
whenever a new admin is invited to that organization.
|
||||||
|
|
||||||
|
If you have questions or concerns, reach out to the person who sent the invitation or reply to this email.
|
||||||
|
|
||||||
|
|
||||||
|
THANK YOU
|
||||||
|
.Gov helps the public identify official, trusted information. Thank you for using a .gov domain.
|
||||||
|
|
||||||
|
----------------------------------------------------------------
|
||||||
|
|
||||||
|
The .gov team
|
||||||
|
Contact us: <https://get.gov/contact/>
|
||||||
|
Learn about .gov <https://get.gov>
|
||||||
|
|
||||||
|
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency
|
||||||
|
(CISA) <https://cisa.gov/>
|
||||||
|
{% endautoescape %}
|
|
@ -0,0 +1 @@
|
||||||
|
An admin was invited to your .gov organization
|
|
@ -0,0 +1,33 @@
|
||||||
|
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
|
||||||
|
Hi,{% if portfolio_admin and portfolio_admin.first_name %} {{ portfolio_admin.first_name }}.{% endif %}
|
||||||
|
|
||||||
|
An admin was removed from your .gov organization.
|
||||||
|
|
||||||
|
ORGANIZATION: {{ portfolio.organization_name }}
|
||||||
|
REMOVED BY: {{ requestor_email }}
|
||||||
|
REMOVED ON: {{date}}
|
||||||
|
ADMIN REMOVED: {{ removed_email_address }}
|
||||||
|
|
||||||
|
You can view this update by going to the Members section for your .gov organization <https://manage.get.gov/>.
|
||||||
|
|
||||||
|
----------------------------------------------------------------
|
||||||
|
|
||||||
|
WHY DID YOU RECEIVE THIS EMAIL?
|
||||||
|
You’re listed as an admin for {{ portfolio.organization_name }}. That means you'll receive a notification
|
||||||
|
whenever an admin is removed from that organization.
|
||||||
|
|
||||||
|
If you have questions or concerns, reach out to the person who removed the admin or reply to this email.
|
||||||
|
|
||||||
|
|
||||||
|
THANK YOU
|
||||||
|
.Gov helps the public identify official, trusted information. Thank you for using a .gov domain.
|
||||||
|
|
||||||
|
----------------------------------------------------------------
|
||||||
|
|
||||||
|
The .gov team
|
||||||
|
Contact us: <https://get.gov/contact/>
|
||||||
|
Learn about .gov <https://get.gov>
|
||||||
|
|
||||||
|
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency
|
||||||
|
(CISA) <https://cisa.gov/>
|
||||||
|
{% endautoescape %}
|
|
@ -0,0 +1 @@
|
||||||
|
An admin was removed from your .gov organization
|
|
@ -208,7 +208,7 @@
|
||||||
<th data-sortable="name" scope="col" role="columnheader">Domain name</th>
|
<th data-sortable="name" scope="col" role="columnheader">Domain name</th>
|
||||||
<th data-sortable="expiration_date" scope="col" role="columnheader">Expires</th>
|
<th data-sortable="expiration_date" scope="col" role="columnheader">Expires</th>
|
||||||
<th data-sortable="state_display" scope="col" role="columnheader">Status</th>
|
<th data-sortable="state_display" scope="col" role="columnheader">Status</th>
|
||||||
{% if portfolio and has_view_suborganization_portfolio_permission %}
|
{% if portfolio and has_view_portfolio_permission %}
|
||||||
<th data-sortable="domain_info__sub_organization" scope="col" role="columnheader">Suborganization</th>
|
<th data-sortable="domain_info__sub_organization" scope="col" role="columnheader">Suborganization</th>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<th
|
<th
|
||||||
|
|
|
@ -59,7 +59,7 @@
|
||||||
role="columnheader"
|
role="columnheader"
|
||||||
id="header-action"
|
id="header-action"
|
||||||
>
|
>
|
||||||
<span class="usa-sr-only">Action</span>
|
Action
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
|
@ -113,10 +113,10 @@
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if value.invitations.all %}
|
{% if value.active_invitations.all %}
|
||||||
<h4 class="margin-bottom-05">Invited domain managers</h4>
|
<h4 class="margin-bottom-05">Invited domain managers</h4>
|
||||||
<ul class="usa-list usa-list--unstyled margin-top-0">
|
<ul class="usa-list usa-list--unstyled margin-top-0">
|
||||||
{% for item in value.invitations.all %}
|
{% for item in value.active_invitations.all %}
|
||||||
<li>{{ item.email }}</li>
|
<li>{{ item.email }}</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
{# the entire logged in page goes here #}
|
{# the entire logged in page goes here #}
|
||||||
|
|
||||||
<div class="grid-row {% if not is_widescreen_centered %}max-width--grid-container{% endif %}">
|
<div class="grid-row {% if not is_widescreen_centered %}max-width--grid-container{% endif %}">
|
||||||
<div class="tablet:grid-col-11 desktop:grid-col-10 {% if is_widescreen_centered %}tablet:grid-offset-1{% endif %}">
|
<div class="desktop:grid-col-10 {% if not is_widescreen_centered %}tablet:grid-col-11 {% else %}tablet:padding-left-4 tablet:padding-right-4 tablet:grid-col-12 desktop:grid-offset-1{% endif %}">
|
||||||
|
|
||||||
{% block portfolio_content %}{% endblock %}
|
{% block portfolio_content %}{% endblock %}
|
||||||
|
|
||||||
|
|
|
@ -28,7 +28,7 @@
|
||||||
|
|
||||||
<p>The name of your organization will be publicly listed as the domain registrant.</p>
|
<p>The name of your organization will be publicly listed as the domain registrant.</p>
|
||||||
|
|
||||||
{% if has_edit_org_portfolio_permission %}
|
{% if has_edit_portfolio_permission %}
|
||||||
<p>
|
<p>
|
||||||
Your organization name can’t be updated here.
|
Your organization name can’t be updated here.
|
||||||
To suggest an update, email <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
|
To suggest an update, email <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
|
||||||
|
|
|
@ -120,7 +120,7 @@ class TestFsmModelResource(TestCase):
|
||||||
fsm_field_mock.save.assert_not_called()
|
fsm_field_mock.save.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
class TestDomainInvitationAdmin(TestCase):
|
class TestDomainInvitationAdmin(WebTest):
|
||||||
"""Tests for the DomainInvitationAdmin class as super user
|
"""Tests for the DomainInvitationAdmin class as super user
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
@ -128,15 +128,27 @@ class TestDomainInvitationAdmin(TestCase):
|
||||||
tests have available superuser, client, and admin
|
tests have available superuser, client, and admin
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def setUp(self):
|
# csrf checks do not work with WebTest.
|
||||||
|
# We disable them here. TODO for another ticket.
|
||||||
|
csrf_checks = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(self):
|
||||||
|
super().setUpClass()
|
||||||
|
self.site = AdminSite()
|
||||||
self.factory = RequestFactory()
|
self.factory = RequestFactory()
|
||||||
self.admin = ListHeaderAdmin(model=DomainInvitationAdmin, admin_site=AdminSite())
|
|
||||||
self.superuser = create_superuser()
|
self.superuser = create_superuser()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.admin = ListHeaderAdmin(model=DomainInvitationAdmin, admin_site=AdminSite())
|
||||||
self.domain = Domain.objects.create(name="example.com")
|
self.domain = Domain.objects.create(name="example.com")
|
||||||
self.portfolio = Portfolio.objects.create(organization_name="new portfolio", creator=self.superuser)
|
self.portfolio = Portfolio.objects.create(organization_name="new portfolio", creator=self.superuser)
|
||||||
DomainInformation.objects.create(domain=self.domain, portfolio=self.portfolio, creator=self.superuser)
|
DomainInformation.objects.create(domain=self.domain, portfolio=self.portfolio, creator=self.superuser)
|
||||||
"""Create a client object"""
|
"""Create a client object"""
|
||||||
self.client = Client(HTTP_HOST="localhost:8080")
|
self.client = Client(HTTP_HOST="localhost:8080")
|
||||||
|
self.client.force_login(self.superuser)
|
||||||
|
self.app.set_user(self.superuser.username)
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
"""Delete all DomainInvitation objects"""
|
"""Delete all DomainInvitation objects"""
|
||||||
|
@ -254,6 +266,7 @@ class TestDomainInvitationAdmin(TestCase):
|
||||||
email="test@example.com",
|
email="test@example.com",
|
||||||
requestor=self.superuser,
|
requestor=self.superuser,
|
||||||
portfolio=self.portfolio,
|
portfolio=self.portfolio,
|
||||||
|
is_admin_invitation=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Assert success message
|
# Assert success message
|
||||||
|
@ -504,6 +517,7 @@ class TestDomainInvitationAdmin(TestCase):
|
||||||
email="test@example.com",
|
email="test@example.com",
|
||||||
requestor=self.superuser,
|
requestor=self.superuser,
|
||||||
portfolio=self.portfolio,
|
portfolio=self.portfolio,
|
||||||
|
is_admin_invitation=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Assert retrieve on domain invite only was called
|
# Assert retrieve on domain invite only was called
|
||||||
|
@ -567,6 +581,7 @@ class TestDomainInvitationAdmin(TestCase):
|
||||||
email="test@example.com",
|
email="test@example.com",
|
||||||
requestor=self.superuser,
|
requestor=self.superuser,
|
||||||
portfolio=self.portfolio,
|
portfolio=self.portfolio,
|
||||||
|
is_admin_invitation=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Assert retrieve on domain invite only was called
|
# Assert retrieve on domain invite only was called
|
||||||
|
@ -693,6 +708,7 @@ class TestDomainInvitationAdmin(TestCase):
|
||||||
email="nonexistent@example.com",
|
email="nonexistent@example.com",
|
||||||
requestor=self.superuser,
|
requestor=self.superuser,
|
||||||
portfolio=self.portfolio,
|
portfolio=self.portfolio,
|
||||||
|
is_admin_invitation=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Assert retrieve was not called
|
# Assert retrieve was not called
|
||||||
|
@ -918,6 +934,7 @@ class TestDomainInvitationAdmin(TestCase):
|
||||||
email="nonexistent@example.com",
|
email="nonexistent@example.com",
|
||||||
requestor=self.superuser,
|
requestor=self.superuser,
|
||||||
portfolio=self.portfolio,
|
portfolio=self.portfolio,
|
||||||
|
is_admin_invitation=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Assert retrieve on domain invite only was called
|
# Assert retrieve on domain invite only was called
|
||||||
|
@ -979,6 +996,7 @@ class TestDomainInvitationAdmin(TestCase):
|
||||||
email="nonexistent@example.com",
|
email="nonexistent@example.com",
|
||||||
requestor=self.superuser,
|
requestor=self.superuser,
|
||||||
portfolio=self.portfolio,
|
portfolio=self.portfolio,
|
||||||
|
is_admin_invitation=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Assert retrieve on domain invite only was called
|
# Assert retrieve on domain invite only was called
|
||||||
|
@ -1065,6 +1083,50 @@ class TestDomainInvitationAdmin(TestCase):
|
||||||
self.assertEqual(DomainInvitation.objects.count(), 0)
|
self.assertEqual(DomainInvitation.objects.count(), 0)
|
||||||
self.assertEqual(PortfolioInvitation.objects.count(), 1)
|
self.assertEqual(PortfolioInvitation.objects.count(), 1)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_custom_delete_confirmation_page(self):
|
||||||
|
"""Tests if custom alerts display on Domain Invitation delete page"""
|
||||||
|
self.client.force_login(self.superuser)
|
||||||
|
self.app.set_user(self.superuser.username)
|
||||||
|
domain, _ = Domain.objects.get_or_create(name="domain-invitation-test.gov", state=Domain.State.READY)
|
||||||
|
domain_invitation, _ = DomainInvitation.objects.get_or_create(domain=domain)
|
||||||
|
|
||||||
|
domain_invitation_change_page = self.app.get(
|
||||||
|
reverse("admin:registrar_domaininvitation_change", args=[domain_invitation.pk])
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertContains(domain_invitation_change_page, "domain-invitation-test.gov")
|
||||||
|
# click the "Delete" link
|
||||||
|
confirmation_page = domain_invitation_change_page.click("Delete", index=0)
|
||||||
|
|
||||||
|
custom_alert_content = "If you cancel the domain invitation here"
|
||||||
|
self.assertContains(confirmation_page, custom_alert_content)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_custom_selected_delete_confirmation_page(self):
|
||||||
|
"""Tests if custom alerts display on Domain Invitation selected delete page from Domain Invitation table"""
|
||||||
|
domain, _ = Domain.objects.get_or_create(name="domain-invitation-test.gov", state=Domain.State.READY)
|
||||||
|
domain_invitation, _ = DomainInvitation.objects.get_or_create(domain=domain)
|
||||||
|
|
||||||
|
# Get the index. The post expects the index to be encoded as a string
|
||||||
|
index = f"{domain_invitation.id}"
|
||||||
|
|
||||||
|
test_helper = GenericTestHelper(
|
||||||
|
factory=self.factory,
|
||||||
|
user=self.superuser,
|
||||||
|
admin=self.admin,
|
||||||
|
url=reverse("admin:registrar_domaininvitation_changelist"),
|
||||||
|
model=Domain,
|
||||||
|
client=self.client,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Simulate selecting a single record, then clicking "Delete selected domains"
|
||||||
|
response = test_helper.get_table_delete_confirmation_page("0", index)
|
||||||
|
|
||||||
|
# Check for custom alert message
|
||||||
|
custom_alert_content = "If you cancel the domain invitation here"
|
||||||
|
self.assertContains(response, custom_alert_content)
|
||||||
|
|
||||||
|
|
||||||
class TestUserPortfolioPermissionAdmin(TestCase):
|
class TestUserPortfolioPermissionAdmin(TestCase):
|
||||||
"""Tests for the PortfolioInivtationAdmin class"""
|
"""Tests for the PortfolioInivtationAdmin class"""
|
||||||
|
@ -1204,7 +1266,7 @@ class TestPortfolioInvitationAdmin(TestCase):
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
@patch("registrar.admin.send_portfolio_invitation_email")
|
@patch("registrar.admin.send_portfolio_invitation_email")
|
||||||
@patch("django.contrib.messages.success") # Mock the `messages.warning` call
|
@patch("django.contrib.messages.success") # Mock the `messages.success` call
|
||||||
def test_save_sends_email(self, mock_messages_success, mock_send_email):
|
def test_save_sends_email(self, mock_messages_success, mock_send_email):
|
||||||
"""On save_model, an email is sent if an invitation already exists."""
|
"""On save_model, an email is sent if an invitation already exists."""
|
||||||
|
|
||||||
|
@ -1455,6 +1517,94 @@ class TestPortfolioInvitationAdmin(TestCase):
|
||||||
# Assert that messages.error was called with the correct message
|
# Assert that messages.error was called with the correct message
|
||||||
mock_messages_error.assert_called_once_with(request, "Could not send email invitation.")
|
mock_messages_error.assert_called_once_with(request, "Could not send email invitation.")
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@patch("registrar.admin.send_portfolio_admin_addition_emails")
|
||||||
|
def test_save_existing_sends_email_notification(self, mock_send_email):
|
||||||
|
"""On save_model to an existing invitation, an email is set to notify existing
|
||||||
|
admins, if the invitation changes from member to admin."""
|
||||||
|
|
||||||
|
# Create an instance of the admin class
|
||||||
|
admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=None)
|
||||||
|
|
||||||
|
# Mock the response value of the email send
|
||||||
|
mock_send_email.return_value = True
|
||||||
|
|
||||||
|
# Create and save a PortfolioInvitation instance
|
||||||
|
portfolio_invitation = PortfolioInvitation.objects.create(
|
||||||
|
email="james.gordon@gotham.gov",
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], # Initially NOT an admin
|
||||||
|
status=PortfolioInvitation.PortfolioInvitationStatus.INVITED, # Must be "INVITED"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a request object
|
||||||
|
request = self.factory.post(f"/admin/registrar/PortfolioInvitation/{portfolio_invitation.pk}/change/")
|
||||||
|
request.user = self.superuser
|
||||||
|
|
||||||
|
# Change roles from MEMBER to ADMIN
|
||||||
|
portfolio_invitation.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
|
|
||||||
|
# Call the save_model method
|
||||||
|
admin_instance.save_model(request, portfolio_invitation, None, True)
|
||||||
|
|
||||||
|
# Assert that send_portfolio_admin_addition_emails is called
|
||||||
|
mock_send_email.assert_called_once()
|
||||||
|
|
||||||
|
# Get the arguments passed to send_portfolio_admin_addition_emails
|
||||||
|
_, called_kwargs = mock_send_email.call_args
|
||||||
|
|
||||||
|
# Assert the email content
|
||||||
|
self.assertEqual(called_kwargs["email"], "james.gordon@gotham.gov")
|
||||||
|
self.assertEqual(called_kwargs["requestor"], self.superuser)
|
||||||
|
self.assertEqual(called_kwargs["portfolio"], self.portfolio)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@patch("registrar.admin.send_portfolio_admin_addition_emails")
|
||||||
|
@patch("django.contrib.messages.warning") # Mock the `messages.warning` call
|
||||||
|
def test_save_existing_email_notification_warning(self, mock_messages_warning, mock_send_email):
|
||||||
|
"""On save_model for an existing invitation, a warning is displayed if method to
|
||||||
|
send email to notify admins returns False."""
|
||||||
|
|
||||||
|
# Create an instance of the admin class
|
||||||
|
admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=None)
|
||||||
|
|
||||||
|
# Mock the response value of the email send
|
||||||
|
mock_send_email.return_value = False
|
||||||
|
|
||||||
|
# Create and save a PortfolioInvitation instance
|
||||||
|
portfolio_invitation = PortfolioInvitation.objects.create(
|
||||||
|
email="james.gordon@gotham.gov",
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], # Initially NOT an admin
|
||||||
|
status=PortfolioInvitation.PortfolioInvitationStatus.INVITED, # Must be "INVITED"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a request object
|
||||||
|
request = self.factory.post(f"/admin/registrar/PortfolioInvitation/{portfolio_invitation.pk}/change/")
|
||||||
|
request.user = self.superuser
|
||||||
|
|
||||||
|
# Change roles from MEMBER to ADMIN
|
||||||
|
portfolio_invitation.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
|
|
||||||
|
# Call the save_model method
|
||||||
|
admin_instance.save_model(request, portfolio_invitation, None, True)
|
||||||
|
|
||||||
|
# Assert that send_portfolio_admin_addition_emails is called
|
||||||
|
mock_send_email.assert_called_once()
|
||||||
|
|
||||||
|
# Get the arguments passed to send_portfolio_admin_addition_emails
|
||||||
|
_, called_kwargs = mock_send_email.call_args
|
||||||
|
|
||||||
|
# Assert the email content
|
||||||
|
self.assertEqual(called_kwargs["email"], "james.gordon@gotham.gov")
|
||||||
|
self.assertEqual(called_kwargs["requestor"], self.superuser)
|
||||||
|
self.assertEqual(called_kwargs["portfolio"], self.portfolio)
|
||||||
|
|
||||||
|
# Assert that messages.error was called with the correct message
|
||||||
|
mock_messages_warning.assert_called_once_with(
|
||||||
|
request, "Could not send email notification to existing organization admins."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestHostAdmin(TestCase):
|
class TestHostAdmin(TestCase):
|
||||||
"""Tests for the HostAdmin class as super user
|
"""Tests for the HostAdmin class as super user
|
||||||
|
@ -1922,7 +2072,7 @@ class TestDomainInformationAdmin(TestCase):
|
||||||
self.test_helper.assert_table_sorted("-4", ("-creator__first_name", "-creator__last_name"))
|
self.test_helper.assert_table_sorted("-4", ("-creator__first_name", "-creator__last_name"))
|
||||||
|
|
||||||
|
|
||||||
class TestUserDomainRoleAdmin(TestCase):
|
class TestUserDomainRoleAdmin(WebTest):
|
||||||
"""Tests for the UserDomainRoleAdmin class as super user
|
"""Tests for the UserDomainRoleAdmin class as super user
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
@ -1949,6 +2099,8 @@ class TestUserDomainRoleAdmin(TestCase):
|
||||||
"""Setup environment for a mock admin user"""
|
"""Setup environment for a mock admin user"""
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.client = Client(HTTP_HOST="localhost:8080")
|
self.client = Client(HTTP_HOST="localhost:8080")
|
||||||
|
self.client.force_login(self.superuser)
|
||||||
|
self.app.set_user(self.superuser.username)
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
"""Delete all Users, Domains, and UserDomainRoles"""
|
"""Delete all Users, Domains, and UserDomainRoles"""
|
||||||
|
@ -2111,6 +2263,48 @@ class TestUserDomainRoleAdmin(TestCase):
|
||||||
# We only need to check for the end of the HTML string
|
# We only need to check for the end of the HTML string
|
||||||
self.assertContains(response, "Joe Jones AntarcticPolarBears@example.com</a></th>", count=1)
|
self.assertContains(response, "Joe Jones AntarcticPolarBears@example.com</a></th>", count=1)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_custom_delete_confirmation_page(self):
|
||||||
|
"""Tests if custom alerts display on User Domain Role delete page"""
|
||||||
|
domain, _ = Domain.objects.get_or_create(name="user-domain-role-test.gov", state=Domain.State.READY)
|
||||||
|
domain_role, _ = UserDomainRole.objects.get_or_create(domain=domain, user=self.superuser)
|
||||||
|
|
||||||
|
domain_invitation_change_page = self.app.get(
|
||||||
|
reverse("admin:registrar_userdomainrole_change", args=[domain_role.pk])
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertContains(domain_invitation_change_page, "user-domain-role-test.gov")
|
||||||
|
# click the "Delete" link
|
||||||
|
confirmation_page = domain_invitation_change_page.click("Delete", index=0)
|
||||||
|
|
||||||
|
custom_alert_content = "If you remove someone from a domain here"
|
||||||
|
self.assertContains(confirmation_page, custom_alert_content)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_custom_selected_delete_confirmation_page(self):
|
||||||
|
"""Tests if custom alerts display on selected delete page from User Domain Roles table"""
|
||||||
|
domain, _ = Domain.objects.get_or_create(name="domain-invitation-test.gov", state=Domain.State.READY)
|
||||||
|
domain_role, _ = UserDomainRole.objects.get_or_create(domain=domain, user=self.superuser)
|
||||||
|
|
||||||
|
# Get the index. The post expects the index to be encoded as a string
|
||||||
|
index = f"{domain_role.id}"
|
||||||
|
|
||||||
|
test_helper = GenericTestHelper(
|
||||||
|
factory=self.factory,
|
||||||
|
user=self.superuser,
|
||||||
|
admin=self.admin,
|
||||||
|
url=reverse("admin:registrar_userdomainrole_changelist"),
|
||||||
|
model=Domain,
|
||||||
|
client=self.client,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Simulate selecting a single record, then clicking "Delete selected domains"
|
||||||
|
response = test_helper.get_table_delete_confirmation_page("0", index)
|
||||||
|
|
||||||
|
# Check for custom alert message
|
||||||
|
custom_alert_content = "If you remove someone from a domain here"
|
||||||
|
self.assertContains(response, custom_alert_content)
|
||||||
|
|
||||||
|
|
||||||
class TestListHeaderAdmin(TestCase):
|
class TestListHeaderAdmin(TestCase):
|
||||||
"""Tests for the ListHeaderAdmin class as super user
|
"""Tests for the ListHeaderAdmin class as super user
|
||||||
|
|
|
@ -2,12 +2,24 @@ import unittest
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from registrar.models.domain import Domain
|
from registrar.models.domain import Domain
|
||||||
|
from registrar.models.portfolio import 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
|
||||||
|
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||||
|
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
||||||
from registrar.utility.email import EmailSendingError
|
from registrar.utility.email import EmailSendingError
|
||||||
from registrar.utility.email_invitations import send_domain_invitation_email, send_emails_to_domain_managers
|
from registrar.utility.email_invitations import (
|
||||||
|
_send_portfolio_admin_addition_emails_to_portfolio_admins,
|
||||||
|
_send_portfolio_admin_removal_emails_to_portfolio_admins,
|
||||||
|
send_domain_invitation_email,
|
||||||
|
send_emails_to_domain_managers,
|
||||||
|
send_portfolio_admin_addition_emails,
|
||||||
|
send_portfolio_admin_removal_emails,
|
||||||
|
send_portfolio_invitation_email,
|
||||||
|
)
|
||||||
|
|
||||||
from api.tests.common import less_console_noise_decorator
|
from api.tests.common import less_console_noise_decorator
|
||||||
|
from registrar.utility.errors import MissingEmailError
|
||||||
|
|
||||||
|
|
||||||
class DomainInvitationEmail(unittest.TestCase):
|
class DomainInvitationEmail(unittest.TestCase):
|
||||||
|
@ -16,9 +28,9 @@ class DomainInvitationEmail(unittest.TestCase):
|
||||||
@patch("registrar.utility.email_invitations.send_templated_email")
|
@patch("registrar.utility.email_invitations.send_templated_email")
|
||||||
@patch("registrar.utility.email_invitations.UserDomainRole.objects.filter")
|
@patch("registrar.utility.email_invitations.UserDomainRole.objects.filter")
|
||||||
@patch("registrar.utility.email_invitations._validate_invitation")
|
@patch("registrar.utility.email_invitations._validate_invitation")
|
||||||
@patch("registrar.utility.email_invitations.get_requestor_email")
|
@patch("registrar.utility.email_invitations._get_requestor_email")
|
||||||
@patch("registrar.utility.email_invitations.send_invitation_email")
|
@patch("registrar.utility.email_invitations.send_invitation_email")
|
||||||
@patch("registrar.utility.email_invitations.normalize_domains")
|
@patch("registrar.utility.email_invitations._normalize_domains")
|
||||||
def test_send_domain_invitation_email(
|
def test_send_domain_invitation_email(
|
||||||
self,
|
self,
|
||||||
mock_normalize_domains,
|
mock_normalize_domains,
|
||||||
|
@ -58,7 +70,7 @@ class DomainInvitationEmail(unittest.TestCase):
|
||||||
|
|
||||||
# Assertions
|
# Assertions
|
||||||
mock_normalize_domains.assert_called_once_with(mock_domain)
|
mock_normalize_domains.assert_called_once_with(mock_domain)
|
||||||
mock_get_requestor_email.assert_called_once_with(mock_requestor, [mock_domain])
|
mock_get_requestor_email.assert_called_once_with(mock_requestor, domains=[mock_domain])
|
||||||
mock_validate_invitation.assert_called_once_with(
|
mock_validate_invitation.assert_called_once_with(
|
||||||
email, None, [mock_domain], mock_requestor, is_member_of_different_org
|
email, None, [mock_domain], mock_requestor, is_member_of_different_org
|
||||||
)
|
)
|
||||||
|
@ -81,9 +93,9 @@ class DomainInvitationEmail(unittest.TestCase):
|
||||||
@patch("registrar.utility.email_invitations.send_templated_email")
|
@patch("registrar.utility.email_invitations.send_templated_email")
|
||||||
@patch("registrar.utility.email_invitations.UserDomainRole.objects.filter")
|
@patch("registrar.utility.email_invitations.UserDomainRole.objects.filter")
|
||||||
@patch("registrar.utility.email_invitations._validate_invitation")
|
@patch("registrar.utility.email_invitations._validate_invitation")
|
||||||
@patch("registrar.utility.email_invitations.get_requestor_email")
|
@patch("registrar.utility.email_invitations._get_requestor_email")
|
||||||
@patch("registrar.utility.email_invitations.send_invitation_email")
|
@patch("registrar.utility.email_invitations.send_invitation_email")
|
||||||
@patch("registrar.utility.email_invitations.normalize_domains")
|
@patch("registrar.utility.email_invitations._normalize_domains")
|
||||||
def test_send_domain_invitation_email_multiple_domains(
|
def test_send_domain_invitation_email_multiple_domains(
|
||||||
self,
|
self,
|
||||||
mock_normalize_domains,
|
mock_normalize_domains,
|
||||||
|
@ -137,7 +149,7 @@ class DomainInvitationEmail(unittest.TestCase):
|
||||||
|
|
||||||
# Assertions
|
# Assertions
|
||||||
mock_normalize_domains.assert_called_once_with([mock_domain1, mock_domain2])
|
mock_normalize_domains.assert_called_once_with([mock_domain1, mock_domain2])
|
||||||
mock_get_requestor_email.assert_called_once_with(mock_requestor, [mock_domain1, mock_domain2])
|
mock_get_requestor_email.assert_called_once_with(mock_requestor, domains=[mock_domain1, mock_domain2])
|
||||||
mock_validate_invitation.assert_called_once_with(
|
mock_validate_invitation.assert_called_once_with(
|
||||||
email, None, [mock_domain1, mock_domain2], mock_requestor, is_member_of_different_org
|
email, None, [mock_domain1, mock_domain2], mock_requestor, is_member_of_different_org
|
||||||
)
|
)
|
||||||
|
@ -197,7 +209,7 @@ class DomainInvitationEmail(unittest.TestCase):
|
||||||
mock_validate_invitation.assert_called_once()
|
mock_validate_invitation.assert_called_once()
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
@patch("registrar.utility.email_invitations.get_requestor_email")
|
@patch("registrar.utility.email_invitations._get_requestor_email")
|
||||||
def test_send_domain_invitation_email_raises_get_requestor_email_exception(self, mock_get_requestor_email):
|
def test_send_domain_invitation_email_raises_get_requestor_email_exception(self, mock_get_requestor_email):
|
||||||
"""Test sending domain invitation email for one domain and assert exception
|
"""Test sending domain invitation email for one domain and assert exception
|
||||||
when get_requestor_email fails.
|
when get_requestor_email fails.
|
||||||
|
@ -217,9 +229,9 @@ class DomainInvitationEmail(unittest.TestCase):
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
@patch("registrar.utility.email_invitations._validate_invitation")
|
@patch("registrar.utility.email_invitations._validate_invitation")
|
||||||
@patch("registrar.utility.email_invitations.get_requestor_email")
|
@patch("registrar.utility.email_invitations._get_requestor_email")
|
||||||
@patch("registrar.utility.email_invitations.send_invitation_email")
|
@patch("registrar.utility.email_invitations.send_invitation_email")
|
||||||
@patch("registrar.utility.email_invitations.normalize_domains")
|
@patch("registrar.utility.email_invitations._normalize_domains")
|
||||||
def test_send_domain_invitation_email_raises_sending_email_exception(
|
def test_send_domain_invitation_email_raises_sending_email_exception(
|
||||||
self,
|
self,
|
||||||
mock_normalize_domains,
|
mock_normalize_domains,
|
||||||
|
@ -258,7 +270,7 @@ class DomainInvitationEmail(unittest.TestCase):
|
||||||
|
|
||||||
# Assertions
|
# Assertions
|
||||||
mock_normalize_domains.assert_called_once_with(mock_domain)
|
mock_normalize_domains.assert_called_once_with(mock_domain)
|
||||||
mock_get_requestor_email.assert_called_once_with(mock_requestor, [mock_domain])
|
mock_get_requestor_email.assert_called_once_with(mock_requestor, domains=[mock_domain])
|
||||||
mock_validate_invitation.assert_called_once_with(
|
mock_validate_invitation.assert_called_once_with(
|
||||||
email, None, [mock_domain], mock_requestor, is_member_of_different_org
|
email, None, [mock_domain], mock_requestor, is_member_of_different_org
|
||||||
)
|
)
|
||||||
|
@ -267,9 +279,9 @@ class DomainInvitationEmail(unittest.TestCase):
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
@patch("registrar.utility.email_invitations.send_emails_to_domain_managers")
|
@patch("registrar.utility.email_invitations.send_emails_to_domain_managers")
|
||||||
@patch("registrar.utility.email_invitations._validate_invitation")
|
@patch("registrar.utility.email_invitations._validate_invitation")
|
||||||
@patch("registrar.utility.email_invitations.get_requestor_email")
|
@patch("registrar.utility.email_invitations._get_requestor_email")
|
||||||
@patch("registrar.utility.email_invitations.send_invitation_email")
|
@patch("registrar.utility.email_invitations.send_invitation_email")
|
||||||
@patch("registrar.utility.email_invitations.normalize_domains")
|
@patch("registrar.utility.email_invitations._normalize_domains")
|
||||||
def test_send_domain_invitation_email_manager_emails_send_mail_exception(
|
def test_send_domain_invitation_email_manager_emails_send_mail_exception(
|
||||||
self,
|
self,
|
||||||
mock_normalize_domains,
|
mock_normalize_domains,
|
||||||
|
@ -306,7 +318,7 @@ class DomainInvitationEmail(unittest.TestCase):
|
||||||
|
|
||||||
# Assertions
|
# Assertions
|
||||||
mock_normalize_domains.assert_called_once_with(mock_domain)
|
mock_normalize_domains.assert_called_once_with(mock_domain)
|
||||||
mock_get_requestor_email.assert_called_once_with(mock_requestor, [mock_domain])
|
mock_get_requestor_email.assert_called_once_with(mock_requestor, domains=[mock_domain])
|
||||||
mock_validate_invitation.assert_called_once_with(
|
mock_validate_invitation.assert_called_once_with(
|
||||||
email, None, [mock_domain], mock_requestor, is_member_of_different_org
|
email, None, [mock_domain], mock_requestor, is_member_of_different_org
|
||||||
)
|
)
|
||||||
|
@ -469,3 +481,410 @@ class DomainInvitationEmail(unittest.TestCase):
|
||||||
"date": date.today(),
|
"date": date.today(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioInvitationEmailTests(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Setup common test data for all test cases"""
|
||||||
|
self.email = "invitee@example.com"
|
||||||
|
self.requestor = MagicMock(name="User")
|
||||||
|
self.requestor.email = "requestor@example.com"
|
||||||
|
self.portfolio = MagicMock(name="Portfolio")
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@patch("registrar.utility.email_invitations.send_templated_email")
|
||||||
|
def test_send_portfolio_invitation_email_success(self, mock_send_templated_email):
|
||||||
|
"""Test successful email sending"""
|
||||||
|
is_admin_invitation = False
|
||||||
|
|
||||||
|
result = send_portfolio_invitation_email(self.email, self.requestor, self.portfolio, is_admin_invitation)
|
||||||
|
|
||||||
|
self.assertTrue(result)
|
||||||
|
mock_send_templated_email.assert_called_once()
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@patch(
|
||||||
|
"registrar.utility.email_invitations.send_templated_email",
|
||||||
|
side_effect=EmailSendingError("Failed to send email"),
|
||||||
|
)
|
||||||
|
def test_send_portfolio_invitation_email_failure(self, mock_send_templated_email):
|
||||||
|
"""Test failure when sending email"""
|
||||||
|
is_admin_invitation = False
|
||||||
|
|
||||||
|
with self.assertRaises(EmailSendingError) as context:
|
||||||
|
send_portfolio_invitation_email(self.email, self.requestor, self.portfolio, is_admin_invitation)
|
||||||
|
|
||||||
|
self.assertIn("Could not sent email invitation to", str(context.exception))
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@patch(
|
||||||
|
"registrar.utility.email_invitations._get_requestor_email",
|
||||||
|
side_effect=MissingEmailError("Requestor has no email"),
|
||||||
|
)
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_send_portfolio_invitation_email_missing_requestor_email(self, mock_get_email):
|
||||||
|
"""Test when requestor has no email"""
|
||||||
|
is_admin_invitation = False
|
||||||
|
|
||||||
|
with self.assertRaises(MissingEmailError) as context:
|
||||||
|
send_portfolio_invitation_email(self.email, self.requestor, self.portfolio, is_admin_invitation)
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
"Can't send invitation email. No email is associated with your user account.", str(context.exception)
|
||||||
|
)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@patch(
|
||||||
|
"registrar.utility.email_invitations._send_portfolio_admin_addition_emails_to_portfolio_admins",
|
||||||
|
return_value=False,
|
||||||
|
)
|
||||||
|
@patch("registrar.utility.email_invitations.send_templated_email")
|
||||||
|
def test_send_portfolio_invitation_email_admin_invitation(self, mock_send_templated_email, mock_admin_email):
|
||||||
|
"""Test admin invitation email logic"""
|
||||||
|
is_admin_invitation = True
|
||||||
|
|
||||||
|
result = send_portfolio_invitation_email(self.email, self.requestor, self.portfolio, is_admin_invitation)
|
||||||
|
|
||||||
|
self.assertFalse(result) # Admin email sending failed
|
||||||
|
mock_send_templated_email.assert_called_once()
|
||||||
|
mock_admin_email.assert_called_once()
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@patch("registrar.utility.email_invitations._get_requestor_email")
|
||||||
|
@patch("registrar.utility.email_invitations._send_portfolio_admin_addition_emails_to_portfolio_admins")
|
||||||
|
def test_send_email_success(self, mock_send_admin_emails, mock_get_requestor_email):
|
||||||
|
"""Test successful sending of admin addition emails."""
|
||||||
|
mock_get_requestor_email.return_value = "requestor@example.com"
|
||||||
|
mock_send_admin_emails.return_value = True
|
||||||
|
|
||||||
|
result = send_portfolio_admin_addition_emails(self.email, self.requestor, self.portfolio)
|
||||||
|
|
||||||
|
mock_get_requestor_email.assert_called_once_with(self.requestor, portfolio=self.portfolio)
|
||||||
|
mock_send_admin_emails.assert_called_once_with(self.email, "requestor@example.com", self.portfolio)
|
||||||
|
self.assertTrue(result)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@patch(
|
||||||
|
"registrar.utility.email_invitations._get_requestor_email",
|
||||||
|
side_effect=MissingEmailError("Requestor email missing"),
|
||||||
|
)
|
||||||
|
def test_missing_requestor_email_raises_exception(self, mock_get_requestor_email):
|
||||||
|
"""Test exception raised if requestor email is missing."""
|
||||||
|
with self.assertRaises(MissingEmailError):
|
||||||
|
send_portfolio_admin_addition_emails(self.email, self.requestor, self.portfolio)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@patch("registrar.utility.email_invitations._get_requestor_email")
|
||||||
|
@patch("registrar.utility.email_invitations._send_portfolio_admin_addition_emails_to_portfolio_admins")
|
||||||
|
def test_send_email_failure(self, mock_send_admin_emails, mock_get_requestor_email):
|
||||||
|
"""Test handling of failure in sending admin addition emails."""
|
||||||
|
mock_get_requestor_email.return_value = "requestor@example.com"
|
||||||
|
mock_send_admin_emails.return_value = False # Simulate failure
|
||||||
|
|
||||||
|
result = send_portfolio_admin_addition_emails(self.email, self.requestor, self.portfolio)
|
||||||
|
|
||||||
|
self.assertFalse(result)
|
||||||
|
mock_get_requestor_email.assert_called_once_with(self.requestor, portfolio=self.portfolio)
|
||||||
|
mock_send_admin_emails.assert_called_once_with(self.email, "requestor@example.com", self.portfolio)
|
||||||
|
|
||||||
|
|
||||||
|
class SendPortfolioAdminAdditionEmailsTests(unittest.TestCase):
|
||||||
|
"""Unit tests for _send_portfolio_admin_addition_emails_to_portfolio_admins function."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up test data."""
|
||||||
|
self.email = "new.admin@example.com"
|
||||||
|
self.requestor_email = "requestor@example.com"
|
||||||
|
self.portfolio = MagicMock(spec=Portfolio)
|
||||||
|
self.portfolio.organization_name = "Test Organization"
|
||||||
|
|
||||||
|
# Mock portfolio admin users
|
||||||
|
self.admin_user1 = MagicMock(spec=User)
|
||||||
|
self.admin_user1.email = "admin1@example.com"
|
||||||
|
|
||||||
|
self.admin_user2 = MagicMock(spec=User)
|
||||||
|
self.admin_user2.email = "admin2@example.com"
|
||||||
|
|
||||||
|
self.portfolio_admin1 = MagicMock(spec=UserPortfolioPermission)
|
||||||
|
self.portfolio_admin1.user = self.admin_user1
|
||||||
|
self.portfolio_admin1.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
|
|
||||||
|
self.portfolio_admin2 = MagicMock(spec=UserPortfolioPermission)
|
||||||
|
self.portfolio_admin2.user = self.admin_user2
|
||||||
|
self.portfolio_admin2.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@patch("registrar.utility.email_invitations.send_templated_email")
|
||||||
|
@patch("registrar.utility.email_invitations.UserPortfolioPermission.objects.filter")
|
||||||
|
def test_send_email_success(self, mock_filter, mock_send_templated_email):
|
||||||
|
"""Test successful sending of admin addition emails."""
|
||||||
|
mock_filter.return_value.exclude.return_value = [self.portfolio_admin1, self.portfolio_admin2]
|
||||||
|
mock_send_templated_email.return_value = None # No exception means success
|
||||||
|
|
||||||
|
result = _send_portfolio_admin_addition_emails_to_portfolio_admins(
|
||||||
|
self.email, self.requestor_email, self.portfolio
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_filter.assert_called_once_with(
|
||||||
|
portfolio=self.portfolio, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
|
)
|
||||||
|
mock_send_templated_email.assert_any_call(
|
||||||
|
"emails/portfolio_admin_addition_notification.txt",
|
||||||
|
"emails/portfolio_admin_addition_notification_subject.txt",
|
||||||
|
to_address=self.admin_user1.email,
|
||||||
|
context={
|
||||||
|
"portfolio": self.portfolio,
|
||||||
|
"requestor_email": self.requestor_email,
|
||||||
|
"invited_email_address": self.email,
|
||||||
|
"portfolio_admin": self.admin_user1,
|
||||||
|
"date": date.today(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
mock_send_templated_email.assert_any_call(
|
||||||
|
"emails/portfolio_admin_addition_notification.txt",
|
||||||
|
"emails/portfolio_admin_addition_notification_subject.txt",
|
||||||
|
to_address=self.admin_user2.email,
|
||||||
|
context={
|
||||||
|
"portfolio": self.portfolio,
|
||||||
|
"requestor_email": self.requestor_email,
|
||||||
|
"invited_email_address": self.email,
|
||||||
|
"portfolio_admin": self.admin_user2,
|
||||||
|
"date": date.today(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertTrue(result)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@patch("registrar.utility.email_invitations.send_templated_email", side_effect=EmailSendingError)
|
||||||
|
@patch("registrar.utility.email_invitations.UserPortfolioPermission.objects.filter")
|
||||||
|
def test_send_email_failure(self, mock_filter, mock_send_templated_email):
|
||||||
|
"""Test handling of failure in sending admin addition emails."""
|
||||||
|
mock_filter.return_value.exclude.return_value = [self.portfolio_admin1, self.portfolio_admin2]
|
||||||
|
|
||||||
|
result = _send_portfolio_admin_addition_emails_to_portfolio_admins(
|
||||||
|
self.email, self.requestor_email, self.portfolio
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(result)
|
||||||
|
mock_filter.assert_called_once_with(
|
||||||
|
portfolio=self.portfolio, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
|
)
|
||||||
|
mock_send_templated_email.assert_any_call(
|
||||||
|
"emails/portfolio_admin_addition_notification.txt",
|
||||||
|
"emails/portfolio_admin_addition_notification_subject.txt",
|
||||||
|
to_address=self.admin_user1.email,
|
||||||
|
context={
|
||||||
|
"portfolio": self.portfolio,
|
||||||
|
"requestor_email": self.requestor_email,
|
||||||
|
"invited_email_address": self.email,
|
||||||
|
"portfolio_admin": self.admin_user1,
|
||||||
|
"date": date.today(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
mock_send_templated_email.assert_any_call(
|
||||||
|
"emails/portfolio_admin_addition_notification.txt",
|
||||||
|
"emails/portfolio_admin_addition_notification_subject.txt",
|
||||||
|
to_address=self.admin_user2.email,
|
||||||
|
context={
|
||||||
|
"portfolio": self.portfolio,
|
||||||
|
"requestor_email": self.requestor_email,
|
||||||
|
"invited_email_address": self.email,
|
||||||
|
"portfolio_admin": self.admin_user2,
|
||||||
|
"date": date.today(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@patch("registrar.utility.email_invitations.UserPortfolioPermission.objects.filter")
|
||||||
|
def test_no_admins_to_notify(self, mock_filter):
|
||||||
|
"""Test case where there are no portfolio admins to notify."""
|
||||||
|
mock_filter.return_value.exclude.return_value = [] # No admins
|
||||||
|
|
||||||
|
result = _send_portfolio_admin_addition_emails_to_portfolio_admins(
|
||||||
|
self.email, self.requestor_email, self.portfolio
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result) # No emails sent, but also no failures
|
||||||
|
mock_filter.assert_called_once_with(
|
||||||
|
portfolio=self.portfolio, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SendPortfolioAdminRemovalEmailsToAdminsTests(unittest.TestCase):
|
||||||
|
"""Unit tests for _send_portfolio_admin_removal_emails_to_portfolio_admins function."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up test data."""
|
||||||
|
self.email = "removed.admin@example.com"
|
||||||
|
self.requestor_email = "requestor@example.com"
|
||||||
|
self.portfolio = MagicMock(spec=Portfolio)
|
||||||
|
self.portfolio.organization_name = "Test Organization"
|
||||||
|
|
||||||
|
# Mock portfolio admin users
|
||||||
|
self.admin_user1 = MagicMock(spec=User)
|
||||||
|
self.admin_user1.email = "admin1@example.com"
|
||||||
|
|
||||||
|
self.admin_user2 = MagicMock(spec=User)
|
||||||
|
self.admin_user2.email = "admin2@example.com"
|
||||||
|
|
||||||
|
self.portfolio_admin1 = MagicMock(spec=UserPortfolioPermission)
|
||||||
|
self.portfolio_admin1.user = self.admin_user1
|
||||||
|
self.portfolio_admin1.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
|
|
||||||
|
self.portfolio_admin2 = MagicMock(spec=UserPortfolioPermission)
|
||||||
|
self.portfolio_admin2.user = self.admin_user2
|
||||||
|
self.portfolio_admin2.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@patch("registrar.utility.email_invitations.send_templated_email")
|
||||||
|
@patch("registrar.utility.email_invitations.UserPortfolioPermission.objects.filter")
|
||||||
|
def test_send_email_success(self, mock_filter, mock_send_templated_email):
|
||||||
|
"""Test successful sending of admin removal emails."""
|
||||||
|
mock_filter.return_value.exclude.return_value = [self.portfolio_admin1, self.portfolio_admin2]
|
||||||
|
mock_send_templated_email.return_value = None # No exception means success
|
||||||
|
|
||||||
|
result = _send_portfolio_admin_removal_emails_to_portfolio_admins(
|
||||||
|
self.email, self.requestor_email, self.portfolio
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_filter.assert_called_once_with(
|
||||||
|
portfolio=self.portfolio, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
|
)
|
||||||
|
mock_send_templated_email.assert_any_call(
|
||||||
|
"emails/portfolio_admin_removal_notification.txt",
|
||||||
|
"emails/portfolio_admin_removal_notification_subject.txt",
|
||||||
|
to_address=self.admin_user1.email,
|
||||||
|
context={
|
||||||
|
"portfolio": self.portfolio,
|
||||||
|
"requestor_email": self.requestor_email,
|
||||||
|
"removed_email_address": self.email,
|
||||||
|
"portfolio_admin": self.admin_user1,
|
||||||
|
"date": date.today(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
mock_send_templated_email.assert_any_call(
|
||||||
|
"emails/portfolio_admin_removal_notification.txt",
|
||||||
|
"emails/portfolio_admin_removal_notification_subject.txt",
|
||||||
|
to_address=self.admin_user2.email,
|
||||||
|
context={
|
||||||
|
"portfolio": self.portfolio,
|
||||||
|
"requestor_email": self.requestor_email,
|
||||||
|
"removed_email_address": self.email,
|
||||||
|
"portfolio_admin": self.admin_user2,
|
||||||
|
"date": date.today(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertTrue(result)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@patch("registrar.utility.email_invitations.send_templated_email", side_effect=EmailSendingError)
|
||||||
|
@patch("registrar.utility.email_invitations.UserPortfolioPermission.objects.filter")
|
||||||
|
def test_send_email_failure(self, mock_filter, mock_send_templated_email):
|
||||||
|
"""Test handling of failure in sending admin removal emails."""
|
||||||
|
mock_filter.return_value.exclude.return_value = [self.portfolio_admin1, self.portfolio_admin2]
|
||||||
|
|
||||||
|
result = _send_portfolio_admin_removal_emails_to_portfolio_admins(
|
||||||
|
self.email, self.requestor_email, self.portfolio
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(result)
|
||||||
|
mock_filter.assert_called_once_with(
|
||||||
|
portfolio=self.portfolio, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
|
)
|
||||||
|
mock_send_templated_email.assert_any_call(
|
||||||
|
"emails/portfolio_admin_removal_notification.txt",
|
||||||
|
"emails/portfolio_admin_removal_notification_subject.txt",
|
||||||
|
to_address=self.admin_user1.email,
|
||||||
|
context={
|
||||||
|
"portfolio": self.portfolio,
|
||||||
|
"requestor_email": self.requestor_email,
|
||||||
|
"removed_email_address": self.email,
|
||||||
|
"portfolio_admin": self.admin_user1,
|
||||||
|
"date": date.today(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
mock_send_templated_email.assert_any_call(
|
||||||
|
"emails/portfolio_admin_removal_notification.txt",
|
||||||
|
"emails/portfolio_admin_removal_notification_subject.txt",
|
||||||
|
to_address=self.admin_user2.email,
|
||||||
|
context={
|
||||||
|
"portfolio": self.portfolio,
|
||||||
|
"requestor_email": self.requestor_email,
|
||||||
|
"removed_email_address": self.email,
|
||||||
|
"portfolio_admin": self.admin_user2,
|
||||||
|
"date": date.today(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@patch("registrar.utility.email_invitations.UserPortfolioPermission.objects.filter")
|
||||||
|
def test_no_admins_to_notify(self, mock_filter):
|
||||||
|
"""Test case where there are no portfolio admins to notify."""
|
||||||
|
mock_filter.return_value.exclude.return_value = [] # No admins
|
||||||
|
|
||||||
|
result = _send_portfolio_admin_removal_emails_to_portfolio_admins(
|
||||||
|
self.email, self.requestor_email, self.portfolio
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result) # No emails sent, but also no failures
|
||||||
|
mock_filter.assert_called_once_with(
|
||||||
|
portfolio=self.portfolio, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SendPortfolioAdminRemovalEmailsTests(unittest.TestCase):
|
||||||
|
"""Unit tests for send_portfolio_admin_removal_emails function."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Set up test data."""
|
||||||
|
self.email = "removed.admin@example.com"
|
||||||
|
self.requestor = MagicMock(spec=User)
|
||||||
|
self.requestor.email = "requestor@example.com"
|
||||||
|
self.portfolio = MagicMock(spec=Portfolio)
|
||||||
|
self.portfolio.organization_name = "Test Organization"
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@patch("registrar.utility.email_invitations._get_requestor_email")
|
||||||
|
@patch("registrar.utility.email_invitations._send_portfolio_admin_removal_emails_to_portfolio_admins")
|
||||||
|
def test_send_email_success(self, mock_send_removal_emails, mock_get_requestor_email):
|
||||||
|
"""Test successful execution of send_portfolio_admin_removal_emails."""
|
||||||
|
mock_get_requestor_email.return_value = self.requestor.email
|
||||||
|
mock_send_removal_emails.return_value = True # Simulating success
|
||||||
|
|
||||||
|
result = send_portfolio_admin_removal_emails(self.email, self.requestor, self.portfolio)
|
||||||
|
|
||||||
|
mock_get_requestor_email.assert_called_once_with(self.requestor, portfolio=self.portfolio)
|
||||||
|
mock_send_removal_emails.assert_called_once_with(self.email, self.requestor.email, self.portfolio)
|
||||||
|
self.assertTrue(result)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@patch("registrar.utility.email_invitations._get_requestor_email", side_effect=MissingEmailError("No email found"))
|
||||||
|
@patch("registrar.utility.email_invitations._send_portfolio_admin_removal_emails_to_portfolio_admins")
|
||||||
|
def test_missing_email_error(self, mock_send_removal_emails, mock_get_requestor_email):
|
||||||
|
"""Test handling of MissingEmailError when requestor has no email."""
|
||||||
|
with self.assertRaises(MissingEmailError) as context:
|
||||||
|
send_portfolio_admin_removal_emails(self.email, self.requestor, self.portfolio)
|
||||||
|
|
||||||
|
mock_get_requestor_email.assert_called_once_with(self.requestor, portfolio=self.portfolio)
|
||||||
|
mock_send_removal_emails.assert_not_called() # Should not proceed if email retrieval fails
|
||||||
|
self.assertEqual(
|
||||||
|
str(context.exception), "Can't send invitation email. No email is associated with your user account."
|
||||||
|
)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@patch("registrar.utility.email_invitations._get_requestor_email")
|
||||||
|
@patch(
|
||||||
|
"registrar.utility.email_invitations._send_portfolio_admin_removal_emails_to_portfolio_admins",
|
||||||
|
return_value=False,
|
||||||
|
)
|
||||||
|
def test_send_email_failure(self, mock_send_removal_emails, mock_get_requestor_email):
|
||||||
|
"""Test handling of failure when admin removal emails fail to send."""
|
||||||
|
mock_get_requestor_email.return_value = self.requestor.email
|
||||||
|
mock_send_removal_emails.return_value = False # Simulating failure
|
||||||
|
|
||||||
|
result = send_portfolio_admin_removal_emails(self.email, self.requestor, self.portfolio)
|
||||||
|
|
||||||
|
mock_get_requestor_email.assert_called_once_with(self.requestor, portfolio=self.portfolio)
|
||||||
|
mock_send_removal_emails.assert_called_once_with(self.email, self.requestor.email, self.portfolio)
|
||||||
|
self.assertFalse(result)
|
||||||
|
|
|
@ -7,6 +7,7 @@ from registrar.models.domain_group import DomainGroup
|
||||||
from registrar.models.portfolio_invitation import PortfolioInvitation
|
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||||
from registrar.models.senior_official import SeniorOfficial
|
from registrar.models.senior_official import SeniorOfficial
|
||||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||||
|
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
||||||
from registrar.utility.constants import BranchChoices
|
from registrar.utility.constants import BranchChoices
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.module_loading import import_string
|
from django.utils.module_loading import import_string
|
||||||
|
@ -1465,6 +1466,7 @@ class TestCreateFederalPortfolio(TestCase):
|
||||||
self.executive_so_2 = SeniorOfficial.objects.create(
|
self.executive_so_2 = SeniorOfficial.objects.create(
|
||||||
first_name="first", last_name="last", email="mango@igorville.gov", federal_agency=self.executive_agency_2
|
first_name="first", last_name="last", email="mango@igorville.gov", federal_agency=self.executive_agency_2
|
||||||
)
|
)
|
||||||
|
|
||||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
|
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
|
||||||
self.domain_request = completed_domain_request(
|
self.domain_request = completed_domain_request(
|
||||||
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
|
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
|
||||||
|
@ -1474,6 +1476,7 @@ class TestCreateFederalPortfolio(TestCase):
|
||||||
)
|
)
|
||||||
self.domain_request.approve()
|
self.domain_request.approve()
|
||||||
self.domain_info = DomainInformation.objects.filter(domain_request=self.domain_request).get()
|
self.domain_info = DomainInformation.objects.filter(domain_request=self.domain_request).get()
|
||||||
|
self.domain = Domain.objects.get(name="city.gov")
|
||||||
|
|
||||||
self.domain_request_2 = completed_domain_request(
|
self.domain_request_2 = completed_domain_request(
|
||||||
name="icecreamforigorville.gov",
|
name="icecreamforigorville.gov",
|
||||||
|
@ -1517,7 +1520,6 @@ class TestCreateFederalPortfolio(TestCase):
|
||||||
FederalAgency.objects.all().delete()
|
FederalAgency.objects.all().delete()
|
||||||
User.objects.all().delete()
|
User.objects.all().delete()
|
||||||
|
|
||||||
@less_console_noise_decorator
|
|
||||||
def run_create_federal_portfolio(self, **kwargs):
|
def run_create_federal_portfolio(self, **kwargs):
|
||||||
with patch(
|
with patch(
|
||||||
"registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit",
|
"registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit",
|
||||||
|
@ -1812,12 +1814,12 @@ class TestCreateFederalPortfolio(TestCase):
|
||||||
|
|
||||||
# We expect a error to be thrown when we dont pass parse requests or domains
|
# We expect a error to be thrown when we dont pass parse requests or domains
|
||||||
with self.assertRaisesRegex(
|
with self.assertRaisesRegex(
|
||||||
CommandError, "You must specify at least one of --parse_requests or --parse_domains."
|
CommandError, "You must specify at least one of --parse_requests, --parse_domains, or --add_managers."
|
||||||
):
|
):
|
||||||
self.run_create_federal_portfolio(branch="executive")
|
self.run_create_federal_portfolio(branch="executive")
|
||||||
|
|
||||||
with self.assertRaisesRegex(
|
with self.assertRaisesRegex(
|
||||||
CommandError, "You must specify at least one of --parse_requests or --parse_domains."
|
CommandError, "You must specify at least one of --parse_requests, --parse_domains, or --add_managers."
|
||||||
):
|
):
|
||||||
self.run_create_federal_portfolio(agency_name="test")
|
self.run_create_federal_portfolio(agency_name="test")
|
||||||
|
|
||||||
|
@ -1854,6 +1856,143 @@ class TestCreateFederalPortfolio(TestCase):
|
||||||
self.assertEqual(existing_portfolio.notes, "Old notes")
|
self.assertEqual(existing_portfolio.notes, "Old notes")
|
||||||
self.assertEqual(existing_portfolio.creator, self.user)
|
self.assertEqual(existing_portfolio.creator, self.user)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_add_managers_from_domains(self):
|
||||||
|
"""Test that all domain managers are added as portfolio managers."""
|
||||||
|
|
||||||
|
# Create users and assign them as domain managers
|
||||||
|
manager1 = User.objects.create(username="manager1", email="manager1@example.com")
|
||||||
|
manager2 = User.objects.create(username="manager2", email="manager2@example.com")
|
||||||
|
UserDomainRole.objects.create(user=manager1, domain=self.domain, role=UserDomainRole.Roles.MANAGER)
|
||||||
|
UserDomainRole.objects.create(user=manager2, domain=self.domain, role=UserDomainRole.Roles.MANAGER)
|
||||||
|
|
||||||
|
# Run the management command
|
||||||
|
self.run_create_federal_portfolio(agency_name=self.federal_agency.agency, parse_domains=True, add_managers=True)
|
||||||
|
|
||||||
|
# Check that the portfolio was created
|
||||||
|
self.portfolio = Portfolio.objects.get(federal_agency=self.federal_agency)
|
||||||
|
|
||||||
|
# Check that the users have been added as portfolio managers
|
||||||
|
permissions = UserPortfolioPermission.objects.filter(portfolio=self.portfolio, user__in=[manager1, manager2])
|
||||||
|
|
||||||
|
# Check that the users have been added as portfolio managers
|
||||||
|
self.assertEqual(permissions.count(), 2)
|
||||||
|
for perm in permissions:
|
||||||
|
self.assertIn(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, perm.roles)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_add_invited_managers(self):
|
||||||
|
"""Test that invited domain managers receive portfolio invitations."""
|
||||||
|
|
||||||
|
# create a domain invitation for the manager
|
||||||
|
_ = DomainInvitation.objects.create(
|
||||||
|
domain=self.domain, email="manager1@example.com", status=DomainInvitation.DomainInvitationStatus.INVITED
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run the management command
|
||||||
|
self.run_create_federal_portfolio(agency_name=self.federal_agency.agency, parse_domains=True, add_managers=True)
|
||||||
|
|
||||||
|
# Check that the portfolio was created
|
||||||
|
self.portfolio = Portfolio.objects.get(federal_agency=self.federal_agency)
|
||||||
|
|
||||||
|
# Check that a PortfolioInvitation has been created for the invited email
|
||||||
|
invitation = PortfolioInvitation.objects.get(email="manager1@example.com", portfolio=self.portfolio)
|
||||||
|
|
||||||
|
# Verify the status of the invitation remains INVITED
|
||||||
|
self.assertEqual(
|
||||||
|
invitation.status,
|
||||||
|
PortfolioInvitation.PortfolioInvitationStatus.INVITED,
|
||||||
|
"PortfolioInvitation status should remain INVITED for non-existent users.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify that no duplicate invitations are created
|
||||||
|
self.run_create_federal_portfolio(
|
||||||
|
agency_name=self.federal_agency.agency, parse_requests=True, add_managers=True
|
||||||
|
)
|
||||||
|
invitations = PortfolioInvitation.objects.filter(email="manager1@example.com", portfolio=self.portfolio)
|
||||||
|
self.assertEqual(
|
||||||
|
invitations.count(),
|
||||||
|
1,
|
||||||
|
"Duplicate PortfolioInvitation should not be created for the same email and portfolio.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_no_duplicate_managers_added(self):
|
||||||
|
"""Test that duplicate managers are not added multiple times."""
|
||||||
|
# Create a manager
|
||||||
|
manager = User.objects.create(username="manager", email="manager@example.com")
|
||||||
|
UserDomainRole.objects.create(user=manager, domain=self.domain, role=UserDomainRole.Roles.MANAGER)
|
||||||
|
|
||||||
|
# Create a pre-existing portfolio
|
||||||
|
self.portfolio = Portfolio.objects.create(
|
||||||
|
organization_name=self.federal_agency.agency, federal_agency=self.federal_agency, creator=self.user
|
||||||
|
)
|
||||||
|
|
||||||
|
# Manually add the manager to the portfolio
|
||||||
|
UserPortfolioPermission.objects.create(
|
||||||
|
portfolio=self.portfolio, user=manager, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run the management command
|
||||||
|
self.run_create_federal_portfolio(
|
||||||
|
agency_name=self.federal_agency.agency, parse_requests=True, add_managers=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure that the manager is not duplicated
|
||||||
|
permissions = UserPortfolioPermission.objects.filter(portfolio=self.portfolio, user=manager)
|
||||||
|
self.assertEqual(permissions.count(), 1)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_add_managers_skip_existing_portfolios(self):
|
||||||
|
"""Test that managers are skipped when the portfolio already exists."""
|
||||||
|
|
||||||
|
# Create a pre-existing portfolio
|
||||||
|
self.portfolio = Portfolio.objects.create(
|
||||||
|
organization_name=self.federal_agency.agency, federal_agency=self.federal_agency, creator=self.user
|
||||||
|
)
|
||||||
|
|
||||||
|
domain_request_1 = completed_domain_request(
|
||||||
|
name="domain1.gov",
|
||||||
|
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
|
||||||
|
generic_org_type=DomainRequest.OrganizationChoices.CITY,
|
||||||
|
federal_agency=self.federal_agency,
|
||||||
|
user=self.user,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
)
|
||||||
|
domain_request_1.approve()
|
||||||
|
domain1 = Domain.objects.get(name="domain1.gov")
|
||||||
|
|
||||||
|
domain_request_2 = completed_domain_request(
|
||||||
|
name="domain2.gov",
|
||||||
|
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
|
||||||
|
generic_org_type=DomainRequest.OrganizationChoices.CITY,
|
||||||
|
federal_agency=self.federal_agency,
|
||||||
|
user=self.user,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
)
|
||||||
|
domain_request_2.approve()
|
||||||
|
domain2 = Domain.objects.get(name="domain2.gov")
|
||||||
|
|
||||||
|
# Create users and assign them as domain managers
|
||||||
|
manager1 = User.objects.create(username="manager1", email="manager1@example.com")
|
||||||
|
manager2 = User.objects.create(username="manager2", email="manager2@example.com")
|
||||||
|
UserDomainRole.objects.create(user=manager1, domain=domain1, role=UserDomainRole.Roles.MANAGER)
|
||||||
|
UserDomainRole.objects.create(user=manager2, domain=domain2, role=UserDomainRole.Roles.MANAGER)
|
||||||
|
|
||||||
|
# Run the management command
|
||||||
|
self.run_create_federal_portfolio(
|
||||||
|
agency_name=self.federal_agency.agency,
|
||||||
|
parse_requests=True,
|
||||||
|
add_managers=True,
|
||||||
|
skip_existing_portfolios=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check that managers were added to the portfolio
|
||||||
|
permissions = UserPortfolioPermission.objects.filter(portfolio=self.portfolio, user__in=[manager1, manager2])
|
||||||
|
self.assertEqual(permissions.count(), 2)
|
||||||
|
for perm in permissions:
|
||||||
|
self.assertIn(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, perm.roles)
|
||||||
|
|
||||||
def test_skip_existing_portfolios(self):
|
def test_skip_existing_portfolios(self):
|
||||||
"""Tests the skip_existing_portfolios to ensure that it doesn't add
|
"""Tests the skip_existing_portfolios to ensure that it doesn't add
|
||||||
suborgs, domain requests, and domain info."""
|
suborgs, domain requests, and domain info."""
|
||||||
|
|
|
@ -1191,8 +1191,8 @@ class TestUser(TestCase):
|
||||||
User.objects.all().delete()
|
User.objects.all().delete()
|
||||||
UserDomainRole.objects.all().delete()
|
UserDomainRole.objects.all().delete()
|
||||||
|
|
||||||
@patch.object(User, "has_edit_suborganization_portfolio_permission", return_value=True)
|
@patch.object(User, "has_edit_portfolio_permission", return_value=True)
|
||||||
def test_portfolio_role_summary_admin(self, mock_edit_suborganization):
|
def test_portfolio_role_summary_admin(self, mock_edit_org):
|
||||||
# Test if the user is recognized as an Admin
|
# Test if the user is recognized as an Admin
|
||||||
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Admin"])
|
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Admin"])
|
||||||
|
|
||||||
|
@ -1217,7 +1217,7 @@ class TestUser(TestCase):
|
||||||
|
|
||||||
@patch.multiple(
|
@patch.multiple(
|
||||||
User,
|
User,
|
||||||
has_base_portfolio_permission=lambda self, portfolio: True,
|
has_view_portfolio_permission=lambda self, portfolio: True,
|
||||||
has_edit_request_portfolio_permission=lambda self, portfolio: True,
|
has_edit_request_portfolio_permission=lambda self, portfolio: True,
|
||||||
has_any_domains_portfolio_permission=lambda self, portfolio: True,
|
has_any_domains_portfolio_permission=lambda self, portfolio: True,
|
||||||
)
|
)
|
||||||
|
@ -1227,7 +1227,7 @@ class TestUser(TestCase):
|
||||||
|
|
||||||
@patch.multiple(
|
@patch.multiple(
|
||||||
User,
|
User,
|
||||||
has_base_portfolio_permission=lambda self, portfolio: True,
|
has_view_portfolio_permission=lambda self, portfolio: True,
|
||||||
has_edit_request_portfolio_permission=lambda self, portfolio: True,
|
has_edit_request_portfolio_permission=lambda self, portfolio: True,
|
||||||
)
|
)
|
||||||
def test_portfolio_role_summary_member_domain_requestor(self):
|
def test_portfolio_role_summary_member_domain_requestor(self):
|
||||||
|
@ -1236,14 +1236,14 @@ class TestUser(TestCase):
|
||||||
|
|
||||||
@patch.multiple(
|
@patch.multiple(
|
||||||
User,
|
User,
|
||||||
has_base_portfolio_permission=lambda self, portfolio: True,
|
has_view_portfolio_permission=lambda self, portfolio: True,
|
||||||
has_any_domains_portfolio_permission=lambda self, portfolio: True,
|
has_any_domains_portfolio_permission=lambda self, portfolio: True,
|
||||||
)
|
)
|
||||||
def test_portfolio_role_summary_member_domain_manager(self):
|
def test_portfolio_role_summary_member_domain_manager(self):
|
||||||
# Test if the user has 'Member' and 'Domain manager' roles
|
# Test if the user has 'Member' and 'Domain manager' roles
|
||||||
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Domain manager"])
|
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Domain manager"])
|
||||||
|
|
||||||
@patch.multiple(User, has_base_portfolio_permission=lambda self, portfolio: True)
|
@patch.multiple(User, has_view_portfolio_permission=lambda self, portfolio: True)
|
||||||
def test_portfolio_role_summary_member(self):
|
def test_portfolio_role_summary_member(self):
|
||||||
# Test if the user is recognized as a Member
|
# Test if the user is recognized as a Member
|
||||||
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Member"])
|
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Member"])
|
||||||
|
@ -1253,17 +1253,17 @@ class TestUser(TestCase):
|
||||||
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), [])
|
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), [])
|
||||||
|
|
||||||
@patch("registrar.models.User._has_portfolio_permission")
|
@patch("registrar.models.User._has_portfolio_permission")
|
||||||
def test_has_base_portfolio_permission(self, mock_has_permission):
|
def test_has_view_portfolio_permission(self, mock_has_permission):
|
||||||
mock_has_permission.return_value = True
|
mock_has_permission.return_value = True
|
||||||
|
|
||||||
self.assertTrue(self.user.has_base_portfolio_permission(self.portfolio))
|
self.assertTrue(self.user.has_view_portfolio_permission(self.portfolio))
|
||||||
mock_has_permission.assert_called_once_with(self.portfolio, UserPortfolioPermissionChoices.VIEW_PORTFOLIO)
|
mock_has_permission.assert_called_once_with(self.portfolio, UserPortfolioPermissionChoices.VIEW_PORTFOLIO)
|
||||||
|
|
||||||
@patch("registrar.models.User._has_portfolio_permission")
|
@patch("registrar.models.User._has_portfolio_permission")
|
||||||
def test_has_edit_org_portfolio_permission(self, mock_has_permission):
|
def test_has_edit_portfolio_permission(self, mock_has_permission):
|
||||||
mock_has_permission.return_value = True
|
mock_has_permission.return_value = True
|
||||||
|
|
||||||
self.assertTrue(self.user.has_edit_org_portfolio_permission(self.portfolio))
|
self.assertTrue(self.user.has_edit_portfolio_permission(self.portfolio))
|
||||||
mock_has_permission.assert_called_once_with(self.portfolio, UserPortfolioPermissionChoices.EDIT_PORTFOLIO)
|
mock_has_permission.assert_called_once_with(self.portfolio, UserPortfolioPermissionChoices.EDIT_PORTFOLIO)
|
||||||
|
|
||||||
@patch("registrar.models.User._has_portfolio_permission")
|
@patch("registrar.models.User._has_portfolio_permission")
|
||||||
|
@ -1306,20 +1306,6 @@ class TestUser(TestCase):
|
||||||
self.assertTrue(self.user.has_edit_request_portfolio_permission(self.portfolio))
|
self.assertTrue(self.user.has_edit_request_portfolio_permission(self.portfolio))
|
||||||
mock_has_permission.assert_called_once_with(self.portfolio, UserPortfolioPermissionChoices.EDIT_REQUESTS)
|
mock_has_permission.assert_called_once_with(self.portfolio, UserPortfolioPermissionChoices.EDIT_REQUESTS)
|
||||||
|
|
||||||
@patch("registrar.models.User._has_portfolio_permission")
|
|
||||||
def test_has_view_suborganization_portfolio_permission(self, mock_has_permission):
|
|
||||||
mock_has_permission.return_value = True
|
|
||||||
|
|
||||||
self.assertTrue(self.user.has_view_suborganization_portfolio_permission(self.portfolio))
|
|
||||||
mock_has_permission.assert_called_once_with(self.portfolio, UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION)
|
|
||||||
|
|
||||||
@patch("registrar.models.User._has_portfolio_permission")
|
|
||||||
def test_has_edit_suborganization_portfolio_permission(self, mock_has_permission):
|
|
||||||
mock_has_permission.return_value = True
|
|
||||||
|
|
||||||
self.assertTrue(self.user.has_edit_suborganization_portfolio_permission(self.portfolio))
|
|
||||||
mock_has_permission.assert_called_once_with(self.portfolio, UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION)
|
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
def test_check_transition_domains_without_domains_on_login(self):
|
def test_check_transition_domains_without_domains_on_login(self):
|
||||||
"""A user's on_each_login callback does not check transition domains.
|
"""A user's on_each_login callback does not check transition domains.
|
||||||
|
|
|
@ -1106,7 +1106,7 @@ class TestDomainRequest(TestCase):
|
||||||
federal_agency=fed_agency,
|
federal_agency=fed_agency,
|
||||||
organization_type=DomainRequest.OrganizationChoices.FEDERAL,
|
organization_type=DomainRequest.OrganizationChoices.FEDERAL,
|
||||||
)
|
)
|
||||||
user_portfolio_permission = UserPortfolioPermission.objects.create( # noqa: F841
|
UserPortfolioPermission.objects.create(
|
||||||
user=self.dummy_user_3, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
user=self.dummy_user_3, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
)
|
)
|
||||||
# Adds cc'ed email in this test's allow list
|
# Adds cc'ed email in this test's allow list
|
||||||
|
|
|
@ -725,7 +725,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
|
||||||
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
|
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
|
||||||
self.assertEqual(csv_content, expected_content)
|
self.assertEqual(csv_content, expected_content)
|
||||||
|
|
||||||
# @less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
def test_domain_request_data_full(self):
|
def test_domain_request_data_full(self):
|
||||||
"""Tests the full domain request report."""
|
"""Tests the full domain request report."""
|
||||||
# Remove "Submitted at" because we can't guess this immutable, dynamically generated test data
|
# Remove "Submitted at" because we can't guess this immutable, dynamically generated test data
|
||||||
|
|
|
@ -849,7 +849,10 @@ class TestDomainManagers(TestDomainOverview):
|
||||||
|
|
||||||
# Verify that the invitation emails were sent
|
# Verify that the invitation emails were sent
|
||||||
mock_send_portfolio_email.assert_called_once_with(
|
mock_send_portfolio_email.assert_called_once_with(
|
||||||
email="mayor@igorville.gov", requestor=self.user, portfolio=self.portfolio
|
email="mayor@igorville.gov",
|
||||||
|
requestor=self.user,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
is_admin_invitation=False,
|
||||||
)
|
)
|
||||||
mock_send_domain_email.assert_called_once()
|
mock_send_domain_email.assert_called_once()
|
||||||
call_args = mock_send_domain_email.call_args.kwargs
|
call_args = mock_send_domain_email.call_args.kwargs
|
||||||
|
@ -903,7 +906,10 @@ class TestDomainManagers(TestDomainOverview):
|
||||||
|
|
||||||
# Verify that the invitation emails were sent
|
# Verify that the invitation emails were sent
|
||||||
mock_send_portfolio_email.assert_called_once_with(
|
mock_send_portfolio_email.assert_called_once_with(
|
||||||
email="notauser@igorville.gov", requestor=self.user, portfolio=self.portfolio
|
email="notauser@igorville.gov",
|
||||||
|
requestor=self.user,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
is_admin_invitation=False,
|
||||||
)
|
)
|
||||||
mock_send_domain_email.assert_called_once()
|
mock_send_domain_email.assert_called_once()
|
||||||
call_args = mock_send_domain_email.call_args.kwargs
|
call_args = mock_send_domain_email.call_args.kwargs
|
||||||
|
@ -1038,7 +1044,10 @@ class TestDomainManagers(TestDomainOverview):
|
||||||
|
|
||||||
# Verify that the invitation emails were sent
|
# Verify that the invitation emails were sent
|
||||||
mock_send_portfolio_email.assert_called_once_with(
|
mock_send_portfolio_email.assert_called_once_with(
|
||||||
email="mayor@igorville.gov", requestor=self.user, portfolio=self.portfolio
|
email="mayor@igorville.gov",
|
||||||
|
requestor=self.user,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
is_admin_invitation=False,
|
||||||
)
|
)
|
||||||
mock_send_domain_email.assert_not_called()
|
mock_send_domain_email.assert_not_called()
|
||||||
|
|
||||||
|
@ -1054,6 +1063,23 @@ class TestDomainManagers(TestDomainOverview):
|
||||||
success_page = success_result.follow()
|
success_page = success_result.follow()
|
||||||
self.assertContains(success_page, "Failed to send email.")
|
self.assertContains(success_page, "Failed to send email.")
|
||||||
|
|
||||||
|
@boto3_mocking.patching
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@patch("registrar.views.domain.send_templated_email")
|
||||||
|
def test_domain_remove_manager(self, mock_send_templated_email):
|
||||||
|
"""Removing a domain manager sends notification email to other domain managers."""
|
||||||
|
self.manager, _ = User.objects.get_or_create(email="mayor@igorville.com", first_name="Hello", last_name="World")
|
||||||
|
self.manager_domain_permission, _ = UserDomainRole.objects.get_or_create(user=self.manager, domain=self.domain)
|
||||||
|
self.client.post(reverse("domain-user-delete", kwargs={"pk": self.domain.id, "user_pk": self.manager.id}))
|
||||||
|
|
||||||
|
# Verify that the notification emails were sent to domain manager
|
||||||
|
mock_send_templated_email.assert_called_once_with(
|
||||||
|
"emails/domain_manager_deleted_notification.txt",
|
||||||
|
"emails/domain_manager_deleted_notification_subject.txt",
|
||||||
|
to_address="info@example.com",
|
||||||
|
context=ANY,
|
||||||
|
)
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
@patch("registrar.views.domain.send_domain_invitation_email")
|
@patch("registrar.views.domain.send_domain_invitation_email")
|
||||||
def test_domain_invitation_created(self, mock_send_domain_email):
|
def test_domain_invitation_created(self, mock_send_domain_email):
|
||||||
|
@ -2181,7 +2207,7 @@ class TestDomainSuborganization(TestDomainOverview):
|
||||||
self.domain_information.refresh_from_db()
|
self.domain_information.refresh_from_db()
|
||||||
|
|
||||||
# Add portfolio perms to the user object
|
# Add portfolio perms to the user object
|
||||||
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
|
UserPortfolioPermission.objects.get_or_create(
|
||||||
user=self.user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
user=self.user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -372,6 +372,21 @@ class GetPortfolioMembersJsonTest(MockEppLib, WebTest):
|
||||||
domain=domain3,
|
domain=domain3,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# create another domain in the portfolio
|
||||||
|
# but make sure the domain invitation is canceled
|
||||||
|
domain4 = Domain.objects.create(
|
||||||
|
name="somedomain4.com",
|
||||||
|
)
|
||||||
|
DomainInformation.objects.create(
|
||||||
|
creator=self.user,
|
||||||
|
domain=domain4,
|
||||||
|
)
|
||||||
|
DomainInvitation.objects.create(
|
||||||
|
email=self.email6,
|
||||||
|
domain=domain4,
|
||||||
|
status=DomainInvitation.DomainInvitationStatus.CANCELED,
|
||||||
|
)
|
||||||
|
|
||||||
response = self.app.get(reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id})
|
response = self.app.get(reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id})
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
data = response.json
|
data = response.json
|
||||||
|
@ -381,6 +396,7 @@ class GetPortfolioMembersJsonTest(MockEppLib, WebTest):
|
||||||
self.assertIn("somedomain1.com", domain_names)
|
self.assertIn("somedomain1.com", domain_names)
|
||||||
self.assertIn("thissecondinvitetestsasubqueryinjson@lets.notbreak", domain_names)
|
self.assertIn("thissecondinvitetestsasubqueryinjson@lets.notbreak", domain_names)
|
||||||
self.assertNotIn("somedomain3.com", domain_names)
|
self.assertNotIn("somedomain3.com", domain_names)
|
||||||
|
self.assertNotIn("somedomain4.com", domain_names)
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
@override_flag("organization_feature", active=True)
|
@override_flag("organization_feature", active=True)
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,6 +1,9 @@
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from registrar.models import Domain, DomainInvitation, UserDomainRole
|
from registrar.models import Domain, DomainInvitation, UserDomainRole
|
||||||
|
from registrar.models.portfolio import Portfolio
|
||||||
|
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||||
|
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
||||||
from registrar.utility.errors import (
|
from registrar.utility.errors import (
|
||||||
AlreadyDomainInvitedError,
|
AlreadyDomainInvitedError,
|
||||||
AlreadyDomainManagerError,
|
AlreadyDomainManagerError,
|
||||||
|
@ -37,8 +40,8 @@ def send_domain_invitation_email(
|
||||||
OutsideOrgMemberError: If the requested_user is part of a different organization.
|
OutsideOrgMemberError: If the requested_user is part of a different organization.
|
||||||
EmailSendingError: If there is an error while sending the email.
|
EmailSendingError: If there is an error while sending the email.
|
||||||
"""
|
"""
|
||||||
domains = normalize_domains(domains)
|
domains = _normalize_domains(domains)
|
||||||
requestor_email = get_requestor_email(requestor, domains)
|
requestor_email = _get_requestor_email(requestor, domains=domains)
|
||||||
|
|
||||||
_validate_invitation(email, requested_user, domains, requestor, is_member_of_different_org)
|
_validate_invitation(email, requested_user, domains, requestor, is_member_of_different_org)
|
||||||
|
|
||||||
|
@ -92,22 +95,27 @@ def send_emails_to_domain_managers(email: str, requestor_email, domain: Domain,
|
||||||
return all_emails_sent
|
return all_emails_sent
|
||||||
|
|
||||||
|
|
||||||
def normalize_domains(domains: Domain | list[Domain]) -> list[Domain]:
|
def _normalize_domains(domains: Domain | list[Domain]) -> list[Domain]:
|
||||||
"""Ensures domains is always a list."""
|
"""Ensures domains is always a list."""
|
||||||
return [domains] if isinstance(domains, Domain) else domains
|
return [domains] if isinstance(domains, Domain) else domains
|
||||||
|
|
||||||
|
|
||||||
def get_requestor_email(requestor, domains):
|
def _get_requestor_email(requestor, domains=None, portfolio=None):
|
||||||
"""Get the requestor's email or raise an error if it's missing.
|
"""Get the requestor's email or raise an error if it's missing.
|
||||||
|
|
||||||
If the requestor is staff, default email is returned.
|
If the requestor is staff, default email is returned.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
MissingEmailError
|
||||||
"""
|
"""
|
||||||
if requestor.is_staff:
|
if requestor.is_staff:
|
||||||
return settings.DEFAULT_FROM_EMAIL
|
return settings.DEFAULT_FROM_EMAIL
|
||||||
|
|
||||||
if not requestor.email or requestor.email.strip() == "":
|
if not requestor.email or requestor.email.strip() == "":
|
||||||
domain_names = ", ".join([domain.name for domain in domains])
|
domain_names = None
|
||||||
raise MissingEmailError(email=requestor.email, domain=domain_names)
|
if domains:
|
||||||
|
domain_names = ", ".join([domain.name for domain in domains])
|
||||||
|
raise MissingEmailError(email=requestor.email, domain=domain_names, portfolio=portfolio)
|
||||||
|
|
||||||
return requestor.email
|
return requestor.email
|
||||||
|
|
||||||
|
@ -169,7 +177,7 @@ def send_invitation_email(email, requestor_email, domains, requested_user):
|
||||||
raise EmailSendingError(f"Could not send email invitation to {email} for domains: {domain_names}") from err
|
raise EmailSendingError(f"Could not send email invitation to {email} for domains: {domain_names}") from err
|
||||||
|
|
||||||
|
|
||||||
def send_portfolio_invitation_email(email: str, requestor, portfolio):
|
def send_portfolio_invitation_email(email: str, requestor, portfolio, is_admin_invitation):
|
||||||
"""
|
"""
|
||||||
Sends a portfolio member invitation email to the specified address.
|
Sends a portfolio member invitation email to the specified address.
|
||||||
|
|
||||||
|
@ -179,21 +187,17 @@ def send_portfolio_invitation_email(email: str, requestor, portfolio):
|
||||||
email (str): Email address of the recipient
|
email (str): Email address of the recipient
|
||||||
requestor (User): The user initiating the invitation.
|
requestor (User): The user initiating the invitation.
|
||||||
portfolio (Portfolio): The portfolio object for which the invitation is being sent.
|
portfolio (Portfolio): The portfolio object for which the invitation is being sent.
|
||||||
|
is_admin_invitation (boolean): boolean indicating if the invitation is an admin invitation
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Boolean indicating if all messages were sent successfully.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
MissingEmailError: If the requestor has no email associated with their account.
|
MissingEmailError: If the requestor has no email associated with their account.
|
||||||
EmailSendingError: If there is an error while sending the email.
|
EmailSendingError: If there is an error while sending the email.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Default email address for staff
|
requestor_email = _get_requestor_email(requestor, portfolio=portfolio)
|
||||||
requestor_email = settings.DEFAULT_FROM_EMAIL
|
|
||||||
|
|
||||||
# Check if the requestor is staff and has an email
|
|
||||||
if not requestor.is_staff:
|
|
||||||
if not requestor.email or requestor.email.strip() == "":
|
|
||||||
raise MissingEmailError(email=email, portfolio=portfolio)
|
|
||||||
else:
|
|
||||||
requestor_email = requestor.email
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
send_templated_email(
|
send_templated_email(
|
||||||
|
@ -210,3 +214,119 @@ def send_portfolio_invitation_email(email: str, requestor, portfolio):
|
||||||
raise EmailSendingError(
|
raise EmailSendingError(
|
||||||
f"Could not sent email invitation to {email} for portfolio {portfolio}. Portfolio invitation not saved."
|
f"Could not sent email invitation to {email} for portfolio {portfolio}. Portfolio invitation not saved."
|
||||||
) from err
|
) from err
|
||||||
|
|
||||||
|
all_admin_emails_sent = True
|
||||||
|
# send emails to portfolio admins
|
||||||
|
if is_admin_invitation:
|
||||||
|
all_admin_emails_sent = _send_portfolio_admin_addition_emails_to_portfolio_admins(
|
||||||
|
email=email,
|
||||||
|
requestor_email=requestor_email,
|
||||||
|
portfolio=portfolio,
|
||||||
|
)
|
||||||
|
return all_admin_emails_sent
|
||||||
|
|
||||||
|
|
||||||
|
def send_portfolio_admin_addition_emails(email: str, requestor, portfolio: Portfolio):
|
||||||
|
"""
|
||||||
|
Notifies all portfolio admins of the provided portfolio of a newly invited portfolio admin
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Boolean indicating if all messages were sent successfully.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
MissingEmailError
|
||||||
|
"""
|
||||||
|
requestor_email = _get_requestor_email(requestor, portfolio=portfolio)
|
||||||
|
return _send_portfolio_admin_addition_emails_to_portfolio_admins(email, requestor_email, portfolio)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_portfolio_admin_addition_emails_to_portfolio_admins(email: str, requestor_email, portfolio: Portfolio):
|
||||||
|
"""
|
||||||
|
Notifies all portfolio admins of the provided portfolio of a newly invited portfolio admin
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Boolean indicating if all messages were sent successfully.
|
||||||
|
"""
|
||||||
|
all_emails_sent = True
|
||||||
|
# Get each portfolio admin from list
|
||||||
|
user_portfolio_permissions = UserPortfolioPermission.objects.filter(
|
||||||
|
portfolio=portfolio, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
|
).exclude(user__email=email)
|
||||||
|
for user_portfolio_permission in user_portfolio_permissions:
|
||||||
|
# Send email to each portfolio_admin
|
||||||
|
user = user_portfolio_permission.user
|
||||||
|
try:
|
||||||
|
send_templated_email(
|
||||||
|
"emails/portfolio_admin_addition_notification.txt",
|
||||||
|
"emails/portfolio_admin_addition_notification_subject.txt",
|
||||||
|
to_address=user.email,
|
||||||
|
context={
|
||||||
|
"portfolio": portfolio,
|
||||||
|
"requestor_email": requestor_email,
|
||||||
|
"invited_email_address": email,
|
||||||
|
"portfolio_admin": user,
|
||||||
|
"date": date.today(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except EmailSendingError:
|
||||||
|
logger.warning(
|
||||||
|
"Could not send email organization admin notification to %s " "for portfolio: %s",
|
||||||
|
user.email,
|
||||||
|
portfolio.organization_name,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
all_emails_sent = False
|
||||||
|
return all_emails_sent
|
||||||
|
|
||||||
|
|
||||||
|
def send_portfolio_admin_removal_emails(email: str, requestor, portfolio: Portfolio):
|
||||||
|
"""
|
||||||
|
Notifies all portfolio admins of the provided portfolio of a removed portfolio admin
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Boolean indicating if all messages were sent successfully.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
MissingEmailError
|
||||||
|
"""
|
||||||
|
requestor_email = _get_requestor_email(requestor, portfolio=portfolio)
|
||||||
|
return _send_portfolio_admin_removal_emails_to_portfolio_admins(email, requestor_email, portfolio)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_portfolio_admin_removal_emails_to_portfolio_admins(email: str, requestor_email, portfolio: Portfolio):
|
||||||
|
"""
|
||||||
|
Notifies all portfolio admins of the provided portfolio of a removed portfolio admin
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Boolean indicating if all messages were sent successfully.
|
||||||
|
"""
|
||||||
|
all_emails_sent = True
|
||||||
|
# Get each portfolio admin from list
|
||||||
|
user_portfolio_permissions = UserPortfolioPermission.objects.filter(
|
||||||
|
portfolio=portfolio, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
|
).exclude(user__email=email)
|
||||||
|
for user_portfolio_permission in user_portfolio_permissions:
|
||||||
|
# Send email to each portfolio_admin
|
||||||
|
user = user_portfolio_permission.user
|
||||||
|
try:
|
||||||
|
send_templated_email(
|
||||||
|
"emails/portfolio_admin_removal_notification.txt",
|
||||||
|
"emails/portfolio_admin_removal_notification_subject.txt",
|
||||||
|
to_address=user.email,
|
||||||
|
context={
|
||||||
|
"portfolio": portfolio,
|
||||||
|
"requestor_email": requestor_email,
|
||||||
|
"removed_email_address": email,
|
||||||
|
"portfolio_admin": user,
|
||||||
|
"date": date.today(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except EmailSendingError:
|
||||||
|
logger.warning(
|
||||||
|
"Could not send email organization admin notification to %s " "for portfolio: %s",
|
||||||
|
user.email,
|
||||||
|
portfolio.organization_name,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
all_emails_sent = False
|
||||||
|
return all_emails_sent
|
||||||
|
|
|
@ -1234,7 +1234,9 @@ class DomainAddUserView(DomainFormBaseView):
|
||||||
and requestor_can_update_portfolio
|
and requestor_can_update_portfolio
|
||||||
and not member_of_this_org
|
and not member_of_this_org
|
||||||
):
|
):
|
||||||
send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=domain_org)
|
send_portfolio_invitation_email(
|
||||||
|
email=requested_email, requestor=requestor, portfolio=domain_org, is_admin_invitation=False
|
||||||
|
)
|
||||||
portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(
|
portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(
|
||||||
email=requested_email, portfolio=domain_org, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
|
email=requested_email, portfolio=domain_org, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
|
||||||
)
|
)
|
||||||
|
@ -1346,10 +1348,49 @@ class DomainDeleteUserView(UserDomainRolePermissionDeleteView):
|
||||||
# Delete the object
|
# Delete the object
|
||||||
super().form_valid(form)
|
super().form_valid(form)
|
||||||
|
|
||||||
|
# Email all domain managers that domain manager has been removed
|
||||||
|
domain = self.object.domain
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"domain": domain,
|
||||||
|
"removed_by": self.request.user,
|
||||||
|
"manager_removed": self.object.user,
|
||||||
|
"date": date.today(),
|
||||||
|
"changes": "Domain Manager",
|
||||||
|
}
|
||||||
|
self.email_domain_managers(
|
||||||
|
domain,
|
||||||
|
"emails/domain_manager_deleted_notification.txt",
|
||||||
|
"emails/domain_manager_deleted_notification_subject.txt",
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
|
||||||
# Add a success message
|
# Add a success message
|
||||||
messages.success(self.request, self.get_success_message())
|
messages.success(self.request, self.get_success_message())
|
||||||
return redirect(self.get_success_url())
|
return redirect(self.get_success_url())
|
||||||
|
|
||||||
|
def email_domain_managers(self, domain: Domain, template: str, subject_template: str, context={}):
|
||||||
|
manager_pks = UserDomainRole.objects.filter(domain=domain.pk, role=UserDomainRole.Roles.MANAGER).values_list(
|
||||||
|
"user", flat=True
|
||||||
|
)
|
||||||
|
emails = list(User.objects.filter(pk__in=manager_pks).values_list("email", flat=True))
|
||||||
|
|
||||||
|
for email in emails:
|
||||||
|
try:
|
||||||
|
send_templated_email(
|
||||||
|
template,
|
||||||
|
subject_template,
|
||||||
|
to_address=email,
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
except EmailSendingError:
|
||||||
|
logger.warning(
|
||||||
|
"Could not send notification email to %s for domain %s",
|
||||||
|
email,
|
||||||
|
domain.name,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
"""Custom post implementation to ensure last userdomainrole is not removed and to
|
"""Custom post implementation to ensure last userdomainrole is not removed and to
|
||||||
redirect to home in the event that the user deletes themselves"""
|
redirect to home in the event that the user deletes themselves"""
|
||||||
|
|
|
@ -123,7 +123,11 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
|
||||||
|
|
||||||
# Subquery to get concatenated domain information for each email
|
# Subquery to get concatenated domain information for each email
|
||||||
domain_invitations = (
|
domain_invitations = (
|
||||||
DomainInvitation.objects.filter(email=OuterRef("email"), domain__domain_info__portfolio=portfolio)
|
DomainInvitation.objects.filter(
|
||||||
|
email=OuterRef("email"),
|
||||||
|
domain__domain_info__portfolio=portfolio,
|
||||||
|
status=DomainInvitation.DomainInvitationStatus.INVITED,
|
||||||
|
)
|
||||||
.annotate(
|
.annotate(
|
||||||
concatenated_info=Concat(F("domain__id"), Value(":"), F("domain__name"), output_field=CharField())
|
concatenated_info=Concat(F("domain__id"), Value(":"), F("domain__name"), output_field=CharField())
|
||||||
)
|
)
|
||||||
|
|
|
@ -15,7 +15,12 @@ from registrar.models.user_domain_role import UserDomainRole
|
||||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||||
from registrar.utility.email import EmailSendingError
|
from registrar.utility.email import EmailSendingError
|
||||||
from registrar.utility.email_invitations import send_domain_invitation_email, send_portfolio_invitation_email
|
from registrar.utility.email_invitations import (
|
||||||
|
send_domain_invitation_email,
|
||||||
|
send_portfolio_admin_addition_emails,
|
||||||
|
send_portfolio_admin_removal_emails,
|
||||||
|
send_portfolio_invitation_email,
|
||||||
|
)
|
||||||
from registrar.utility.errors import MissingEmailError
|
from registrar.utility.errors import MissingEmailError
|
||||||
from registrar.utility.enums import DefaultUserValues
|
from registrar.utility.enums import DefaultUserValues
|
||||||
from registrar.views.utility.mixins import PortfolioMemberPermission
|
from registrar.views.utility.mixins import PortfolioMemberPermission
|
||||||
|
@ -143,6 +148,19 @@ class PortfolioMemberDeleteView(PortfolioMemberPermission, View):
|
||||||
messages.error(request, error_message)
|
messages.error(request, error_message)
|
||||||
return redirect(reverse("member", kwargs={"pk": pk}))
|
return redirect(reverse("member", kwargs={"pk": pk}))
|
||||||
|
|
||||||
|
# if member being removed is an admin
|
||||||
|
if UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_member_permission.roles:
|
||||||
|
try:
|
||||||
|
# attempt to send notification emails of the removal to other portfolio admins
|
||||||
|
if not send_portfolio_admin_removal_emails(
|
||||||
|
email=portfolio_member_permission.user.email,
|
||||||
|
requestor=request.user,
|
||||||
|
portfolio=portfolio_member_permission.portfolio,
|
||||||
|
):
|
||||||
|
messages.warning(self.request, "Could not send email notification to existing organization admins.")
|
||||||
|
except Exception as e:
|
||||||
|
self._handle_exceptions(e)
|
||||||
|
|
||||||
# passed all error conditions
|
# passed all error conditions
|
||||||
portfolio_member_permission.delete()
|
portfolio_member_permission.delete()
|
||||||
|
|
||||||
|
@ -154,6 +172,18 @@ class PortfolioMemberDeleteView(PortfolioMemberPermission, View):
|
||||||
messages.success(request, success_message)
|
messages.success(request, success_message)
|
||||||
return redirect(reverse("members"))
|
return redirect(reverse("members"))
|
||||||
|
|
||||||
|
def _handle_exceptions(self, exception):
|
||||||
|
"""Handle exceptions raised during the process."""
|
||||||
|
if isinstance(exception, MissingEmailError):
|
||||||
|
messages.warning(self.request, "Could not send email notification to existing organization admins.")
|
||||||
|
logger.warning(
|
||||||
|
"Could not send email notification to existing organization admins.",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning("Could not send email notification to existing organization admins.", exc_info=True)
|
||||||
|
messages.warning(self.request, "Could not send email notification to existing organization admins.")
|
||||||
|
|
||||||
|
|
||||||
class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View):
|
class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View):
|
||||||
|
|
||||||
|
@ -177,16 +207,33 @@ class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View):
|
||||||
|
|
||||||
def post(self, request, pk):
|
def post(self, request, pk):
|
||||||
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
|
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
|
||||||
user_initially_is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_permission.roles
|
|
||||||
user = portfolio_permission.user
|
user = portfolio_permission.user
|
||||||
form = self.form_class(request.POST, instance=portfolio_permission)
|
form = self.form_class(request.POST, instance=portfolio_permission)
|
||||||
|
removing_admin_role_on_self = False
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
# Check if user is removing their own admin or edit role
|
try:
|
||||||
removing_admin_role_on_self = (
|
if form.is_change_from_member_to_admin():
|
||||||
request.user == user
|
if not send_portfolio_admin_addition_emails(
|
||||||
and user_initially_is_admin
|
email=portfolio_permission.user.email,
|
||||||
and UserPortfolioRoleChoices.ORGANIZATION_ADMIN not in form.cleaned_data.get("role", [])
|
requestor=request.user,
|
||||||
)
|
portfolio=portfolio_permission.portfolio,
|
||||||
|
):
|
||||||
|
messages.warning(
|
||||||
|
self.request, "Could not send email notification to existing organization admins."
|
||||||
|
)
|
||||||
|
elif form.is_change_from_admin_to_member():
|
||||||
|
if not send_portfolio_admin_removal_emails(
|
||||||
|
email=portfolio_permission.user.email,
|
||||||
|
requestor=request.user,
|
||||||
|
portfolio=portfolio_permission.portfolio,
|
||||||
|
):
|
||||||
|
messages.warning(
|
||||||
|
self.request, "Could not send email notification to existing organization admins."
|
||||||
|
)
|
||||||
|
# Check if user is removing their own admin or edit role
|
||||||
|
removing_admin_role_on_self = request.user == user
|
||||||
|
except Exception as e:
|
||||||
|
self._handle_exceptions(e)
|
||||||
form.save()
|
form.save()
|
||||||
messages.success(self.request, "The member access and permission changes have been saved.")
|
messages.success(self.request, "The member access and permission changes have been saved.")
|
||||||
return redirect("member", pk=pk) if not removing_admin_role_on_self else redirect("home")
|
return redirect("member", pk=pk) if not removing_admin_role_on_self else redirect("home")
|
||||||
|
@ -200,6 +247,18 @@ class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View):
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _handle_exceptions(self, exception):
|
||||||
|
"""Handle exceptions raised during the process."""
|
||||||
|
if isinstance(exception, MissingEmailError):
|
||||||
|
messages.warning(self.request, "Could not send email notification to existing organization admins.")
|
||||||
|
logger.warning(
|
||||||
|
"Could not send email notification to existing organization admins.",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning("Could not send email notification to existing organization admins.", exc_info=True)
|
||||||
|
messages.warning(self.request, "Could not send email notification to existing organization admins.")
|
||||||
|
|
||||||
|
|
||||||
class PortfolioMemberDomainsView(PortfolioMemberDomainsPermissionView, View):
|
class PortfolioMemberDomainsView(PortfolioMemberDomainsPermissionView, View):
|
||||||
|
|
||||||
|
@ -380,6 +439,17 @@ class PortfolioInvitedMemberDeleteView(PortfolioMemberPermission, View):
|
||||||
"""
|
"""
|
||||||
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
|
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
|
||||||
|
|
||||||
|
# if invitation being removed is an admin
|
||||||
|
if UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_invitation.roles:
|
||||||
|
try:
|
||||||
|
# attempt to send notification emails of the removal to portfolio admins
|
||||||
|
if not send_portfolio_admin_removal_emails(
|
||||||
|
email=portfolio_invitation.email, requestor=request.user, portfolio=portfolio_invitation.portfolio
|
||||||
|
):
|
||||||
|
messages.warning(self.request, "Could not send email notification to existing organization admins.")
|
||||||
|
except Exception as e:
|
||||||
|
self._handle_exceptions(e)
|
||||||
|
|
||||||
portfolio_invitation.delete()
|
portfolio_invitation.delete()
|
||||||
|
|
||||||
success_message = f"You've removed {portfolio_invitation.email} from the organization."
|
success_message = f"You've removed {portfolio_invitation.email} from the organization."
|
||||||
|
@ -390,6 +460,18 @@ class PortfolioInvitedMemberDeleteView(PortfolioMemberPermission, View):
|
||||||
messages.success(request, success_message)
|
messages.success(request, success_message)
|
||||||
return redirect(reverse("members"))
|
return redirect(reverse("members"))
|
||||||
|
|
||||||
|
def _handle_exceptions(self, exception):
|
||||||
|
"""Handle exceptions raised during the process."""
|
||||||
|
if isinstance(exception, MissingEmailError):
|
||||||
|
messages.warning(self.request, "Could not send email notification to existing organization admins.")
|
||||||
|
logger.warning(
|
||||||
|
"Could not send email notification to existing organization admins.",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning("Could not send email notification to existing organization admins.", exc_info=True)
|
||||||
|
messages.warning(self.request, "Could not send email notification to existing organization admins.")
|
||||||
|
|
||||||
|
|
||||||
class PortfolioInvitedMemberEditView(PortfolioMemberEditPermissionView, View):
|
class PortfolioInvitedMemberEditView(PortfolioMemberEditPermissionView, View):
|
||||||
|
|
||||||
|
@ -413,6 +495,27 @@ class PortfolioInvitedMemberEditView(PortfolioMemberEditPermissionView, View):
|
||||||
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
|
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
|
||||||
form = self.form_class(request.POST, instance=portfolio_invitation)
|
form = self.form_class(request.POST, instance=portfolio_invitation)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
|
try:
|
||||||
|
if form.is_change_from_member_to_admin():
|
||||||
|
if not send_portfolio_admin_addition_emails(
|
||||||
|
email=portfolio_invitation.email,
|
||||||
|
requestor=request.user,
|
||||||
|
portfolio=portfolio_invitation.portfolio,
|
||||||
|
):
|
||||||
|
messages.warning(
|
||||||
|
self.request, "Could not send email notification to existing organization admins."
|
||||||
|
)
|
||||||
|
elif form.is_change_from_admin_to_member():
|
||||||
|
if not send_portfolio_admin_removal_emails(
|
||||||
|
email=portfolio_invitation.email,
|
||||||
|
requestor=request.user,
|
||||||
|
portfolio=portfolio_invitation.portfolio,
|
||||||
|
):
|
||||||
|
messages.warning(
|
||||||
|
self.request, "Could not send email notification to existing organization admins."
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self._handle_exceptions(e)
|
||||||
form.save()
|
form.save()
|
||||||
messages.success(self.request, "The member access and permission changes have been saved.")
|
messages.success(self.request, "The member access and permission changes have been saved.")
|
||||||
return redirect("invitedmember", pk=pk)
|
return redirect("invitedmember", pk=pk)
|
||||||
|
@ -426,6 +529,18 @@ class PortfolioInvitedMemberEditView(PortfolioMemberEditPermissionView, View):
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _handle_exceptions(self, exception):
|
||||||
|
"""Handle exceptions raised during the process."""
|
||||||
|
if isinstance(exception, MissingEmailError):
|
||||||
|
messages.warning(self.request, "Could not send email notification to existing organization admins.")
|
||||||
|
logger.warning(
|
||||||
|
"Could not send email notification to existing organization admins.",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning("Could not send email notification to existing organization admins.", exc_info=True)
|
||||||
|
messages.warning(self.request, "Could not send email notification to existing organization admins.")
|
||||||
|
|
||||||
|
|
||||||
class PortfolioInvitedMemberDomainsView(PortfolioMemberDomainsPermissionView, View):
|
class PortfolioInvitedMemberDomainsView(PortfolioMemberDomainsPermissionView, View):
|
||||||
|
|
||||||
|
@ -641,7 +756,7 @@ class PortfolioOrganizationView(PortfolioBasePermissionView, FormMixin):
|
||||||
"""Add additional context data to the template."""
|
"""Add additional context data to the template."""
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
portfolio = self.request.session.get("portfolio")
|
portfolio = self.request.session.get("portfolio")
|
||||||
context["has_edit_org_portfolio_permission"] = self.request.user.has_edit_org_portfolio_permission(portfolio)
|
context["has_edit_portfolio_permission"] = self.request.user.has_edit_portfolio_permission(portfolio)
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def get_object(self, queryset=None):
|
def get_object(self, queryset=None):
|
||||||
|
@ -781,12 +896,19 @@ class PortfolioAddMemberView(PortfolioMembersPermissionView, FormMixin):
|
||||||
requested_email = form.cleaned_data["email"]
|
requested_email = form.cleaned_data["email"]
|
||||||
requestor = self.request.user
|
requestor = self.request.user
|
||||||
portfolio = form.cleaned_data["portfolio"]
|
portfolio = form.cleaned_data["portfolio"]
|
||||||
|
is_admin_invitation = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in form.cleaned_data["roles"]
|
||||||
|
|
||||||
requested_user = User.objects.filter(email=requested_email).first()
|
requested_user = User.objects.filter(email=requested_email).first()
|
||||||
permission_exists = UserPortfolioPermission.objects.filter(user=requested_user, portfolio=portfolio).exists()
|
permission_exists = UserPortfolioPermission.objects.filter(user=requested_user, portfolio=portfolio).exists()
|
||||||
try:
|
try:
|
||||||
if not requested_user or not permission_exists:
|
if not requested_user or not permission_exists:
|
||||||
send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=portfolio)
|
if not send_portfolio_invitation_email(
|
||||||
|
email=requested_email,
|
||||||
|
requestor=requestor,
|
||||||
|
portfolio=portfolio,
|
||||||
|
is_admin_invitation=is_admin_invitation,
|
||||||
|
):
|
||||||
|
messages.warning(self.request, "Could not send email notification to existing organization admins.")
|
||||||
portfolio_invitation = form.save()
|
portfolio_invitation = form.save()
|
||||||
# if user exists for email, immediately retrieve portfolio invitation upon creation
|
# if user exists for email, immediately retrieve portfolio invitation upon creation
|
||||||
if requested_user is not None:
|
if requested_user is not None:
|
||||||
|
@ -809,7 +931,7 @@ class PortfolioAddMemberView(PortfolioMembersPermissionView, FormMixin):
|
||||||
portfolio,
|
portfolio,
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
messages.warning(self.request, "Could not send portfolio email invitation.")
|
messages.error(self.request, "Could not send organization invitation email.")
|
||||||
elif isinstance(exception, MissingEmailError):
|
elif isinstance(exception, MissingEmailError):
|
||||||
messages.error(self.request, str(exception))
|
messages.error(self.request, str(exception))
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue