mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-24 19:48:36 +02:00
Merge main
This commit is contained in:
commit
819b4a30ab
104 changed files with 2160 additions and 643 deletions
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: Delete and Recreate database
|
||||
run-name: Delete and Recreate 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. |
|
||||
| 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. |
|
||||
| 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 #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
|
||||
# Tell Django how much stuff to log
|
||||
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
|
||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||
# Flag to disable/enable features in prod environments
|
||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
|||
DJANGO_BASE_URL: https://getgov-ad.app.cloud.gov
|
||||
# Tell Django how much stuff to log
|
||||
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
|
||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||
# Flag to disable/enable features in prod environments
|
||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
|||
DJANGO_BASE_URL: https://getgov-ag.app.cloud.gov
|
||||
# Tell Django how much stuff to log
|
||||
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
|
||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||
# Flag to disable/enable features in prod environments
|
||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
|||
DJANGO_BASE_URL: https://getgov-backup.app.cloud.gov
|
||||
# Tell Django how much stuff to log
|
||||
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
|
||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||
# Flag to disable/enable features in prod environments
|
||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
|||
DJANGO_BASE_URL: https://getgov-bob.app.cloud.gov
|
||||
# Tell Django how much stuff to log
|
||||
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
|
||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||
# Flag to disable/enable features in prod environments
|
||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
|||
DJANGO_BASE_URL: https://getgov-cb.app.cloud.gov
|
||||
# Tell Django how much stuff to log
|
||||
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
|
||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||
# Flag to disable/enable features in prod environments
|
||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
|||
DJANGO_BASE_URL: https://getgov-development.app.cloud.gov
|
||||
# Tell Django how much stuff to log
|
||||
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
|
||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||
# Flag to disable/enable features in prod environments
|
||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
|||
DJANGO_BASE_URL: https://getgov-dk.app.cloud.gov
|
||||
# Tell Django how much stuff to log
|
||||
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
|
||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||
# Flag to disable/enable features in prod environments
|
||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
|||
DJANGO_BASE_URL: https://getgov-el.app.cloud.gov
|
||||
# Tell Django how much stuff to log
|
||||
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
|
||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||
# Flag to disable/enable features in prod environments
|
||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
|||
DJANGO_BASE_URL: https://getgov-es.app.cloud.gov
|
||||
# Tell Django how much stuff to log
|
||||
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
|
||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||
# Flag to disable/enable features in prod environments
|
||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
|||
DJANGO_BASE_URL: https://getgov-gd.app.cloud.gov
|
||||
# Tell Django how much stuff to log
|
||||
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
|
||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||
# Flag to disable/enable features in prod environments
|
||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
|||
DJANGO_BASE_URL: https://getgov-hotgov.app.cloud.gov
|
||||
# Tell Django how much stuff to log
|
||||
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
|
||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||
# Flag to disable/enable features in prod environments
|
||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
|||
DJANGO_BASE_URL: https://getgov-ko.app.cloud.gov
|
||||
# Tell Django how much stuff to log
|
||||
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
|
||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||
# Flag to disable/enable features in prod environments
|
||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
|||
DJANGO_BASE_URL: https://getgov-ky.app.cloud.gov
|
||||
# Tell Django how much stuff to log
|
||||
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
|
||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||
# Flag to disable/enable features in prod environments
|
||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
|||
DJANGO_BASE_URL: https://getgov-litterbox.app.cloud.gov
|
||||
# Tell Django how much stuff to log
|
||||
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
|
||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||
# Flag to disable/enable features in prod environments
|
||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
|||
DJANGO_BASE_URL: https://getgov-meoward.app.cloud.gov
|
||||
# Tell Django how much stuff to log
|
||||
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
|
||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||
# Flag to disable/enable features in prod environments
|
||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
|||
DJANGO_BASE_URL: https://getgov-ms.app.cloud.gov
|
||||
# Tell Django how much stuff to log
|
||||
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
|
||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||
# Flag to disable/enable features in prod environments
|
||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
|||
DJANGO_BASE_URL: https://getgov-nl.app.cloud.gov
|
||||
# Tell Django how much stuff to log
|
||||
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
|
||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||
# Flag to disable/enable features in prod environments
|
||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
|||
DJANGO_BASE_URL: https://getgov-rb.app.cloud.gov
|
||||
# Tell Django how much stuff to log
|
||||
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
|
||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||
# Flag to disable/enable features in prod environments
|
||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
|||
DJANGO_BASE_URL: https://getgov-rh.app.cloud.gov
|
||||
# Tell Django how much stuff to log
|
||||
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
|
||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||
# Flag to disable/enable features in prod environments
|
||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
|||
DJANGO_BASE_URL: https://getgov-rjm.app.cloud.gov
|
||||
# Tell Django how much stuff to log
|
||||
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
|
||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||
# Flag to disable/enable features in prod environments
|
||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
|||
DJANGO_BASE_URL: https://manage.get.gov
|
||||
# Tell Django how much stuff to log
|
||||
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
|
||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||
# Which OIDC provider to use
|
||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
|||
DJANGO_BASE_URL: https://getgov-staging.app.cloud.gov
|
||||
# Tell Django how much stuff to log
|
||||
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
|
||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||
# Flag to disable/enable features in prod environments
|
||||
|
|
|
@ -21,6 +21,8 @@ applications:
|
|||
DJANGO_BASE_URL: https://getgov-ENVIRONMENT.app.cloud.gov
|
||||
# Tell Django how much stuff to log
|
||||
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
|
||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||
# Flag to disable/enable features in prod environments
|
||||
|
|
|
@ -79,6 +79,8 @@ services:
|
|||
- POSTGRES_DB=app
|
||||
- POSTGRES_USER=user
|
||||
- POSTGRES_PASSWORD=feedabee
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
node:
|
||||
build:
|
||||
|
|
|
@ -11,6 +11,7 @@ from django.db.models import (
|
|||
Value,
|
||||
When,
|
||||
)
|
||||
|
||||
from django.db.models.functions import Concat, Coalesce
|
||||
from django.http import HttpResponseRedirect
|
||||
from registrar.models.federal_agency import FederalAgency
|
||||
|
@ -24,7 +25,7 @@ from registrar.utility.admin_helpers import (
|
|||
from django.conf import settings
|
||||
from django.contrib.messages import get_messages
|
||||
from django.contrib.admin.helpers import AdminForm
|
||||
from django.shortcuts import redirect
|
||||
from django.shortcuts import redirect, get_object_or_404
|
||||
from django_fsm import get_available_FIELD_transitions, FSMField
|
||||
from registrar.models import DomainInformation, Portfolio, UserPortfolioPermission, DomainInvitation
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
|
@ -1381,9 +1382,13 @@ class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
|
||||
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
|
||||
def delete_view(self, request, object_id, extra_context=None):
|
||||
"""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)
|
||||
|
||||
if isinstance(response, HttpResponseRedirect) and not request.user.has_perm("registrar.full_access_permission"):
|
||||
|
@ -1518,6 +1523,8 @@ class DomainInvitationAdmin(BaseInvitationAdmin):
|
|||
autocomplete_fields = ["domain"]
|
||||
|
||||
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
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
|
@ -1527,6 +1534,37 @@ class DomainInvitationAdmin(BaseInvitationAdmin):
|
|||
# Get the filtered values
|
||||
return super().changelist_view(request, extra_context=extra_context)
|
||||
|
||||
def change_view(self, request, object_id, form_url="", extra_context=None):
|
||||
"""Override the change_view to add the invitation obj for the change_form_object_tools template"""
|
||||
|
||||
if extra_context is None:
|
||||
extra_context = {}
|
||||
|
||||
# Get the domain invitation object
|
||||
invitation = get_object_or_404(DomainInvitation, id=object_id)
|
||||
extra_context["invitation"] = invitation
|
||||
|
||||
if request.method == "POST" and "cancel_invitation" in request.POST:
|
||||
if invitation.status == DomainInvitation.DomainInvitationStatus.INVITED:
|
||||
invitation.cancel_invitation()
|
||||
invitation.save(update_fields=["status"])
|
||||
messages.success(request, _("Invitation canceled successfully."))
|
||||
|
||||
# Redirect back to the change view
|
||||
return redirect(reverse("admin:registrar_domaininvitation_change", args=[object_id]))
|
||||
|
||||
return super().change_view(request, object_id, form_url, 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):
|
||||
"""
|
||||
Override the save_model method.
|
||||
|
@ -1535,6 +1573,7 @@ class DomainInvitationAdmin(BaseInvitationAdmin):
|
|||
which will be successful if a single User exists for that email; otherwise, will
|
||||
just continue to create the invitation.
|
||||
"""
|
||||
|
||||
if not change:
|
||||
domain = obj.domain
|
||||
domain_org = getattr(domain.domain_info, "portfolio", None)
|
||||
|
|
|
@ -1,130 +0,0 @@
|
|||
/** An IIFE for admin in DjangoAdmin to listen to clicks on the growth report export button,
|
||||
* attach the seleted start and end dates to a url that'll trigger the view, and finally
|
||||
* redirect to that url.
|
||||
*
|
||||
* This function also sets the start and end dates to match the url params if they exist
|
||||
*/
|
||||
(function () {
|
||||
// Function to get URL parameter value by name
|
||||
function getParameterByName(name, url) {
|
||||
if (!url) url = window.location.href;
|
||||
name = name.replace(/[\[\]]/g, '\\$&');
|
||||
var regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)'),
|
||||
results = regex.exec(url);
|
||||
if (!results) return null;
|
||||
if (!results[2]) return '';
|
||||
return decodeURIComponent(results[2].replace(/\+/g, ' '));
|
||||
}
|
||||
|
||||
// Get the current date in the format YYYY-MM-DD
|
||||
let currentDate = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Default the value of the start date input field to the current date
|
||||
let startDateInput = document.getElementById('start');
|
||||
|
||||
// Default the value of the end date input field to the current date
|
||||
let endDateInput = document.getElementById('end');
|
||||
|
||||
let exportButtons = document.querySelectorAll('.exportLink');
|
||||
|
||||
if (exportButtons.length > 0) {
|
||||
// Check if start and end dates are present in the URL
|
||||
let urlStartDate = getParameterByName('start_date');
|
||||
let urlEndDate = getParameterByName('end_date');
|
||||
|
||||
// Set input values based on URL parameters or current date
|
||||
startDateInput.value = urlStartDate || currentDate;
|
||||
endDateInput.value = urlEndDate || currentDate;
|
||||
|
||||
exportButtons.forEach((btn) => {
|
||||
btn.addEventListener('click', function () {
|
||||
// Get the selected start and end dates
|
||||
let startDate = startDateInput.value;
|
||||
let endDate = endDateInput.value;
|
||||
let exportUrl = btn.dataset.exportUrl;
|
||||
|
||||
// Build the URL with parameters
|
||||
exportUrl += "?start_date=" + startDate + "&end_date=" + endDate;
|
||||
|
||||
// Redirect to the export URL
|
||||
window.location.href = exportUrl;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
})();
|
||||
|
||||
|
||||
/** An IIFE to initialize the analytics page
|
||||
*/
|
||||
(function () {
|
||||
function createComparativeColumnChart(canvasId, title, labelOne, labelTwo) {
|
||||
var canvas = document.getElementById(canvasId);
|
||||
if (!canvas) {
|
||||
return
|
||||
}
|
||||
|
||||
var ctx = canvas.getContext("2d");
|
||||
|
||||
var listOne = JSON.parse(canvas.getAttribute('data-list-one'));
|
||||
var listTwo = JSON.parse(canvas.getAttribute('data-list-two'));
|
||||
|
||||
var data = {
|
||||
labels: ["Total", "Federal", "Interstate", "State/Territory", "Tribal", "County", "City", "Special District", "School District", "Election Board"],
|
||||
datasets: [
|
||||
{
|
||||
label: labelOne,
|
||||
backgroundColor: "rgba(255, 99, 132, 0.2)",
|
||||
borderColor: "rgba(255, 99, 132, 1)",
|
||||
borderWidth: 1,
|
||||
data: listOne,
|
||||
},
|
||||
{
|
||||
label: labelTwo,
|
||||
backgroundColor: "rgba(75, 192, 192, 0.2)",
|
||||
borderColor: "rgba(75, 192, 192, 1)",
|
||||
borderWidth: 1,
|
||||
data: listTwo,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: title
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
new Chart(ctx, {
|
||||
type: "bar",
|
||||
data: data,
|
||||
options: options,
|
||||
});
|
||||
}
|
||||
|
||||
function initComparativeColumnCharts() {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
createComparativeColumnChart("myChart1", "Managed domains", "Start Date", "End Date");
|
||||
createComparativeColumnChart("myChart2", "Unmanaged domains", "Start Date", "End Date");
|
||||
createComparativeColumnChart("myChart3", "Deleted domains", "Start Date", "End Date");
|
||||
createComparativeColumnChart("myChart4", "Ready domains", "Start Date", "End Date");
|
||||
createComparativeColumnChart("myChart5", "Submitted requests", "Start Date", "End Date");
|
||||
createComparativeColumnChart("myChart6", "All requests", "Start Date", "End Date");
|
||||
});
|
||||
};
|
||||
|
||||
initComparativeColumnCharts();
|
||||
})();
|
177
src/registrar/assets/src/js/getgov-admin/analytics.js
Normal file
177
src/registrar/assets/src/js/getgov-admin/analytics.js
Normal file
|
@ -0,0 +1,177 @@
|
|||
import { debounce } from '../getgov/helpers.js';
|
||||
import { getParameterByName } from './helpers-admin.js';
|
||||
|
||||
/** This function also sets the start and end dates to match the url params if they exist
|
||||
*/
|
||||
function initAnalyticsExportButtons() {
|
||||
// Get the current date in the format YYYY-MM-DD
|
||||
let currentDate = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Default the value of the start date input field to the current date
|
||||
let startDateInput = document.getElementById('start');
|
||||
|
||||
// Default the value of the end date input field to the current date
|
||||
let endDateInput = document.getElementById('end');
|
||||
|
||||
let exportButtons = document.querySelectorAll('.exportLink');
|
||||
|
||||
if (exportButtons.length > 0) {
|
||||
// Check if start and end dates are present in the URL
|
||||
let urlStartDate = getParameterByName('start_date');
|
||||
let urlEndDate = getParameterByName('end_date');
|
||||
|
||||
// Set input values based on URL parameters or current date
|
||||
startDateInput.value = urlStartDate || currentDate;
|
||||
endDateInput.value = urlEndDate || currentDate;
|
||||
|
||||
exportButtons.forEach((btn) => {
|
||||
btn.addEventListener('click', function () {
|
||||
// Get the selected start and end dates
|
||||
let startDate = startDateInput.value;
|
||||
let endDate = endDateInput.value;
|
||||
let exportUrl = btn.dataset.exportUrl;
|
||||
|
||||
// Build the URL with parameters
|
||||
exportUrl += "?start_date=" + startDate + "&end_date=" + endDate;
|
||||
|
||||
// Redirect to the export URL
|
||||
window.location.href = exportUrl;
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a diagonal stripe pattern for chart.js
|
||||
* Inspired by https://stackoverflow.com/questions/28569667/fill-chart-js-bar-chart-with-diagonal-stripes-or-other-patterns
|
||||
* and https://github.com/ashiguruma/patternomaly
|
||||
* @param {string} backgroundColor - Background color of the pattern
|
||||
* @param {string} [lineColor="white"] - Color of the diagonal lines
|
||||
* @param {boolean} [rightToLeft=false] - Direction of the diagonal lines
|
||||
* @param {number} [lineGap=1] - Gap between lines
|
||||
* @returns {CanvasPattern} A canvas pattern object for use with backgroundColor
|
||||
*/
|
||||
function createDiagonalPattern(backgroundColor, lineColor, rightToLeft=false, lineGap=1) {
|
||||
// Define the canvas and the 2d context so we can draw on it
|
||||
let shape = document.createElement("canvas");
|
||||
shape.width = 20;
|
||||
shape.height = 20;
|
||||
let context = shape.getContext("2d");
|
||||
|
||||
// Fill with specified background color
|
||||
context.fillStyle = backgroundColor;
|
||||
context.fillRect(0, 0, shape.width, shape.height);
|
||||
|
||||
// Set stroke properties
|
||||
context.strokeStyle = lineColor;
|
||||
context.lineWidth = 2;
|
||||
|
||||
// Rotate canvas for a right-to-left pattern
|
||||
if (rightToLeft) {
|
||||
context.translate(shape.width, 0);
|
||||
context.rotate(90 * Math.PI / 180);
|
||||
};
|
||||
|
||||
// First diagonal line
|
||||
let halfSize = shape.width / 2;
|
||||
context.moveTo(halfSize - lineGap, -lineGap);
|
||||
context.lineTo(shape.width + lineGap, halfSize + lineGap);
|
||||
|
||||
// Second diagonal line (x,y are swapped)
|
||||
context.moveTo(-lineGap, halfSize - lineGap);
|
||||
context.lineTo(halfSize + lineGap, shape.width + lineGap);
|
||||
|
||||
context.stroke();
|
||||
return context.createPattern(shape, "repeat");
|
||||
}
|
||||
|
||||
function createComparativeColumnChart(id, title, labelOne, labelTwo) {
|
||||
var canvas = document.getElementById(id);
|
||||
if (!canvas) {
|
||||
return
|
||||
}
|
||||
|
||||
var ctx = canvas.getContext("2d");
|
||||
var listOne = JSON.parse(canvas.getAttribute('data-list-one'));
|
||||
var listTwo = JSON.parse(canvas.getAttribute('data-list-two'));
|
||||
|
||||
var data = {
|
||||
labels: ["Total", "Federal", "Interstate", "State/Territory", "Tribal", "County", "City", "Special District", "School District", "Election Board"],
|
||||
datasets: [
|
||||
{
|
||||
label: labelOne,
|
||||
backgroundColor: "rgba(255, 99, 132, 0.3)",
|
||||
borderColor: "rgba(255, 99, 132, 1)",
|
||||
borderWidth: 1,
|
||||
data: listOne,
|
||||
// Set this line style to be rightToLeft for visual distinction
|
||||
backgroundColor: createDiagonalPattern('rgba(255, 99, 132, 0.3)', 'white', true)
|
||||
},
|
||||
{
|
||||
label: labelTwo,
|
||||
backgroundColor: "rgba(75, 192, 192, 0.3)",
|
||||
borderColor: "rgba(75, 192, 192, 1)",
|
||||
borderWidth: 1,
|
||||
data: listTwo,
|
||||
backgroundColor: createDiagonalPattern('rgba(75, 192, 192, 0.3)', 'white')
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: title
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
return new Chart(ctx, {
|
||||
type: "bar",
|
||||
data: data,
|
||||
options: options,
|
||||
});
|
||||
}
|
||||
|
||||
/** An IIFE to initialize the analytics page
|
||||
*/
|
||||
export function initAnalyticsDashboard() {
|
||||
const analyticsPageContainer = document.querySelector('.analytics-dashboard-charts');
|
||||
if (analyticsPageContainer) {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
initAnalyticsExportButtons();
|
||||
|
||||
// Create charts and store each instance of it
|
||||
const chartInstances = new Map();
|
||||
const charts = [
|
||||
{ id: "managed-domains-chart", title: "Managed domains" },
|
||||
{ id: "unmanaged-domains-chart", title: "Unmanaged domains" },
|
||||
{ id: "deleted-domains-chart", title: "Deleted domains" },
|
||||
{ id: "ready-domains-chart", title: "Ready domains" },
|
||||
{ id: "submitted-requests-chart", title: "Submitted requests" },
|
||||
{ id: "all-requests-chart", title: "All requests" }
|
||||
];
|
||||
charts.forEach(chart => {
|
||||
if (chartInstances.has(chart.id)) chartInstances.get(chart.id).destroy();
|
||||
chartInstances.set(chart.id, createComparativeColumnChart(chart.id, chart.title, "Start Date", "End Date"));
|
||||
});
|
||||
|
||||
// Add resize listener to each chart
|
||||
window.addEventListener("resize", debounce(() => {
|
||||
chartInstances.forEach((chart) => {
|
||||
if (chart?.canvas) chart.resize();
|
||||
});
|
||||
}, 200));
|
||||
});
|
||||
}
|
||||
};
|
|
@ -22,3 +22,13 @@ export function addOrRemoveSessionBoolean(name, add){
|
|||
sessionStorage.removeItem(name);
|
||||
}
|
||||
}
|
||||
|
||||
export function getParameterByName(name, url) {
|
||||
if (!url) url = window.location.href;
|
||||
name = name.replace(/[\[\]]/g, '\\$&');
|
||||
var regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)'),
|
||||
results = regex.exec(url);
|
||||
if (!results) return null;
|
||||
if (!results[2]) return '';
|
||||
return decodeURIComponent(results[2].replace(/\+/g, ' '));
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@ import { initDomainFormTargetBlankButtons } from './domain-form.js';
|
|||
import { initDynamicPortfolioFields } from './portfolio-form.js';
|
||||
import { initDynamicDomainInformationFields } from './domain-information-form.js';
|
||||
import { initDynamicDomainFields } from './domain-form.js';
|
||||
import { initAnalyticsDashboard } from './analytics.js';
|
||||
|
||||
// General
|
||||
initModals();
|
||||
|
@ -41,3 +42,6 @@ initDynamicPortfolioFields();
|
|||
|
||||
// Domain information
|
||||
initDynamicDomainInformationFields();
|
||||
|
||||
// Analytics dashboard
|
||||
initAnalyticsDashboard();
|
||||
|
|
|
@ -128,7 +128,7 @@ export function initAddNewMemberPageListeners() {
|
|||
});
|
||||
} else {
|
||||
// for admin users, the permissions are always the same
|
||||
appendPermissionInContainer('Domains', 'Viewer, all', permissionDetailsContainer);
|
||||
appendPermissionInContainer('Domains', 'Viewer', permissionDetailsContainer);
|
||||
appendPermissionInContainer('Domain requests', 'Creator', permissionDetailsContainer);
|
||||
appendPermissionInContainer('Members', 'Manager', permissionDetailsContainer);
|
||||
}
|
||||
|
|
|
@ -116,10 +116,10 @@ export class DomainRequestsTable extends BaseTable {
|
|||
<td data-label="Status">
|
||||
${request.status}
|
||||
</td>
|
||||
<td class="${ this.portfolioValue ? '' : "width-quarter"}">
|
||||
<div class="tablet:display-flex tablet:flex-row">
|
||||
<td class="width--action-column">
|
||||
<div class="tablet:display-flex tablet:flex-row flex-wrap">
|
||||
<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>
|
||||
</svg>
|
||||
${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>
|
||||
</td>
|
||||
${markupForSuborganizationRow}
|
||||
<td class="${ this.portfolioValue ? '' : "width-quarter"}">
|
||||
<a href="${actionUrl}">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="/public/img/sprite.svg#${domain.svg_icon}"></use>
|
||||
</svg>
|
||||
${domain.action_label} <span class="usa-sr-only">${domain.name}</span>
|
||||
</a>
|
||||
<td class="width--action-column">
|
||||
<div class="tablet:display-flex tablet:flex-row flex-align-center margin-right-2">
|
||||
<a href="${actionUrl}">
|
||||
<svg class="usa-icon top-1px" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="/public/img/sprite.svg#${domain.svg_icon}"></use>
|
||||
</svg>
|
||||
${domain.action_label} <span class="usa-sr-only">${domain.name}</span>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
|
|
|
@ -48,18 +48,6 @@ export class MembersTable extends BaseTable {
|
|||
// Get whether the logged in user has edit members permission
|
||||
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 {
|
||||
'hasAdditionalActions': hasEditPermission,
|
||||
'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">
|
||||
${last_active.display_value}
|
||||
</td>
|
||||
<td headers="header-action row-header-${unique_id}">
|
||||
<a href="${member.action_url}">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="/public/img/sprite.svg#${member.svg_icon}"></use>
|
||||
</svg>
|
||||
${member.action_label} <span class="usa-sr-only">${member.name}</span>
|
||||
</a>
|
||||
<td headers="header-action row-header-${unique_id}" class="width--action-column">
|
||||
<div class="tablet:display-flex tablet:flex-row flex-align-center">
|
||||
<a href="${member.action_url}">
|
||||
<svg class="usa-icon top-1px" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="/public/img/sprite.svg#${member.svg_icon}"></use>
|
||||
</svg>
|
||||
${member.action_label} <span class="usa-sr-only">${member.name}</span>
|
||||
</a>
|
||||
<span class="padding-left-1">${customTableOptions.hasAdditionalActions ? kebabHTML : ''}</span>
|
||||
</div>
|
||||
</td>
|
||||
${customTableOptions.hasAdditionalActions ? '<td>'+kebabHTML+'</td>' : ''}
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
if (domainsHTML || permissionsHTML) {
|
||||
|
|
|
@ -498,6 +498,28 @@ input[type=submit].button--dja-toolbar:focus, input[type=submit].button--dja-too
|
|||
font-size: 13px;
|
||||
}
|
||||
|
||||
.object-tools li button {
|
||||
font-family: Source Sans Pro Web, Helvetica Neue, Helvetica, Roboto, Arial, sans-serif;
|
||||
text-transform: none !important;
|
||||
font-size: 14px !important;
|
||||
display: block;
|
||||
float: left;
|
||||
padding: 3px 12px;
|
||||
background: var(--object-tools-bg) !important;
|
||||
color: var(--object-tools-fg);
|
||||
font-weight: 400;
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-radius: 15px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
line-height: 20px;
|
||||
&:focus, &:hover{
|
||||
background: var(--object-tools-hover-bg) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.module--custom {
|
||||
a {
|
||||
font-size: 13px;
|
||||
|
@ -536,13 +558,18 @@ details.dja-detail-table {
|
|||
background-color: transparent;
|
||||
}
|
||||
|
||||
thead tr {
|
||||
background-color: var(--darkened-bg);
|
||||
}
|
||||
|
||||
td, th {
|
||||
padding-left: 12px;
|
||||
border: none
|
||||
border: none;
|
||||
background-color: var(--darkened-bg);
|
||||
color: var(--body-quiet-color);
|
||||
}
|
||||
|
||||
thead > tr > th {
|
||||
border-radius: 4px;
|
||||
border-top: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
@ -924,3 +951,34 @@ ul.add-list-reset {
|
|||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1080px) {
|
||||
.analytics-dashboard-charts {
|
||||
// Desktop layout - charts in top row, details in bottom row
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
// Equal columns each gets 1/2 of the space
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
grid-template-areas:
|
||||
"chart1 chart2"
|
||||
"details1 details2"
|
||||
"chart3 chart4"
|
||||
"details3 details4"
|
||||
"chart5 chart6"
|
||||
"details5 details6";
|
||||
|
||||
.chart-1 { grid-area: chart1; }
|
||||
.chart-2 { grid-area: chart2; }
|
||||
.chart-3 { grid-area: chart3; }
|
||||
.chart-4 { grid-area: chart4; }
|
||||
.chart-5 { grid-area: chart5; }
|
||||
.chart-6 { grid-area: chart6; }
|
||||
.details-1 { grid-area: details1; }
|
||||
.details-2 { grid-area: details2; }
|
||||
.details-3 { grid-area: details3; }
|
||||
.details-4 { grid-area: details4; }
|
||||
.details-5 { grid-area: details5; }
|
||||
.details-6 { grid-area: details6; }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
@use "uswds-core" as *;
|
||||
@use "cisa_colors" as *;
|
||||
|
||||
$widescreen-max-width: 1920px;
|
||||
$widescreen-max-width: 1536px;
|
||||
$widescreen-x-padding: 4.5rem;
|
||||
|
||||
$hot-pink: #FFC3F9;
|
||||
|
@ -46,12 +46,11 @@ body {
|
|||
background-color: color('gray-1');
|
||||
}
|
||||
|
||||
|
||||
.section-outlined {
|
||||
background-color: color('white');
|
||||
border: 1px solid color('base-lighter');
|
||||
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-0 {
|
||||
|
@ -275,3 +274,12 @@ abbr[title] {
|
|||
.width-quarter {
|
||||
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_is_production = env.bool("IS_PRODUCTION", default=False)
|
||||
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_getgov_public_site_url = env.str("GETGOV_PUBLIC_SITE_URL", "")
|
||||
env_oidc_active_provider = env.str("OIDC_ACTIVE_PROVIDER", "identity sandbox")
|
||||
|
@ -106,6 +107,7 @@ DEBUG = env_debug
|
|||
# Controls production specific feature toggles
|
||||
IS_PRODUCTION = env_is_production
|
||||
SECRET_ENCRYPT_METADATA = secret_encrypt_metadata
|
||||
BASE_URL = env_base_url
|
||||
|
||||
# Applications are modular pieces of code.
|
||||
# They are provided by Django, by third-parties, or by yourself.
|
||||
|
@ -492,12 +494,18 @@ class JsonServerFormatter(ServerFormatter):
|
|||
return json.dumps(log_entry)
|
||||
|
||||
|
||||
# default to json formatted logs
|
||||
server_formatter, console_formatter = "json.server", "json"
|
||||
|
||||
# don't use json format locally, it makes logs hard to read in console
|
||||
# If we're running locally we don't want json formatting
|
||||
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 = {
|
||||
"version": 1,
|
||||
|
@ -531,29 +539,52 @@ LOGGING = {
|
|||
"console": {
|
||||
"level": env_log_level,
|
||||
"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": {
|
||||
"level": "INFO",
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": server_formatter,
|
||||
"formatter": "django.server",
|
||||
},
|
||||
"json": {
|
||||
"level": env_log_level,
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "json",
|
||||
},
|
||||
# No file logger is configured,
|
||||
# because containerized apps
|
||||
# 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
|
||||
# messages are sent for processing
|
||||
"loggers": {
|
||||
# Django's generic logger
|
||||
"django": {
|
||||
"handlers": ["console"],
|
||||
"handlers": django_handlers,
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
# Django's template processor
|
||||
"django.template": {
|
||||
"handlers": ["console"],
|
||||
"handlers": django_handlers,
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
|
@ -571,19 +602,19 @@ LOGGING = {
|
|||
},
|
||||
# OpenID Connect logger
|
||||
"oic": {
|
||||
"handlers": ["console"],
|
||||
"handlers": django_handlers,
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
# Django wrapper for OpenID Connect
|
||||
"djangooidc": {
|
||||
"handlers": ["console"],
|
||||
"handlers": django_handlers,
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
# Our app!
|
||||
"registrar": {
|
||||
"handlers": ["console"],
|
||||
"handlers": django_handlers,
|
||||
"level": "DEBUG",
|
||||
"propagate": False,
|
||||
},
|
||||
|
@ -591,7 +622,7 @@ LOGGING = {
|
|||
# root logger catches anything, unless
|
||||
# defined by a more specific logger
|
||||
"root": {
|
||||
"handlers": ["console"],
|
||||
"handlers": django_handlers,
|
||||
"level": "INFO",
|
||||
},
|
||||
}
|
||||
|
|
|
@ -68,19 +68,9 @@ def portfolio_permissions(request):
|
|||
"has_organization_requests_flag": False,
|
||||
"has_organization_members_flag": False,
|
||||
"is_portfolio_admin": False,
|
||||
"has_domain_renewal_flag": False,
|
||||
}
|
||||
try:
|
||||
portfolio = request.session.get("portfolio")
|
||||
|
||||
# These feature flags will display and doesn't depend on portfolio
|
||||
portfolio_context.update(
|
||||
{
|
||||
"has_organization_feature_flag": True,
|
||||
"has_domain_renewal_flag": request.user.has_domain_renewal_flag(),
|
||||
}
|
||||
)
|
||||
|
||||
if portfolio:
|
||||
return {
|
||||
"has_view_portfolio_permission": request.user.has_view_portfolio_permission(portfolio),
|
||||
|
@ -95,7 +85,6 @@ def portfolio_permissions(request):
|
|||
"has_organization_requests_flag": request.user.has_organization_requests_flag(),
|
||||
"has_organization_members_flag": request.user.has_organization_members_flag(),
|
||||
"is_portfolio_admin": request.user.is_portfolio_admin(portfolio),
|
||||
"has_domain_renewal_flag": request.user.has_domain_renewal_flag(),
|
||||
}
|
||||
return portfolio_context
|
||||
|
||||
|
|
|
@ -171,6 +171,13 @@ class UserFixture:
|
|||
"email": "gina.summers@ecstech.com",
|
||||
"title": "Scrum Master",
|
||||
},
|
||||
{
|
||||
"username": "89f2db87-87a2-4778-a5ea-5b27b585b131",
|
||||
"first_name": "Jaxon",
|
||||
"last_name": "Silva",
|
||||
"email": "jaxon.silva@cisa.dhs.gov",
|
||||
"title": "Designer",
|
||||
},
|
||||
]
|
||||
|
||||
STAFF = [
|
||||
|
|
|
@ -127,7 +127,7 @@ class BasePortfolioMemberForm(forms.ModelForm):
|
|||
domain_permissions = forms.ChoiceField(
|
||||
choices=[
|
||||
(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value, "Viewer, limited"),
|
||||
(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value, "Viewer, all"),
|
||||
(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value, "Viewer"),
|
||||
],
|
||||
widget=forms.RadioSelect,
|
||||
required=False,
|
||||
|
@ -338,6 +338,24 @@ class BasePortfolioMemberForm(forms.ModelForm):
|
|||
and UserPortfolioRoleChoices.ORGANIZATION_ADMIN not in new_roles
|
||||
)
|
||||
|
||||
def is_change(self) -> bool:
|
||||
"""
|
||||
Determines if the form has changed by comparing the initial data
|
||||
with the submitted cleaned data.
|
||||
|
||||
Returns:
|
||||
bool: True if the form has changed, False otherwise.
|
||||
"""
|
||||
# Compare role values
|
||||
previous_roles = set(self.initial.get("roles", []))
|
||||
new_roles = set(self.cleaned_data.get("roles", []))
|
||||
|
||||
# Compare additional permissions values
|
||||
previous_permissions = set(self.initial.get("additional_permissions") or [])
|
||||
new_permissions = set(self.cleaned_data.get("additional_permissions") or [])
|
||||
|
||||
return previous_roles != new_roles or previous_permissions != new_permissions
|
||||
|
||||
|
||||
class PortfolioMemberForm(BasePortfolioMemberForm):
|
||||
"""
|
||||
|
|
|
@ -5,9 +5,16 @@ import logging
|
|||
from django.core.management import BaseCommand, CommandError
|
||||
from registrar.management.commands.utility.terminal_helper import TerminalColors, TerminalHelper
|
||||
from registrar.models import DomainInformation, DomainRequest, FederalAgency, Suborganization, Portfolio, User
|
||||
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 django.db.models import F, Q
|
||||
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -21,6 +28,10 @@ class Command(BaseCommand):
|
|||
self.updated_portfolios = set()
|
||||
self.skipped_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):
|
||||
"""Add command line arguments to create federal portfolios.
|
||||
|
@ -38,6 +49,9 @@ class Command(BaseCommand):
|
|||
Optional (mutually exclusive with parse options):
|
||||
--both: Shorthand for using both --parse_requests and --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.add_argument(
|
||||
|
@ -64,23 +78,31 @@ class Command(BaseCommand):
|
|||
action=argparse.BooleanOptionalAction,
|
||||
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(
|
||||
"--skip_existing_portfolios",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
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")
|
||||
branch = options.get("branch")
|
||||
parse_requests = options.get("parse_requests")
|
||||
parse_domains = options.get("parse_domains")
|
||||
both = options.get("both")
|
||||
add_managers = options.get("add_managers")
|
||||
skip_existing_portfolios = options.get("skip_existing_portfolios")
|
||||
|
||||
if not both:
|
||||
if not parse_requests and not parse_domains:
|
||||
raise CommandError("You must specify at least one of --parse_requests or --parse_domains.")
|
||||
if not (parse_requests or parse_domains or add_managers):
|
||||
raise CommandError(
|
||||
"You must specify at least one of --parse_requests, --parse_domains, or --add_managers."
|
||||
)
|
||||
else:
|
||||
if parse_requests or parse_domains:
|
||||
raise CommandError("You cannot pass --parse_requests or --parse_domains when passing --both.")
|
||||
|
@ -96,7 +118,6 @@ class Command(BaseCommand):
|
|||
)
|
||||
else:
|
||||
raise CommandError(f"Cannot find '{branch}' federal agencies in our database.")
|
||||
|
||||
portfolios = []
|
||||
for federal_agency in agencies:
|
||||
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
|
||||
)
|
||||
portfolios.append(portfolio)
|
||||
if add_managers:
|
||||
self.add_managers_to_portfolio(portfolio)
|
||||
except Exception as exec:
|
||||
self.failed_portfolios.add(federal_agency)
|
||||
logger.error(exec)
|
||||
|
@ -127,6 +150,26 @@ class Command(BaseCommand):
|
|||
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.
|
||||
# We only do this for started domain requests.
|
||||
if parse_requests or both:
|
||||
|
@ -147,6 +190,73 @@ class Command(BaseCommand):
|
|||
)
|
||||
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):
|
||||
"""
|
||||
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.
|
||||
# 3. The domain request is in status "started".
|
||||
# Note: Both names are normalized so excess spaces are stripped and the string is lowercased.
|
||||
|
||||
domain_requests_to_update = DomainRequest.objects.filter(
|
||||
federal_agency__in=agencies,
|
||||
federal_agency__agency__isnull=False,
|
||||
|
|
|
@ -9,6 +9,7 @@ from django_fsm import FSMField, transition, TransitionNotAllowed # type: ignor
|
|||
from django.db import models, IntegrityError
|
||||
from django.utils import timezone
|
||||
from typing import Any
|
||||
from registrar.models.domain_invitation import DomainInvitation
|
||||
from registrar.models.host import Host
|
||||
from registrar.models.host_ip import HostIP
|
||||
from registrar.utility.enums import DefaultEmail
|
||||
|
@ -40,7 +41,6 @@ from .utility.time_stamped_model import TimeStampedModel
|
|||
from .public_contact import PublicContact
|
||||
|
||||
from .user_domain_role import UserDomainRole
|
||||
from waffle.decorators import flag_is_active
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -1171,12 +1171,16 @@ class Domain(TimeStampedModel, DomainHelper):
|
|||
"""Return the display status of the domain."""
|
||||
if self.is_expired() and (self.state != self.State.UNKNOWN):
|
||||
return "Expired"
|
||||
elif flag_is_active(request, "domain_renewal") and self.is_expiring():
|
||||
elif self.is_expiring():
|
||||
return "Expiring soon"
|
||||
elif self.state == self.State.UNKNOWN or self.state == self.State.DNS_NEEDED:
|
||||
return "DNS needed"
|
||||
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):
|
||||
"""Maps the Epp contact representation to a PublicContact object.
|
||||
|
||||
|
@ -1583,7 +1587,7 @@ class Domain(TimeStampedModel, DomainHelper):
|
|||
# Given expired is not a physical state, but it is displayed as such,
|
||||
# We need custom logic to determine this message.
|
||||
help_text = "This domain has expired. Complete the online renewal process to maintain access."
|
||||
elif flag_is_active(request, "domain_renewal") and self.is_expiring():
|
||||
elif self.is_expiring():
|
||||
help_text = "This domain is expiring soon. Complete the online renewal process to maintain access."
|
||||
else:
|
||||
help_text = Domain.State.get_help_text(self.state)
|
||||
|
|
|
@ -9,6 +9,10 @@ from .utility.portfolio_helper import (
|
|||
UserPortfolioPermissionChoices,
|
||||
UserPortfolioRoleChoices,
|
||||
cleanup_after_portfolio_member_deletion,
|
||||
get_domain_requests_display,
|
||||
get_domains_display,
|
||||
get_members_display,
|
||||
get_role_display,
|
||||
validate_portfolio_invitation,
|
||||
) # type: ignore
|
||||
from .utility.time_stamped_model import TimeStampedModel
|
||||
|
@ -85,6 +89,60 @@ class PortfolioInvitation(TimeStampedModel):
|
|||
"""
|
||||
return UserPortfolioPermission.get_portfolio_permissions(self.roles, self.additional_permissions)
|
||||
|
||||
@property
|
||||
def role_display(self):
|
||||
"""
|
||||
Returns a human-readable display name for the user's role.
|
||||
|
||||
Uses the `get_role_display` function to determine if the user is an "Admin",
|
||||
"Basic" member, or has no role assigned.
|
||||
|
||||
Returns:
|
||||
str: The display name of the user's role.
|
||||
"""
|
||||
return get_role_display(self.roles)
|
||||
|
||||
@property
|
||||
def domains_display(self):
|
||||
"""
|
||||
Returns a string representation of the user's domain access level.
|
||||
|
||||
Uses the `get_domains_display` function to determine whether the user has
|
||||
"Viewer" access (can view all domains) or "Viewer, limited" access.
|
||||
|
||||
Returns:
|
||||
str: The display name of the user's domain permissions.
|
||||
"""
|
||||
return get_domains_display(self.roles, self.additional_permissions)
|
||||
|
||||
@property
|
||||
def domain_requests_display(self):
|
||||
"""
|
||||
Returns a string representation of the user's access to domain requests.
|
||||
|
||||
Uses the `get_domain_requests_display` function to determine if the user
|
||||
is a "Creator" (can create and edit requests), a "Viewer" (can only view requests),
|
||||
or has "No access" to domain requests.
|
||||
|
||||
Returns:
|
||||
str: The display name of the user's domain request permissions.
|
||||
"""
|
||||
return get_domain_requests_display(self.roles, self.additional_permissions)
|
||||
|
||||
@property
|
||||
def members_display(self):
|
||||
"""
|
||||
Returns a string representation of the user's access to managing members.
|
||||
|
||||
Uses the `get_members_display` function to determine if the user is a
|
||||
"Manager" (can edit members), a "Viewer" (can view members), or has "No access"
|
||||
to member management.
|
||||
|
||||
Returns:
|
||||
str: The display name of the user's member management permissions.
|
||||
"""
|
||||
return get_members_display(self.roles, self.additional_permissions)
|
||||
|
||||
@transition(field="status", source=PortfolioInvitationStatus.INVITED, target=PortfolioInvitationStatus.RETRIEVED)
|
||||
def retrieve(self):
|
||||
"""When an invitation is retrieved, create the corresponding permission.
|
||||
|
|
|
@ -269,10 +269,7 @@ class User(AbstractUser):
|
|||
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_REQUESTS)
|
||||
|
||||
def is_portfolio_admin(self, portfolio):
|
||||
return "Admin" in self.portfolio_role_summary(portfolio)
|
||||
|
||||
def has_domain_renewal_flag(self):
|
||||
return flag_is_active_for_user(self, "domain_renewal")
|
||||
return self.has_edit_portfolio_permission(portfolio)
|
||||
|
||||
def get_first_portfolio(self):
|
||||
permission = self.portfolio_permissions.first()
|
||||
|
@ -280,49 +277,6 @@ class User(AbstractUser):
|
|||
return permission.portfolio
|
||||
return None
|
||||
|
||||
def portfolio_role_summary(self, portfolio):
|
||||
"""Returns a list of roles based on the user's permissions."""
|
||||
roles = []
|
||||
|
||||
# Define the conditions and their corresponding roles
|
||||
conditions_roles = [
|
||||
(self.has_edit_portfolio_permission(portfolio), ["Admin"]),
|
||||
(
|
||||
self.has_view_all_domains_portfolio_permission(portfolio)
|
||||
and self.has_any_requests_portfolio_permission(portfolio)
|
||||
and self.has_edit_request_portfolio_permission(portfolio),
|
||||
["View-only admin", "Domain requestor"],
|
||||
),
|
||||
(
|
||||
self.has_view_all_domains_portfolio_permission(portfolio)
|
||||
and self.has_any_requests_portfolio_permission(portfolio),
|
||||
["View-only admin"],
|
||||
),
|
||||
(
|
||||
self.has_view_portfolio_permission(portfolio)
|
||||
and self.has_edit_request_portfolio_permission(portfolio)
|
||||
and self.has_any_domains_portfolio_permission(portfolio),
|
||||
["Domain requestor", "Domain manager"],
|
||||
),
|
||||
(
|
||||
self.has_view_portfolio_permission(portfolio) and self.has_edit_request_portfolio_permission(portfolio),
|
||||
["Domain requestor"],
|
||||
),
|
||||
(
|
||||
self.has_view_portfolio_permission(portfolio) and self.has_any_domains_portfolio_permission(portfolio),
|
||||
["Domain manager"],
|
||||
),
|
||||
(self.has_view_portfolio_permission(portfolio), ["Member"]),
|
||||
]
|
||||
|
||||
# Evaluate conditions and add roles
|
||||
for condition, role_list in conditions_roles:
|
||||
if condition:
|
||||
roles.extend(role_list)
|
||||
break
|
||||
|
||||
return roles
|
||||
|
||||
def get_portfolios(self):
|
||||
return self.portfolio_permissions.all()
|
||||
|
||||
|
|
|
@ -6,6 +6,10 @@ from registrar.models.utility.portfolio_helper import (
|
|||
DomainRequestPermissionDisplay,
|
||||
MemberPermissionDisplay,
|
||||
cleanup_after_portfolio_member_deletion,
|
||||
get_domain_requests_display,
|
||||
get_domains_display,
|
||||
get_members_display,
|
||||
get_role_display,
|
||||
validate_user_portfolio_permission,
|
||||
)
|
||||
from .utility.time_stamped_model import TimeStampedModel
|
||||
|
@ -181,6 +185,60 @@ class UserPortfolioPermission(TimeStampedModel):
|
|||
# This is the same as portfolio_permissions & common_forbidden_perms.
|
||||
return portfolio_permissions.intersection(common_forbidden_perms)
|
||||
|
||||
@property
|
||||
def role_display(self):
|
||||
"""
|
||||
Returns a human-readable display name for the user's role.
|
||||
|
||||
Uses the `get_role_display` function to determine if the user is an "Admin",
|
||||
"Basic" member, or has no role assigned.
|
||||
|
||||
Returns:
|
||||
str: The display name of the user's role.
|
||||
"""
|
||||
return get_role_display(self.roles)
|
||||
|
||||
@property
|
||||
def domains_display(self):
|
||||
"""
|
||||
Returns a string representation of the user's domain access level.
|
||||
|
||||
Uses the `get_domains_display` function to determine whether the user has
|
||||
"Viewer" access (can view all domains) or "Viewer, limited" access.
|
||||
|
||||
Returns:
|
||||
str: The display name of the user's domain permissions.
|
||||
"""
|
||||
return get_domains_display(self.roles, self.additional_permissions)
|
||||
|
||||
@property
|
||||
def domain_requests_display(self):
|
||||
"""
|
||||
Returns a string representation of the user's access to domain requests.
|
||||
|
||||
Uses the `get_domain_requests_display` function to determine if the user
|
||||
is a "Creator" (can create and edit requests), a "Viewer" (can only view requests),
|
||||
or has "No access" to domain requests.
|
||||
|
||||
Returns:
|
||||
str: The display name of the user's domain request permissions.
|
||||
"""
|
||||
return get_domain_requests_display(self.roles, self.additional_permissions)
|
||||
|
||||
@property
|
||||
def members_display(self):
|
||||
"""
|
||||
Returns a string representation of the user's access to managing members.
|
||||
|
||||
Uses the `get_members_display` function to determine if the user is a
|
||||
"Manager" (can edit members), a "Viewer" (can view members), or has "No access"
|
||||
to member management.
|
||||
|
||||
Returns:
|
||||
str: The display name of the user's member management permissions.
|
||||
"""
|
||||
return get_members_display(self.roles, self.additional_permissions)
|
||||
|
||||
def clean(self):
|
||||
"""Extends clean method to perform additional validation, which can raise errors in django admin."""
|
||||
super().clean()
|
||||
|
|
|
@ -79,6 +79,100 @@ class MemberPermissionDisplay(StrEnum):
|
|||
NONE = "None"
|
||||
|
||||
|
||||
def get_role_display(roles):
|
||||
"""
|
||||
Returns a user-friendly display name for a given list of user roles.
|
||||
|
||||
- If the user has the ORGANIZATION_ADMIN role, return "Admin".
|
||||
- If the user has the ORGANIZATION_MEMBER role, return "Basic".
|
||||
- If the user has neither role, return "-".
|
||||
|
||||
Args:
|
||||
roles (list): A list of role strings assigned to the user.
|
||||
|
||||
Returns:
|
||||
str: The display name for the highest applicable role.
|
||||
"""
|
||||
if UserPortfolioRoleChoices.ORGANIZATION_ADMIN in roles:
|
||||
return "Admin"
|
||||
elif UserPortfolioRoleChoices.ORGANIZATION_MEMBER in roles:
|
||||
return "Basic"
|
||||
else:
|
||||
return "-"
|
||||
|
||||
|
||||
def get_domains_display(roles, permissions):
|
||||
"""
|
||||
Determines the display name for a user's domain viewing permissions.
|
||||
|
||||
- If the user has the VIEW_ALL_DOMAINS permission, return "Viewer".
|
||||
- Otherwise, return "Viewer, limited".
|
||||
|
||||
Args:
|
||||
roles (list): A list of role strings assigned to the user.
|
||||
permissions (list): A list of additional permissions assigned to the user.
|
||||
|
||||
Returns:
|
||||
str: A string representing the user's domain viewing access.
|
||||
"""
|
||||
UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
|
||||
all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions)
|
||||
if UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS in all_permissions:
|
||||
return "Viewer"
|
||||
else:
|
||||
return "Viewer, limited"
|
||||
|
||||
|
||||
def get_domain_requests_display(roles, permissions):
|
||||
"""
|
||||
Determines the display name for a user's domain request permissions.
|
||||
|
||||
- If the user has the EDIT_REQUESTS permission, return "Creator".
|
||||
- If the user has the VIEW_ALL_REQUESTS permission, return "Viewer".
|
||||
- Otherwise, return "No access".
|
||||
|
||||
Args:
|
||||
roles (list): A list of role strings assigned to the user.
|
||||
permissions (list): A list of additional permissions assigned to the user.
|
||||
|
||||
Returns:
|
||||
str: A string representing the user's domain request access level.
|
||||
"""
|
||||
UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
|
||||
all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions)
|
||||
if UserPortfolioPermissionChoices.EDIT_REQUESTS in all_permissions:
|
||||
return "Creator"
|
||||
elif UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS in all_permissions:
|
||||
return "Viewer"
|
||||
else:
|
||||
return "No access"
|
||||
|
||||
|
||||
def get_members_display(roles, permissions):
|
||||
"""
|
||||
Determines the display name for a user's member management permissions.
|
||||
|
||||
- If the user has the EDIT_MEMBERS permission, return "Manager".
|
||||
- If the user has the VIEW_MEMBERS permission, return "Viewer".
|
||||
- Otherwise, return "No access".
|
||||
|
||||
Args:
|
||||
roles (list): A list of role strings assigned to the user.
|
||||
permissions (list): A list of additional permissions assigned to the user.
|
||||
|
||||
Returns:
|
||||
str: A string representing the user's member management access level.
|
||||
"""
|
||||
UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
|
||||
all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions)
|
||||
if UserPortfolioPermissionChoices.EDIT_MEMBERS in all_permissions:
|
||||
return "Manager"
|
||||
elif UserPortfolioPermissionChoices.VIEW_MEMBERS in all_permissions:
|
||||
return "Viewer"
|
||||
else:
|
||||
return "No access"
|
||||
|
||||
|
||||
def validate_user_portfolio_permission(user_portfolio_permission):
|
||||
"""
|
||||
Validates a UserPortfolioPermission instance. Located in portfolio_helper to avoid circular imports
|
||||
|
|
|
@ -1,11 +1,24 @@
|
|||
{% extends "admin/base_site.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
<div id="content-main" class="custom-admin-template">
|
||||
<div id="content-main" class="custom-admin-template analytics-dashboard">
|
||||
|
||||
<div class="grid-row grid-gap-2">
|
||||
<div class="tablet:grid-col-6 margin-top-2">
|
||||
|
@ -82,7 +95,7 @@
|
|||
<input type="date" id="end" name="end" value="2023-12-01" min="2023-12-01" />
|
||||
</div>
|
||||
</div>
|
||||
<ul class="usa-button-group">
|
||||
<ul class="usa-button-group flex-wrap">
|
||||
<li class="usa-button-group__item">
|
||||
<button class="usa-button usa-button--dja exportLink" data-export-url="{% url 'export_domains_growth' %}" type="button">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
|
@ -120,80 +133,127 @@
|
|||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="grid-row grid-gap-2 margin-y-2">
|
||||
<div class="grid-col">
|
||||
<canvas id="myChart1" width="400" height="200"
|
||||
aria-label="Chart: {{ data.managed_domains_sliced_at_end_date.0 }} managed domains for {{ data.end_date }}"
|
||||
role="img"
|
||||
data-list-one="{{data.managed_domains_sliced_at_start_date}}"
|
||||
data-list-two="{{data.managed_domains_sliced_at_end_date}}"
|
||||
>
|
||||
<h2>Chart: Managed domains</h2>
|
||||
<p>{{ data.managed_domains_sliced_at_end_date.0 }} managed domains for {{ data.end_date }}</p>
|
||||
</canvas>
|
||||
</div>
|
||||
<div class="grid-col">
|
||||
<canvas id="myChart2" width="400" height="200"
|
||||
aria-label="Chart: {{ data.unmanaged_domains_sliced_at_end_date.0 }} unmanaged domains for {{ data.end_date }}"
|
||||
role="img"
|
||||
data-list-one="{{data.unmanaged_domains_sliced_at_start_date}}"
|
||||
data-list-two="{{data.unmanaged_domains_sliced_at_end_date}}"
|
||||
>
|
||||
<h2>Chart: Unmanaged domains</h2>
|
||||
<p>{{ data.unmanaged_domains_sliced_at_end_date.0 }} unmanaged domains for {{ data.end_date }}</p>
|
||||
</canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="analytics-dashboard-charts margin-top-2">
|
||||
{% comment %} Managed/Unmanaged domains {% endcomment %}
|
||||
<div class="chart-1 grid-col">
|
||||
<canvas id="managed-domains-chart" width="400" height="200"
|
||||
aria-label="Chart: {{ data.managed_domains.end_date_count.0 }} managed domains for {{ data.end_date }}"
|
||||
role="img"
|
||||
data-list-one="{{ data.managed_domains.start_date_count }}"
|
||||
data-list-two="{{ data.managed_domains.end_date_count }}"
|
||||
>
|
||||
<h2>Chart: Managed domains</h2>
|
||||
<p>{{ data.managed_domains.end_date_count.0 }} managed domains for {{ data.end_date }}</p>
|
||||
</canvas>
|
||||
</div>
|
||||
<div class="details-1 grid-col margin-bottom-2">
|
||||
<details class="dja-detail-table" aria-role="button" closed>
|
||||
<summary class="dja-details-summary">Details for managed domains</summary>
|
||||
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
|
||||
{% include "admin/analytics_graph_table.html" with data=data property_name="managed_domains" %}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="chart-2 grid-col">
|
||||
<canvas id="unmanaged-domains-chart" width="400" height="200"
|
||||
aria-label="Chart: {{ data.unmanaged_domains.end_date_count.0 }} unmanaged domains for {{ data.end_date }}"
|
||||
role="img"
|
||||
data-list-one="{{ data.unmanaged_domains.start_date_count }}"
|
||||
data-list-two="{{ data.unmanaged_domains.end_date_count }}"
|
||||
>
|
||||
<h2>Chart: Unmanaged domains</h2>
|
||||
<p>{{ data.unmanaged_domains.end_date_count.0 }} unmanaged domains for {{ data.end_date }}</p>
|
||||
</canvas>
|
||||
</div>
|
||||
<div class="details-2 grid-col margin-bottom-2">
|
||||
<details class="dja-detail-table" aria-role="button" closed>
|
||||
<summary class="dja-details-summary">Details for unmanaged domains</summary>
|
||||
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
|
||||
{% include "admin/analytics_graph_table.html" with data=data property_name="unmanaged_domains" %}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="grid-row grid-gap-2 margin-y-2">
|
||||
<div class="grid-col">
|
||||
<canvas id="myChart3" width="400" height="200"
|
||||
aria-label="Chart: {{ data.deleted_domains_sliced_at_end_date.0 }} deleted domains for {{ data.end_date }}"
|
||||
role="img"
|
||||
data-list-one="{{data.deleted_domains_sliced_at_start_date}}"
|
||||
data-list-two="{{data.deleted_domains_sliced_at_end_date}}"
|
||||
>
|
||||
<h2>Chart: Deleted domains</h2>
|
||||
<p>{{ data.deleted_domains_sliced_at_end_date.0 }} deleted domains for {{ data.end_date }}</p>
|
||||
</canvas>
|
||||
</div>
|
||||
<div class="grid-col">
|
||||
<canvas id="myChart4" width="400" height="200"
|
||||
aria-label="Chart: {{ data.ready_domains_sliced_at_end_date.0 }} ready domains for {{ data.end_date }}"
|
||||
role="img"
|
||||
data-list-one="{{data.ready_domains_sliced_at_start_date}}"
|
||||
data-list-two="{{data.ready_domains_sliced_at_end_date}}"
|
||||
>
|
||||
<h2>Chart: Ready domains</h2>
|
||||
<p>{{ data.ready_domains_sliced_at_end_date.0 }} ready domains for {{ data.end_date }}</p>
|
||||
</canvas>
|
||||
</div>
|
||||
</div>
|
||||
{% comment %} Deleted/Ready domains {% endcomment %}
|
||||
<div class="chart-3 grid-col">
|
||||
<canvas id="deleted-domains-chart" width="400" height="200"
|
||||
aria-label="Chart: {{ data.deleted_domains.end_date_count.0 }} deleted domains for {{ data.end_date }}"
|
||||
role="img"
|
||||
data-list-one="{{ data.deleted_domains.start_date_count }}"
|
||||
data-list-two="{{ data.deleted_domains.end_date_count }}"
|
||||
>
|
||||
<h2>Chart: Deleted domains</h2>
|
||||
<p>{{ data.deleted_domains.end_date_count.0 }} deleted domains for {{ data.end_date }}</p>
|
||||
</canvas>
|
||||
</div>
|
||||
<div class="details-3 grid-col margin-bottom-2">
|
||||
<details class="dja-detail-table" aria-role="button" closed>
|
||||
<summary class="dja-details-summary">Details for deleted domains</summary>
|
||||
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
|
||||
{% include "admin/analytics_graph_table.html" with data=data property_name="deleted_domains" %}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="chart-4 grid-col">
|
||||
<canvas id="ready-domains-chart" width="400" height="200"
|
||||
aria-label="Chart: {{ data.ready_domains.end_date_count.0 }} ready domains for {{ data.end_date }}"
|
||||
role="img"
|
||||
data-list-one="{{ data.ready_domains.start_date_count }}"
|
||||
data-list-two="{{ data.ready_domains.end_date_count }}"
|
||||
>
|
||||
<h2>Chart: Ready domains</h2>
|
||||
<p>{{ data.ready_domains.end_date_count.0 }} ready domains for {{ data.end_date }}</p>
|
||||
</canvas>
|
||||
</div>
|
||||
<div class="details-4 grid-col margin-bottom-2">
|
||||
<details class="dja-detail-table" aria-role="button" closed>
|
||||
<summary class="dja-details-summary">Details for ready domains</summary>
|
||||
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
|
||||
{% include "admin/analytics_graph_table.html" with data=data property_name="ready_domains" %}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="grid-row grid-gap-2 margin-y-2">
|
||||
<div class="grid-col">
|
||||
<canvas id="myChart5" width="400" height="200"
|
||||
aria-label="Chart: {{ data.submitted_requests_sliced_at_end_date.0 }} submitted requests for {{ data.end_date }}"
|
||||
role="img"
|
||||
data-list-one="{{data.submitted_requests_sliced_at_start_date}}"
|
||||
data-list-two="{{data.submitted_requests_sliced_at_end_date}}"
|
||||
>
|
||||
<h2>Chart: Submitted requests</h2>
|
||||
<p>{{ data.submitted_requests_sliced_at_end_date.0 }} submitted requests for {{ data.end_date }}</p>
|
||||
</canvas>
|
||||
</div>
|
||||
<div class="grid-col">
|
||||
<canvas id="myChart6" width="400" height="200"
|
||||
aria-label="Chart: {{ data.requests_sliced_at_end_date.0 }} requests for {{ data.end_date }}"
|
||||
role="img"
|
||||
data-list-one="{{data.requests_sliced_at_start_date}}"
|
||||
data-list-two="{{data.requests_sliced_at_end_date}}"
|
||||
>
|
||||
<h2>Chart: All requests</h2>
|
||||
<p>{{ data.requests_sliced_at_end_date.0 }} requests for {{ data.end_date }}</p>
|
||||
</canvas>
|
||||
</div>
|
||||
</div>
|
||||
{% comment %} Requests {% endcomment %}
|
||||
<div class="chart-5 grid-col">
|
||||
<canvas id="submitted-requests-chart" width="400" height="200"
|
||||
aria-label="Chart: {{ data.submitted_requests.end_date_count.0 }} submitted requests for {{ data.end_date }}"
|
||||
role="img"
|
||||
data-list-one="{{ data.submitted_requests.start_date_count }}"
|
||||
data-list-two="{{ data.submitted_requests.end_date_count }}"
|
||||
>
|
||||
<h2>Chart: Submitted requests</h2>
|
||||
<p>{{ data.submitted_requests.end_date_count.0 }} submitted requests for {{ data.end_date }}</p>
|
||||
</canvas>
|
||||
</div>
|
||||
<div class="details-5 grid-col margin-bottom-2">
|
||||
<details class="dja-detail-table" aria-role="button" closed>
|
||||
<summary class="dja-details-summary">Details for submitted requests</summary>
|
||||
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
|
||||
{% include "admin/analytics_graph_table.html" with data=data property_name="submitted_requests" %}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="chart-6 grid-col">
|
||||
<canvas id="all-requests-chart" width="400" height="200"
|
||||
aria-label="Chart: {{ data.requests.end_date_count.0 }} requests for {{ data.end_date }}"
|
||||
role="img"
|
||||
data-list-one="{{ data.requests.start_date_count }}"
|
||||
data-list-two="{{ data.requests.end_date_count }}"
|
||||
>
|
||||
<h2>Chart: All requests</h2>
|
||||
<p>{{ data.requests.end_date_count.0 }} requests for {{ data.end_date }}</p>
|
||||
</canvas>
|
||||
</div>
|
||||
<div class="details-6 grid-col margin-bottom-2">
|
||||
<details class="dja-detail-table" aria-role="button" closed>
|
||||
<summary class="dja-details-summary">Details for all requests</summary>
|
||||
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
|
||||
{% include "admin/analytics_graph_table.html" with data=data property_name="requests" %}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
|
26
src/registrar/templates/admin/analytics_graph_table.html
Normal file
26
src/registrar/templates/admin/analytics_graph_table.html
Normal file
|
@ -0,0 +1,26 @@
|
|||
<table class="usa-table usa-table--borderless">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Type</th>
|
||||
<th scope="col">Start date {{ data.start_date }}</th>
|
||||
<th scope="col">End date {{ data.end_date }} </th>
|
||||
<tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% comment %}
|
||||
This ugly notation is equivalent to data.property_name.start_date_count.index.
|
||||
Or represented in the pure python way: data[property_name]["start_date_count"][index]
|
||||
{% endcomment %}
|
||||
{% with start_counts=data|get_item:property_name|get_item:"start_date_count" end_counts=data|get_item:property_name|get_item:"end_date_count" %}
|
||||
{% for org_count_type in data.org_count_types %}
|
||||
{% with index=forloop.counter %}
|
||||
<tr>
|
||||
<th class="padding-left-1" scope="row">{{ org_count_type }}</th>
|
||||
<td class="padding-left-1">{{ start_counts|slice:index|last }}</td>
|
||||
<td class="padding-left-1">{{ end_counts|slice:index|last }}</td>
|
||||
</tr>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
</tbody>
|
||||
</table>
|
|
@ -22,7 +22,6 @@
|
|||
<script src="{% static 'js/uswds.min.js' %}" defer></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script type="application/javascript" src="{% static 'js/getgov-admin.min.js' %}" defer></script>
|
||||
<script type="application/javascript" src="{% static 'js/get-gov-reports.js' %}" defer></script>
|
||||
<script type="application/javascript" src="{% static 'js/dja-collapse.js' %}" defer></script>
|
||||
{% endblock %}
|
||||
|
||||
|
|
|
@ -15,13 +15,28 @@
|
|||
</ul>
|
||||
{% else %}
|
||||
<ul>
|
||||
{% if opts.model_name == 'domaininvitation' %}
|
||||
{% if invitation.status == invitation.DomainInvitationStatus.INVITED %}
|
||||
<li>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="cancel_invitation" value="true">
|
||||
<button type="submit" class="usa-button--dja">
|
||||
Cancel invitation
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<li>
|
||||
<a href="{% add_preserved_filters history_url %}">{% translate "History" %}</a>
|
||||
</li>
|
||||
|
||||
{% if opts.model_name == 'domainrequest' %}
|
||||
<li>
|
||||
<a id="id-copy-to-clipboard-summary" class="usa-button--dja" type="button" href="#">
|
||||
<svg class="usa-icon" >
|
||||
<svg class="usa-icon">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
|
||||
</svg>
|
||||
<!-- the span is targeted in JS, do not remove -->
|
||||
|
@ -32,4 +47,3 @@
|
|||
</ul>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
|
|
|
@ -11,4 +11,4 @@
|
|||
</div>
|
||||
</div>
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
{% endblock %}
|
|
@ -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 %}
|
|
@ -10,7 +10,6 @@
|
|||
<th>Title</th>
|
||||
<th>Email</th>
|
||||
<th>Phone</th>
|
||||
<th>Roles</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
@ -28,11 +27,6 @@
|
|||
{% endif %}
|
||||
</td>
|
||||
<td>{{ member.user.phone }}</td>
|
||||
<td>
|
||||
{% for role in member.user|portfolio_role_summary:original %}
|
||||
<span class="usa-tag bg-primary-dark text-semibold">{{ role }}</span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td class="padding-left-1 text-size-small">
|
||||
{% if member.user.email %}
|
||||
<input aria-hidden="true" class="display-none" value="{{ member.user.email }}" />
|
||||
|
|
|
@ -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 %}
|
|
@ -35,7 +35,7 @@
|
|||
{# UNKNOWN domains would not have an expiration date and thus would show 'Expired' #}
|
||||
{% if domain.is_expired and domain.state != domain.State.UNKNOWN %}
|
||||
Expired
|
||||
{% elif has_domain_renewal_flag and domain.is_expiring %}
|
||||
{% elif domain.is_expiring %}
|
||||
Expiring soon
|
||||
{% elif domain.state == domain.State.UNKNOWN or domain.state == domain.State.DNS_NEEDED %}
|
||||
DNS needed
|
||||
|
@ -46,17 +46,17 @@
|
|||
|
||||
{% if domain.get_state_help_text %}
|
||||
<p class="margin-y-0 text-primary-darker">
|
||||
{% if has_domain_renewal_flag and domain.is_expired and is_domain_manager %}
|
||||
{% if domain.is_expired and is_domain_manager %}
|
||||
This domain has expired, but it is still online.
|
||||
{% url 'domain-renewal' pk=domain.id as url %}
|
||||
<a href="{{ url }}" class="usa-link">Renew to maintain access.</a>
|
||||
{% elif has_domain_renewal_flag and domain.is_expiring and is_domain_manager %}
|
||||
{% elif domain.is_expiring and is_domain_manager %}
|
||||
This domain will expire soon.
|
||||
{% url 'domain-renewal' pk=domain.id as url %}
|
||||
<a href="{{ url }}" class="usa-link">Renew to maintain access.</a>
|
||||
{% elif has_domain_renewal_flag and domain.is_expiring and is_portfolio_user %}
|
||||
{% elif domain.is_expiring and is_portfolio_user %}
|
||||
This domain will expire soon. Contact one of the listed domain managers to renew the domain.
|
||||
{% elif has_domain_renewal_flag and domain.is_expired and is_portfolio_user %}
|
||||
{% elif domain.is_expired and is_portfolio_user %}
|
||||
This domain has expired, but it is still online. Contact one of the listed domain managers to renew the domain.
|
||||
{% else %}
|
||||
{{ domain.get_state_help_text }}
|
||||
|
|
|
@ -81,7 +81,7 @@
|
|||
{% endwith %}
|
||||
|
||||
|
||||
{% if has_domain_renewal_flag and is_domain_manager%}
|
||||
{% if is_domain_manager%}
|
||||
{% if domain.is_expiring or domain.is_expired %}
|
||||
{% with url_name="domain-renewal" %}
|
||||
{% include "includes/domain_sidenav_item.html" with item_text="Renewal form" %}
|
||||
|
|
|
@ -17,7 +17,7 @@ Domains should uniquely identify a government organization and be clear to the g
|
|||
|
||||
|
||||
ACTION NEEDED
|
||||
First, we need you to identify a new domain name that meets our naming requirements for your type of organization. Then, log in to the registrar and update the name in your domain request. <https://manage.get.gov/> Once you submit your updated request, we’ll resume the adjudication process.
|
||||
First, we need you to identify a new domain name that meets our naming requirements for your type of organization. Then, log in to the registrar and update the name in your domain request. <{{ manage_url }}> Once you submit your updated request, we’ll resume the adjudication process.
|
||||
|
||||
If you have questions or want to discuss potential domain names, reply to this email.
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@ We expect a senior official to be someone in a role of significant, executive re
|
|||
ACTION NEEDED
|
||||
Reply to this email with a justification for naming {{ domain_request.senior_official.get_formatted_name }} as the senior official. If you have questions or comments, include those in your reply.
|
||||
|
||||
Alternatively, you can log in to the registrar and enter a different senior official for this domain request. <https://manage.get.gov/> Once you submit your updated request, we’ll resume the adjudication process.
|
||||
Alternatively, you can log in to the registrar and enter a different senior official for this domain request. <{{ manage_url }}> Once you submit your updated request, we’ll resume the adjudication process.
|
||||
|
||||
|
||||
THANK YOU
|
||||
|
|
|
@ -4,7 +4,7 @@ Hi,{% if requested_user and requested_user.first_name %} {{ requested_user.first
|
|||
{{ requestor_email }} has invited you to manage:
|
||||
{% for domain in domains %}{{ domain.name }}
|
||||
{% endfor %}
|
||||
To manage domain information, visit the .gov registrar <https://manage.get.gov>.
|
||||
To manage domain information, visit the .gov registrar <{{ manage_url }}>.
|
||||
|
||||
----------------------------------------------------------------
|
||||
{% if not requested_user %}
|
||||
|
|
|
@ -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 }}
|
|
@ -15,7 +15,7 @@ The person who received the invitation will become a domain manager once they lo
|
|||
associated with the invited email address.
|
||||
|
||||
If you need to cancel this invitation or remove the domain manager, you can do that by going to
|
||||
this domain in the .gov registrar <https://manage.get.gov/>.
|
||||
this domain in the .gov registrar <{{ manage_url }}>.
|
||||
|
||||
|
||||
WHY DID YOU RECEIVE THIS EMAIL?
|
||||
|
|
|
@ -11,7 +11,7 @@ STATUS: Withdrawn
|
|||
----------------------------------------------------------------
|
||||
|
||||
YOU CAN EDIT YOUR WITHDRAWN REQUEST
|
||||
You can edit and resubmit this request by signing in to the registrar <https://manage.get.gov/>.
|
||||
You can edit and resubmit this request by signing in to the registrar <{{ manage_url }}>.
|
||||
|
||||
|
||||
SOMETHING WRONG?
|
||||
|
|
|
@ -16,7 +16,7 @@ The person who received the invitation will become an admin once they log in to
|
|||
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/>.
|
||||
the Members section for your organization <{{ manage_url }}>.
|
||||
|
||||
|
||||
WHY DID YOU RECEIVE THIS EMAIL?
|
||||
|
|
|
@ -8,7 +8,7 @@ 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/>.
|
||||
You can view this update by going to the Members section for your .gov organization <{{ manage_url }}>.
|
||||
|
||||
----------------------------------------------------------------
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ Hi.
|
|||
|
||||
{{ requestor_email }} has invited you to {{ portfolio.organization_name }}.
|
||||
|
||||
You can view this organization on the .gov registrar <https://manage.get.gov>.
|
||||
You can view this organization on the .gov registrar <{{ manage_url }}>.
|
||||
|
||||
----------------------------------------------------------------
|
||||
|
||||
|
|
35
src/registrar/templates/emails/portfolio_update.txt
Normal file
35
src/registrar/templates/emails/portfolio_update.txt
Normal file
|
@ -0,0 +1,35 @@
|
|||
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
|
||||
Hi,{% if requested_user and requested_user.first_name %} {{ requested_user.first_name }}.{% endif %}
|
||||
|
||||
Your permissions were updated in the .gov registrar.
|
||||
|
||||
ORGANIZATION: {{ portfolio.organization_name }}
|
||||
UPDATED BY: {{ requestor_email }}
|
||||
UPDATED ON: {{ date }}
|
||||
YOUR PERMISSIONS: {{ permissions.role_display }}
|
||||
Domains - {{ permissions.domains_display }}
|
||||
Domain requests - {{ permissions.domain_requests_display }}
|
||||
Members - {{ permissions.members_display }}
|
||||
|
||||
Your updated permissions are now active in the .gov registrar <https://manage.get.gov>.
|
||||
|
||||
----------------------------------------------------------------
|
||||
|
||||
SOMETHING WRONG?
|
||||
If you have questions or concerns, reach out to the person who updated your
|
||||
permissions, 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 @@
|
|||
Your permissions were updated in the .gov registrar
|
|
@ -8,7 +8,7 @@ REQUESTED BY: {{ domain_request.creator.email }}
|
|||
REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
|
||||
STATUS: Approved
|
||||
|
||||
You can manage your approved domain on the .gov registrar <https://manage.get.gov>.
|
||||
You can manage your approved domain on the .gov registrar <{{ manage_url }}>.
|
||||
|
||||
----------------------------------------------------------------
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@ During our review, we’ll verify that:
|
|||
- You work at the organization and/or can make requests on its behalf
|
||||
- Your requested domain meets our naming requirements
|
||||
{% endif %}
|
||||
We’ll email you if we have questions. We’ll also email you as soon as we complete our review. You can check the status of your request at any time on the registrar. <https://manage.get.gov>.
|
||||
We’ll email you if we have questions. We’ll also email you as soon as we complete our review. You can check the status of your request at any time on the registrar. <{{ manage_url }}>.
|
||||
|
||||
|
||||
NEED TO MAKE CHANGES?
|
||||
|
|
|
@ -31,7 +31,7 @@ CHECK YOUR .GOV DOMAIN CONTACTS
|
|||
|
||||
This is a good time to check who has access to your .gov domain{% if domains|length > 1 %}s{% endif %}. The admin, technical, and billing contacts listed for your domain{% if domains|length > 1 %}s{% endif %} in our old system also received this email. In our new registrar, these contacts are all considered “domain managers.” We no longer have the admin, technical, and billing roles, and you aren’t limited to three domain managers like in the old system.
|
||||
|
||||
1. Once you have your Login.gov account, sign in to the new registrar at <https://manage.get.gov>.
|
||||
1. Once you have your Login.gov account, sign in to the new registrar at <{{ manage_url }}>.
|
||||
2. Click the “Manage” link next to your .gov domain, then click on “Domain managers” to see who has access to your domain.
|
||||
3. If any of these users should not have access to your domain, let us know in a reply to this email.
|
||||
|
||||
|
@ -57,7 +57,7 @@ THANK YOU
|
|||
The .gov team
|
||||
|
||||
.Gov blog <https://get.gov/updates/>
|
||||
Domain management <https://manage.get.gov>
|
||||
Domain management <{{ manage_url }}}>
|
||||
Get.gov <https://get.gov>
|
||||
|
||||
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <https://cisa.gov/>
|
||||
|
|
|
@ -8,7 +8,7 @@ UPDATED BY: {{user}}
|
|||
UPDATED ON: {{date}}
|
||||
INFORMATION UPDATED: {{changes}}
|
||||
|
||||
You can view this update in the .gov registrar <https://manage.get.gov/>.
|
||||
You can view this update in the .gov registrar <{{ manage_url }}>.
|
||||
|
||||
Get help with managing your .gov domain <https://get.gov/help/domain-management/>.
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
<span id="get_domains_json_url" class="display-none">{{url}}</span>
|
||||
|
||||
<!-- Org model banner (org manager can view, domain manager can edit) -->
|
||||
{% if has_domain_renewal_flag and num_expiring_domains > 0 and has_any_domains_portfolio_permission %}
|
||||
{% if num_expiring_domains > 0 and has_any_domains_portfolio_permission %}
|
||||
<section class="usa-site-alert--slim usa-site-alert--info margin-bottom-2 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
|
||||
<div class="usa-alert">
|
||||
<div class="usa-alert__body">
|
||||
|
@ -75,7 +75,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Non org model banner -->
|
||||
{% if has_domain_renewal_flag and num_expiring_domains > 0 and not portfolio %}
|
||||
{% if num_expiring_domains > 0 and not portfolio %}
|
||||
<section class="usa-site-alert--slim usa-site-alert--info margin-bottom-2 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
|
||||
<div class="usa-alert">
|
||||
<div class="usa-alert__body">
|
||||
|
@ -173,7 +173,6 @@
|
|||
>Deleted</label
|
||||
>
|
||||
</div>
|
||||
{% if has_domain_renewal_flag %}
|
||||
<div class="usa-checkbox">
|
||||
<input
|
||||
class="usa-checkbox__input"
|
||||
|
@ -185,7 +184,6 @@
|
|||
<label class="usa-checkbox__label" for="filter-status-expiring"
|
||||
>Expiring soon</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
{% load static %}
|
||||
|
||||
{% if member %}
|
||||
<span
|
||||
id="portfolio-js-value"
|
||||
|
@ -36,47 +34,9 @@
|
|||
|
||||
<div class="section-outlined__header margin-bottom-3 grid-row">
|
||||
<!-- ---------- SEARCH ---------- -->
|
||||
<div class="section-outlined__search mobile:grid-col-12 desktop:grid-col-9">
|
||||
<section aria-label="Member domains search component">
|
||||
<form class="usa-search usa-search--show-label" method="POST" role="search">
|
||||
{% csrf_token %}
|
||||
<label class="usa-label display-block margin-bottom-05" for="edit-member-domains__search-field">
|
||||
{% if has_edit_members_portfolio_permission %}
|
||||
Search all domains
|
||||
{% else %}
|
||||
Search domains assigned to
|
||||
{% if member %}
|
||||
{{ member.email }}
|
||||
{% else %}
|
||||
{{ portfolio_invitation.email }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</label>
|
||||
<div class="usa-search--show-label__input-wrapper">
|
||||
<button class="usa-button usa-button--unstyled margin-right-3 display-none" id="edit-member-domains__reset-search" type="button">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
|
||||
</svg>
|
||||
Reset
|
||||
</button>
|
||||
<input
|
||||
class="usa-input"
|
||||
id="edit-member-domains__search-field"
|
||||
type="search"
|
||||
name="member-domains-search"
|
||||
/>
|
||||
<button class="usa-button" type="submit" id="edit-member-domains__search-field-submit">
|
||||
<span class="usa-search__submit-text">Search </span>
|
||||
<img
|
||||
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
|
||||
class="usa-search__submit-icon"
|
||||
alt="Search"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
{% with label_text="Search all domains" item_name="edit-member-domains" aria_label_text="Member domains search component" %}
|
||||
{% include "includes/search.html" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
<!-- ---------- MAIN TABLE ---------- -->
|
||||
|
@ -85,7 +45,7 @@
|
|||
<caption class="sr-only">member domains</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sortable="checked" scope="col" role="columnheader" class="padding-right-105"><span class="sr-only">Assigned domains</span></th>
|
||||
<th data-sortable="checked" scope="col" role="columnheader" class="padding-right-105 width-6"><span class="sr-only">Assigned domains</span></th>
|
||||
<!-- We override default sort to be name/ascending in the JSON endpoint. We add the correct aria-sort attribute here to reflect that in the UI -->
|
||||
<th data-sortable="name" scope="col" role="columnheader" aria-sort="descending">Domains</th>
|
||||
</tr>
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
{% load static %}
|
||||
|
||||
{% if member %}
|
||||
<span
|
||||
id="portfolio-js-value"
|
||||
|
@ -34,45 +32,19 @@
|
|||
{% endif %}
|
||||
</h2>
|
||||
|
||||
<div class="section-outlined__header margin-bottom-3 grid-row" id="edit-member-domains__search">
|
||||
<div class="section-outlined__header margin-bottom-3 grid-row" id="edit-member-domains__search">
|
||||
<!-- ---------- SEARCH ---------- -->
|
||||
<div class="section-outlined__search mobile:grid-col-12 desktop:grid-col-9">
|
||||
<section aria-label="Member domains search component">
|
||||
<form class="usa-search usa-search--show-label" method="POST" role="search">
|
||||
{% csrf_token %}
|
||||
<label class="usa-label display-block margin-bottom-05" for="member-domains__search-field">
|
||||
Search domains assigned to
|
||||
{% if member %}
|
||||
{{ member.email }}
|
||||
{% else %}
|
||||
{{ portfolio_invitation.email }}
|
||||
{% endif %}
|
||||
</label>
|
||||
<div class="usa-search--show-label__input-wrapper">
|
||||
<button class="usa-button usa-button--unstyled margin-right-3 display-none" id="member-domains__reset-search" type="button">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
|
||||
</svg>
|
||||
Reset
|
||||
</button>
|
||||
<input
|
||||
class="usa-input"
|
||||
id="member-domains__search-field"
|
||||
type="search"
|
||||
name="member-domains-search"
|
||||
/>
|
||||
<button class="usa-button" type="submit" id="member-domains__search-field-submit">
|
||||
<span class="usa-search__submit-text">Search </span>
|
||||
<img
|
||||
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
|
||||
class="usa-search__submit-icon"
|
||||
alt="Search"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
{% with label_text="Domains assigned to " %}
|
||||
{% if member %}
|
||||
{% with label_text=label_text|add:member.email item_name="member-domains" aria_label_text="Member domains search component" %}
|
||||
{% include "includes/search.html" %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
{% with label_text=label_text|add:portfolio_invitation.email item_name="member-domains" aria_label_text="Member domains search component" %}
|
||||
{% include "includes/search.html" %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
<!-- ---------- MAIN TABLE ---------- -->
|
||||
|
|
|
@ -1,33 +1,11 @@
|
|||
<h4 class="margin-bottom-0">Member access</h4>
|
||||
{% if permissions.roles and 'organization_admin' in permissions.roles %}
|
||||
<p class="margin-top-0">Admin</p>
|
||||
{% elif permissions.roles and 'organization_member' in permissions.roles %}
|
||||
<p class="margin-top-0">Basic</p>
|
||||
{% else %}
|
||||
<p class="margin-top-0">⎯</p>
|
||||
{% endif %}
|
||||
<p class="margin-top-0">{{ permissions.role_display }}</p>
|
||||
|
||||
<h4 class="margin-bottom-0 text-primary">Domains</h4>
|
||||
{% if member_has_view_all_domains_portfolio_permission %}
|
||||
<p class="margin-top-0">Viewer, all</p>
|
||||
{% else %}
|
||||
<p class="margin-top-0">Viewer, limited</p>
|
||||
{% endif %}
|
||||
<p class="margin-top-0">{{ permissions.domains_display }}</p>
|
||||
|
||||
<h4 class="margin-bottom-0 text-primary">Domain requests</h4>
|
||||
{% if member_has_edit_request_portfolio_permission %}
|
||||
<p class="margin-top-0">Creator</p>
|
||||
{% elif member_has_view_all_requests_portfolio_permission %}
|
||||
<p class="margin-top-0">Viewer</p>
|
||||
{% else %}
|
||||
<p class="margin-top-0">No access</p>
|
||||
{% endif %}
|
||||
<p class="margin-top-0">{{ permissions.domain_requests_display }}</p>
|
||||
|
||||
<h4 class="margin-bottom-0 text-primary">Members</h4>
|
||||
{% if member_has_edit_members_portfolio_permission %}
|
||||
<p class="margin-top-0">Manager</p>
|
||||
{% elif member_has_view_members_portfolio_permission %}
|
||||
<p class="margin-top-0">Viewer</p>
|
||||
{% else %}
|
||||
<p class="margin-top-0">No access</p>
|
||||
{% endif %}
|
||||
<p class="margin-top-0">{{ permissions.members_display }}</p>
|
||||
|
|
|
@ -59,7 +59,7 @@
|
|||
role="columnheader"
|
||||
id="header-action"
|
||||
>
|
||||
<span class="usa-sr-only">Action</span>
|
||||
Action
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
34
src/registrar/templates/includes/search.html
Normal file
34
src/registrar/templates/includes/search.html
Normal file
|
@ -0,0 +1,34 @@
|
|||
{% load static %}
|
||||
|
||||
<div class="section-outlined__search mobile:grid-col-12 desktop:grid-col-9">
|
||||
<section aria-label="{{aria_label_text}}">
|
||||
<form class="usa-search usa-search--show-label" method="POST" role="search">
|
||||
{% csrf_token %}
|
||||
<label class="usa-label display-block margin-bottom-05 maxw-none" for="{{item_name}}__search-field">
|
||||
{{ label_text }}
|
||||
</label>
|
||||
<div class="usa-search--show-label__input-wrapper flex-align-self-end">
|
||||
<input
|
||||
class="usa-input minw-15"
|
||||
id="{{item_name}}__search-field"
|
||||
type="search"
|
||||
name="{{item_name}}-search"
|
||||
/>
|
||||
<button class="usa-button" type="submit" id="{{item_name}}__search-field-submit">
|
||||
<span class="usa-search__submit-text">Search </span>
|
||||
<img
|
||||
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
|
||||
class="usa-search__submit-icon"
|
||||
alt="Search"
|
||||
/>
|
||||
</button>
|
||||
<button class="usa-button usa-button--unstyled margin-left-3 display-none flex-1" id="{{item_name}}__reset-search" type="button">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
|
||||
</svg>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
|
@ -113,10 +113,10 @@
|
|||
</ul>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if value.invitations.all %}
|
||||
{% if value.active_invitations.all %}
|
||||
<h4 class="margin-bottom-05">Invited domain managers</h4>
|
||||
<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>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
{# the entire logged in page goes here #}
|
||||
|
||||
<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 %}
|
||||
|
||||
|
|
|
@ -251,15 +251,6 @@ def is_members_subpage(path):
|
|||
return get_url_name(path) in url_names
|
||||
|
||||
|
||||
@register.filter(name="portfolio_role_summary")
|
||||
def portfolio_role_summary(user, portfolio):
|
||||
"""Returns the value of user.portfolio_role_summary"""
|
||||
if user and portfolio:
|
||||
return user.portfolio_role_summary(portfolio)
|
||||
else:
|
||||
return []
|
||||
|
||||
|
||||
@register.filter(name="display_requesting_entity")
|
||||
def display_requesting_entity(domain_request):
|
||||
"""Workaround for a newline issue in .txt files (our emails) as if statements
|
||||
|
|
|
@ -120,7 +120,7 @@ class TestFsmModelResource(TestCase):
|
|||
fsm_field_mock.save.assert_not_called()
|
||||
|
||||
|
||||
class TestDomainInvitationAdmin(TestCase):
|
||||
class TestDomainInvitationAdmin(WebTest):
|
||||
"""Tests for the DomainInvitationAdmin class as super user
|
||||
|
||||
Notes:
|
||||
|
@ -128,15 +128,27 @@ class TestDomainInvitationAdmin(TestCase):
|
|||
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.admin = ListHeaderAdmin(model=DomainInvitationAdmin, admin_site=AdminSite())
|
||||
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.portfolio = Portfolio.objects.create(organization_name="new portfolio", creator=self.superuser)
|
||||
DomainInformation.objects.create(domain=self.domain, portfolio=self.portfolio, creator=self.superuser)
|
||||
"""Create a client object"""
|
||||
self.client = Client(HTTP_HOST="localhost:8080")
|
||||
self.client.force_login(self.superuser)
|
||||
self.app.set_user(self.superuser.username)
|
||||
|
||||
def tearDown(self):
|
||||
"""Delete all DomainInvitation objects"""
|
||||
|
@ -1071,6 +1083,50 @@ class TestDomainInvitationAdmin(TestCase):
|
|||
self.assertEqual(DomainInvitation.objects.count(), 0)
|
||||
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):
|
||||
"""Tests for the PortfolioInivtationAdmin class"""
|
||||
|
@ -2016,7 +2072,7 @@ class TestDomainInformationAdmin(TestCase):
|
|||
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
|
||||
|
||||
Notes:
|
||||
|
@ -2043,6 +2099,8 @@ class TestUserDomainRoleAdmin(TestCase):
|
|||
"""Setup environment for a mock admin user"""
|
||||
super().setUp()
|
||||
self.client = Client(HTTP_HOST="localhost:8080")
|
||||
self.client.force_login(self.superuser)
|
||||
self.app.set_user(self.superuser.username)
|
||||
|
||||
def tearDown(self):
|
||||
"""Delete all Users, Domains, and UserDomainRoles"""
|
||||
|
@ -2205,6 +2263,48 @@ class TestUserDomainRoleAdmin(TestCase):
|
|||
# We only need to check for the end of the HTML string
|
||||
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):
|
||||
"""Tests for the ListHeaderAdmin class as super user
|
||||
|
|
|
@ -12,6 +12,7 @@ from registrar.models import (
|
|||
Domain,
|
||||
DomainRequest,
|
||||
DomainInformation,
|
||||
DomainInvitation,
|
||||
User,
|
||||
Host,
|
||||
Portfolio,
|
||||
|
@ -495,6 +496,107 @@ class TestDomainInformationInline(MockEppLib):
|
|||
self.assertIn("poopy@gov.gov", domain_managers)
|
||||
|
||||
|
||||
class TestDomainInvitationAdmin(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.staffuser = create_user(email="staffdomainmanager@meoward.com", is_staff=True)
|
||||
cls.site = AdminSite()
|
||||
cls.admin = DomainAdmin(model=Domain, admin_site=cls.site)
|
||||
cls.factory = RequestFactory()
|
||||
|
||||
def setUp(self):
|
||||
self.client = Client(HTTP_HOST="localhost:8080")
|
||||
self.client.force_login(self.staffuser)
|
||||
super().setUp()
|
||||
|
||||
def test_successful_cancel_invitation_flow_in_admin(self):
|
||||
"""Testing canceling a domain invitation in Django Admin."""
|
||||
|
||||
# 1. Create a domain and assign staff user role + domain manager
|
||||
domain = Domain.objects.create(name="cancelinvitationflowviaadmin.gov")
|
||||
UserDomainRole.objects.create(user=self.staffuser, domain=domain, role="manager")
|
||||
|
||||
# 2. Invite a domain manager to the above domain
|
||||
invitation = DomainInvitation.objects.create(
|
||||
email="inviteddomainmanager@meoward.com",
|
||||
domain=domain,
|
||||
status=DomainInvitation.DomainInvitationStatus.INVITED,
|
||||
)
|
||||
|
||||
# 3. Go to the Domain Invitations list in /admin
|
||||
domain_invitation_list_url = reverse("admin:registrar_domaininvitation_changelist")
|
||||
response = self.client.get(domain_invitation_list_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# 4. Go to the change view of that invitation and make sure you can see the button
|
||||
domain_invitation_change_url = reverse("admin:registrar_domaininvitation_change", args=[invitation.id])
|
||||
response = self.client.get(domain_invitation_change_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Cancel invitation")
|
||||
|
||||
# 5. Click the cancel invitation button
|
||||
response = self.client.post(domain_invitation_change_url, {"cancel_invitation": "true"}, follow=True)
|
||||
|
||||
# 6. Make sure we're redirect back to the change view page in /admin
|
||||
self.assertRedirects(response, domain_invitation_change_url)
|
||||
|
||||
# 7. Confirm cancellation confirmation message appears
|
||||
expected_message = f"Invitation for {invitation.email} on {domain.name} is canceled"
|
||||
self.assertContains(response, expected_message)
|
||||
|
||||
def test_no_cancel_invitation_button_in_retrieved_state(self):
|
||||
"""Shouldn't be able to see the "Cancel invitation" button if invitation is RETRIEVED state"""
|
||||
|
||||
# 1. Create a domain and assign staff user role + domain manager
|
||||
domain = Domain.objects.create(name="retrieved.gov")
|
||||
UserDomainRole.objects.create(user=self.staffuser, domain=domain, role="manager")
|
||||
|
||||
# 2. Invite a domain manager to the above domain and NOT in invited state
|
||||
invitation = DomainInvitation.objects.create(
|
||||
email="retrievedinvitation@meoward.com",
|
||||
domain=domain,
|
||||
status=DomainInvitation.DomainInvitationStatus.RETRIEVED,
|
||||
)
|
||||
|
||||
# 3. Go to the Domain Invitations list in /admin
|
||||
domain_invitation_list_url = reverse("admin:registrar_domaininvitation_changelist")
|
||||
response = self.client.get(domain_invitation_list_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# 4. Go to the change view of that invitation and make sure you CANNOT see the button
|
||||
domain_invitation_change_url = reverse("admin:registrar_domaininvitation_change", args=[invitation.id])
|
||||
response = self.client.get(domain_invitation_change_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotContains(response, "Cancel invitation")
|
||||
|
||||
def test_no_cancel_invitation_button_in_canceled_state(self):
|
||||
"""Shouldn't be able to see the "Cancel invitation" button if invitation is CANCELED state"""
|
||||
|
||||
# 1. Create a domain and assign staff user role + domain manager
|
||||
domain = Domain.objects.create(name="canceled.gov")
|
||||
UserDomainRole.objects.create(user=self.staffuser, domain=domain, role="manager")
|
||||
|
||||
# 2. Invite a domain manager to the above domain and NOT in invited state
|
||||
invitation = DomainInvitation.objects.create(
|
||||
email="canceledinvitation@meoward.com",
|
||||
domain=domain,
|
||||
status=DomainInvitation.DomainInvitationStatus.CANCELED,
|
||||
)
|
||||
|
||||
# 3. Go to the Domain Invitations list in /admin
|
||||
domain_invitation_list_url = reverse("admin:registrar_domaininvitation_changelist")
|
||||
response = self.client.get(domain_invitation_list_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# 4. Go to the change view of that invitation and make sure you CANNOT see the button
|
||||
domain_invitation_change_url = reverse("admin:registrar_domaininvitation_change", args=[invitation.id])
|
||||
response = self.client.get(domain_invitation_change_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotContains(response, "Cancel invitation")
|
||||
|
||||
|
||||
class TestDomainAdminWithClient(TestCase):
|
||||
"""Test DomainAdmin class as super user.
|
||||
|
||||
|
|
|
@ -16,6 +16,7 @@ from registrar.utility.email_invitations import (
|
|||
send_portfolio_admin_addition_emails,
|
||||
send_portfolio_admin_removal_emails,
|
||||
send_portfolio_invitation_email,
|
||||
send_portfolio_member_permission_update_email,
|
||||
)
|
||||
|
||||
from api.tests.common import less_console_noise_decorator
|
||||
|
@ -522,7 +523,6 @@ class PortfolioInvitationEmailTests(unittest.TestCase):
|
|||
"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
|
||||
|
@ -888,3 +888,78 @@ class SendPortfolioAdminRemovalEmailsTests(unittest.TestCase):
|
|||
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)
|
||||
|
||||
|
||||
class TestSendPortfolioMemberPermissionUpdateEmail(unittest.TestCase):
|
||||
"""Unit tests for send_portfolio_member_permission_update_email function."""
|
||||
|
||||
@patch("registrar.utility.email_invitations.send_templated_email")
|
||||
@patch("registrar.utility.email_invitations._get_requestor_email")
|
||||
def test_send_email_success(self, mock_get_requestor_email, mock_send_email):
|
||||
"""Test that the email is sent successfully when there are no errors."""
|
||||
# Mock data
|
||||
requestor = MagicMock()
|
||||
permissions = MagicMock(spec=UserPortfolioPermission)
|
||||
permissions.user.email = "user@example.com"
|
||||
permissions.portfolio.organization_name = "Test Portfolio"
|
||||
|
||||
mock_get_requestor_email.return_value = "requestor@example.com"
|
||||
|
||||
# Call function
|
||||
result = send_portfolio_member_permission_update_email(requestor, permissions)
|
||||
|
||||
# Assertions
|
||||
mock_get_requestor_email.assert_called_once_with(requestor, portfolio=permissions.portfolio)
|
||||
mock_send_email.assert_called_once_with(
|
||||
"emails/portfolio_update.txt",
|
||||
"emails/portfolio_update_subject.txt",
|
||||
to_address="user@example.com",
|
||||
context={
|
||||
"requested_user": permissions.user,
|
||||
"portfolio": permissions.portfolio,
|
||||
"requestor_email": "requestor@example.com",
|
||||
"permissions": permissions,
|
||||
"date": date.today(),
|
||||
},
|
||||
)
|
||||
self.assertTrue(result)
|
||||
|
||||
@patch("registrar.utility.email_invitations.send_templated_email", side_effect=EmailSendingError("Email failed"))
|
||||
@patch("registrar.utility.email_invitations._get_requestor_email")
|
||||
@patch("registrar.utility.email_invitations.logger")
|
||||
def test_send_email_failure(self, mock_logger, mock_get_requestor_email, mock_send_email):
|
||||
"""Test that the function returns False and logs an error when email sending fails."""
|
||||
# Mock data
|
||||
requestor = MagicMock()
|
||||
permissions = MagicMock(spec=UserPortfolioPermission)
|
||||
permissions.user.email = "user@example.com"
|
||||
permissions.portfolio.organization_name = "Test Portfolio"
|
||||
|
||||
mock_get_requestor_email.return_value = "requestor@example.com"
|
||||
|
||||
# Call function
|
||||
result = send_portfolio_member_permission_update_email(requestor, permissions)
|
||||
|
||||
# Assertions
|
||||
mock_logger.warning.assert_called_once_with(
|
||||
"Could not send email organization member update notification to %s for portfolio: %s",
|
||||
permissions.user.email,
|
||||
permissions.portfolio.organization_name,
|
||||
exc_info=True,
|
||||
)
|
||||
self.assertFalse(result)
|
||||
|
||||
@patch("registrar.utility.email_invitations._get_requestor_email", side_effect=Exception("Unexpected error"))
|
||||
@patch("registrar.utility.email_invitations.logger")
|
||||
def test_requestor_email_retrieval_failure(self, mock_logger, mock_get_requestor_email):
|
||||
"""Test that an exception in retrieving requestor email is logged."""
|
||||
# Mock data
|
||||
requestor = MagicMock()
|
||||
permissions = MagicMock(spec=UserPortfolioPermission)
|
||||
|
||||
# Call function
|
||||
with self.assertRaises(Exception):
|
||||
send_portfolio_member_permission_update_email(requestor, permissions)
|
||||
|
||||
# Assertions
|
||||
mock_logger.warning.assert_not_called() # Function should fail before logging email failure
|
||||
|
|
|
@ -108,6 +108,82 @@ class TestEmails(TestCase):
|
|||
|
||||
self.assertEqual(["testy2@town.com", "mayor@igorville.gov"], kwargs["Destination"]["CcAddresses"])
|
||||
|
||||
@boto3_mocking.patching
|
||||
@override_settings(IS_PRODUCTION=True, BASE_URL="manage.get.gov")
|
||||
def test_email_production_subject_and_url_check(self):
|
||||
"""Test sending an email in production that:
|
||||
1. Does not have a prefix in the email subject (no [MANAGE])
|
||||
2. Uses the production URL in the email body of manage.get.gov still"""
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
send_templated_email(
|
||||
"emails/update_to_approved_domain.txt",
|
||||
"emails/update_to_approved_domain_subject.txt",
|
||||
"doesnotexist@igorville.com",
|
||||
context={"domain": "test", "user": "test", "date": 1, "changes": "test"},
|
||||
bcc_address=None,
|
||||
cc_addresses=["testy2@town.com", "mayor@igorville.gov"],
|
||||
)
|
||||
|
||||
# check that an email was sent
|
||||
self.assertTrue(self.mock_client.send_email.called)
|
||||
|
||||
# check the call sequence for the email
|
||||
args, kwargs = self.mock_client.send_email.call_args
|
||||
self.assertIn("Destination", kwargs)
|
||||
self.assertIn("CcAddresses", kwargs["Destination"])
|
||||
|
||||
self.assertEqual(["testy2@town.com", "mayor@igorville.gov"], kwargs["Destination"]["CcAddresses"])
|
||||
|
||||
# Grab email subject
|
||||
email_subject = kwargs["Content"]["Simple"]["Subject"]["Data"]
|
||||
|
||||
# Check that the subject does NOT contain a prefix for production
|
||||
self.assertNotIn("[MANAGE]", email_subject)
|
||||
self.assertIn("An update was made to", email_subject)
|
||||
|
||||
# Grab email body
|
||||
email_body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
|
||||
|
||||
# Check that manage_url is correctly set for production
|
||||
self.assertIn("https://manage.get.gov", email_body)
|
||||
|
||||
@boto3_mocking.patching
|
||||
@override_settings(IS_PRODUCTION=False, BASE_URL="https://getgov-rh.app.cloud.gov")
|
||||
def test_email_non_production_subject_and_url_check(self):
|
||||
"""Test sending an email in production that:
|
||||
1. Does prefix in the email subject (ie [GETGOV-RH])
|
||||
2. Uses the sandbox url in the email body (ie getgov-rh.app.cloud.gov)"""
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
send_templated_email(
|
||||
"emails/update_to_approved_domain.txt",
|
||||
"emails/update_to_approved_domain_subject.txt",
|
||||
"doesnotexist@igorville.com",
|
||||
context={"domain": "test", "user": "test", "date": 1, "changes": "test"},
|
||||
bcc_address=None,
|
||||
cc_addresses=["testy2@town.com", "mayor@igorville.gov"],
|
||||
)
|
||||
|
||||
# check that an email was sent
|
||||
self.assertTrue(self.mock_client.send_email.called)
|
||||
|
||||
# check the call sequence for the email
|
||||
args, kwargs = self.mock_client.send_email.call_args
|
||||
self.assertIn("Destination", kwargs)
|
||||
self.assertIn("CcAddresses", kwargs["Destination"])
|
||||
self.assertEqual(["testy2@town.com", "mayor@igorville.gov"], kwargs["Destination"]["CcAddresses"])
|
||||
|
||||
# Grab email subject
|
||||
email_subject = kwargs["Content"]["Simple"]["Subject"]["Data"]
|
||||
|
||||
# Check that the subject DOES contain a prefix of the current sandbox
|
||||
self.assertIn("[GETGOV-RH]", email_subject)
|
||||
|
||||
# Grab email body
|
||||
email_body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
|
||||
|
||||
# Check that manage_url is correctly set of the sandbox
|
||||
self.assertIn("https://getgov-rh.app.cloud.gov", email_body)
|
||||
|
||||
@boto3_mocking.patching
|
||||
@less_console_noise_decorator
|
||||
def test_submission_confirmation(self):
|
||||
|
|
|
@ -7,6 +7,7 @@ from registrar.models.domain_group import DomainGroup
|
|||
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||
from registrar.models.senior_official import SeniorOfficial
|
||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
||||
from registrar.utility.constants import BranchChoices
|
||||
from django.utils import timezone
|
||||
from django.utils.module_loading import import_string
|
||||
|
@ -1465,6 +1466,7 @@ class TestCreateFederalPortfolio(TestCase):
|
|||
self.executive_so_2 = SeniorOfficial.objects.create(
|
||||
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):
|
||||
self.domain_request = completed_domain_request(
|
||||
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
|
||||
|
@ -1474,6 +1476,7 @@ class TestCreateFederalPortfolio(TestCase):
|
|||
)
|
||||
self.domain_request.approve()
|
||||
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(
|
||||
name="icecreamforigorville.gov",
|
||||
|
@ -1517,7 +1520,6 @@ class TestCreateFederalPortfolio(TestCase):
|
|||
FederalAgency.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def run_create_federal_portfolio(self, **kwargs):
|
||||
with patch(
|
||||
"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
|
||||
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")
|
||||
|
||||
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")
|
||||
|
||||
|
@ -1854,6 +1856,143 @@ class TestCreateFederalPortfolio(TestCase):
|
|||
self.assertEqual(existing_portfolio.notes, "Old notes")
|
||||
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):
|
||||
"""Tests the skip_existing_portfolios to ensure that it doesn't add
|
||||
suborgs, domain requests, and domain info."""
|
||||
|
|
|
@ -1191,67 +1191,6 @@ class TestUser(TestCase):
|
|||
User.objects.all().delete()
|
||||
UserDomainRole.objects.all().delete()
|
||||
|
||||
@patch.object(User, "has_edit_portfolio_permission", return_value=True)
|
||||
def test_portfolio_role_summary_admin(self, mock_edit_org):
|
||||
# Test if the user is recognized as an Admin
|
||||
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Admin"])
|
||||
|
||||
@patch.multiple(
|
||||
User,
|
||||
has_view_all_domains_portfolio_permission=lambda self, portfolio: True,
|
||||
has_any_requests_portfolio_permission=lambda self, portfolio: True,
|
||||
has_edit_request_portfolio_permission=lambda self, portfolio: True,
|
||||
)
|
||||
def test_portfolio_role_summary_view_only_admin_and_domain_requestor(self):
|
||||
# Test if the user has both 'View-only admin' and 'Domain requestor' roles
|
||||
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["View-only admin", "Domain requestor"])
|
||||
|
||||
@patch.multiple(
|
||||
User,
|
||||
has_view_all_domains_portfolio_permission=lambda self, portfolio: True,
|
||||
has_any_requests_portfolio_permission=lambda self, portfolio: True,
|
||||
)
|
||||
def test_portfolio_role_summary_view_only_admin(self):
|
||||
# Test if the user is recognized as a View-only admin
|
||||
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["View-only admin"])
|
||||
|
||||
@patch.multiple(
|
||||
User,
|
||||
has_view_portfolio_permission=lambda self, portfolio: True,
|
||||
has_edit_request_portfolio_permission=lambda self, portfolio: True,
|
||||
has_any_domains_portfolio_permission=lambda self, portfolio: True,
|
||||
)
|
||||
def test_portfolio_role_summary_member_domain_requestor_domain_manager(self):
|
||||
# Test if the user has 'Member', 'Domain requestor', and 'Domain manager' roles
|
||||
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Domain requestor", "Domain manager"])
|
||||
|
||||
@patch.multiple(
|
||||
User,
|
||||
has_view_portfolio_permission=lambda self, portfolio: True,
|
||||
has_edit_request_portfolio_permission=lambda self, portfolio: True,
|
||||
)
|
||||
def test_portfolio_role_summary_member_domain_requestor(self):
|
||||
# Test if the user has 'Member' and 'Domain requestor' roles
|
||||
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Domain requestor"])
|
||||
|
||||
@patch.multiple(
|
||||
User,
|
||||
has_view_portfolio_permission=lambda self, portfolio: True,
|
||||
has_any_domains_portfolio_permission=lambda self, portfolio: True,
|
||||
)
|
||||
def test_portfolio_role_summary_member_domain_manager(self):
|
||||
# Test if the user has 'Member' and 'Domain manager' roles
|
||||
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Domain manager"])
|
||||
|
||||
@patch.multiple(User, has_view_portfolio_permission=lambda self, portfolio: True)
|
||||
def test_portfolio_role_summary_member(self):
|
||||
# Test if the user is recognized as a Member
|
||||
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Member"])
|
||||
|
||||
def test_portfolio_role_summary_empty(self):
|
||||
# Test if the user has no roles
|
||||
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), [])
|
||||
|
||||
@patch("registrar.models.User._has_portfolio_permission")
|
||||
def test_has_view_portfolio_permission(self, mock_has_permission):
|
||||
mock_has_permission.return_value = True
|
||||
|
|
|
@ -1106,7 +1106,7 @@ class TestDomainRequest(TestCase):
|
|||
federal_agency=fed_agency,
|
||||
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]
|
||||
)
|
||||
# Adds cc'ed email in this test's allow list
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import io
|
||||
from unittest import skip
|
||||
from django.test import Client, RequestFactory
|
||||
from io import StringIO
|
||||
from registrar.models import (
|
||||
|
@ -819,6 +820,7 @@ class MemberExportTest(MockDbForIndividualTests, MockEppLib):
|
|||
super().setUp()
|
||||
self.factory = RequestFactory()
|
||||
|
||||
@skip("flaky test that needs to be refactored")
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
@less_console_noise_decorator
|
||||
|
|
|
@ -477,7 +477,6 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
|
|||
self.domain_with_ip.expiration_date = self.expiration_date_one_year_out()
|
||||
self.domain_with_ip.save()
|
||||
|
||||
@override_flag("domain_renewal", active=True)
|
||||
def test_expiring_domain_on_detail_page_as_domain_manager(self):
|
||||
"""If a user is a domain manager and their domain is expiring soon,
|
||||
user should be able to see the "Renew to maintain access" link domain overview detail box."""
|
||||
|
@ -496,7 +495,6 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
|
|||
self.assertNotContains(detail_page, "DNS needed")
|
||||
self.assertNotContains(detail_page, "Expired")
|
||||
|
||||
@override_flag("domain_renewal", active=True)
|
||||
@override_flag("organization_feature", active=True)
|
||||
def test_expiring_domain_on_detail_page_in_org_model_as_a_non_domain_manager(self):
|
||||
"""In org model: If a user is NOT a domain manager and their domain is expiring soon,
|
||||
|
@ -534,7 +532,6 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
|
|||
)
|
||||
self.assertContains(detail_page, "Contact one of the listed domain managers to renew the domain.")
|
||||
|
||||
@override_flag("domain_renewal", active=True)
|
||||
@override_flag("organization_feature", active=True)
|
||||
def test_expiring_domain_on_detail_page_in_org_model_as_a_domain_manager(self):
|
||||
"""Inorg model: If a user is a domain manager and their domain is expiring soon,
|
||||
|
@ -555,7 +552,6 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
|
|||
)
|
||||
self.assertContains(detail_page, "Renew to maintain access")
|
||||
|
||||
@override_flag("domain_renewal", active=True)
|
||||
def test_domain_renewal_form_and_sidebar_expiring(self):
|
||||
"""If a user is a domain manager and their domain is expiring soon,
|
||||
user should be able to see Renewal Form on the sidebar."""
|
||||
|
@ -584,7 +580,6 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
|
|||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, f"Renew {self.domain_to_renew.name}")
|
||||
|
||||
@override_flag("domain_renewal", active=True)
|
||||
def test_domain_renewal_form_and_sidebar_expired(self):
|
||||
"""If a user is a domain manager and their domain is expired,
|
||||
user should be able to see Renewal Form on the sidebar."""
|
||||
|
@ -614,7 +609,6 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
|
|||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, f"Renew {self.domain_to_renew.name}")
|
||||
|
||||
@override_flag("domain_renewal", active=True)
|
||||
def test_domain_renewal_form_your_contact_info_edit(self):
|
||||
"""Checking that if a user is a domain manager they can edit the
|
||||
Your Profile portion of the Renewal Form."""
|
||||
|
@ -634,7 +628,6 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
|
|||
self.assertEqual(edit_page.status_code, 200)
|
||||
self.assertContains(edit_page, "Review the details below and update any required information")
|
||||
|
||||
@override_flag("domain_renewal", active=True)
|
||||
def test_domain_renewal_form_security_email_edit(self):
|
||||
"""Checking that if a user is a domain manager they can edit the
|
||||
Security Email portion of the Renewal Form."""
|
||||
|
@ -657,7 +650,6 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
|
|||
self.assertEqual(edit_page.status_code, 200)
|
||||
self.assertContains(edit_page, "A security contact should be capable of evaluating")
|
||||
|
||||
@override_flag("domain_renewal", active=True)
|
||||
def test_domain_renewal_form_domain_manager_edit(self):
|
||||
"""Checking that if a user is a domain manager they can edit the
|
||||
Domain Manager portion of the Renewal Form."""
|
||||
|
@ -677,7 +669,6 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
|
|||
self.assertEqual(edit_page.status_code, 200)
|
||||
self.assertContains(edit_page, "Domain managers can update all information related to a domain")
|
||||
|
||||
@override_flag("domain_renewal", active=True)
|
||||
def test_domain_renewal_form_not_expired_or_expiring(self):
|
||||
"""Checking that if the user's domain is not expired or expiring that user should not be able
|
||||
to access /renewal and that it should receive a 403."""
|
||||
|
@ -686,7 +677,6 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
|
|||
renewal_page = self.client.get(reverse("domain-renewal", kwargs={"pk": self.domain_not_expiring.id}))
|
||||
self.assertEqual(renewal_page.status_code, 403)
|
||||
|
||||
@override_flag("domain_renewal", active=True)
|
||||
def test_domain_renewal_form_does_not_appear_if_not_domain_manager(self):
|
||||
"""If user is not a domain manager and tries to access /renewal, user should receive a 403."""
|
||||
with patch.object(Domain, "is_expired", self.custom_is_expired_true), patch.object(
|
||||
|
@ -695,7 +685,6 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
|
|||
renewal_page = self.client.get(reverse("domain-renewal", kwargs={"pk": self.domain_no_domain_manager.id}))
|
||||
self.assertEqual(renewal_page.status_code, 403)
|
||||
|
||||
@override_flag("domain_renewal", active=True)
|
||||
def test_ack_checkbox_not_checked(self):
|
||||
"""If user don't check the checkbox, user should receive an error message."""
|
||||
# Grab the renewal URL
|
||||
|
@ -707,7 +696,6 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
|
|||
error_message = "Check the box if you read and agree to the requirements for operating a .gov domain."
|
||||
self.assertContains(response, error_message)
|
||||
|
||||
@override_flag("domain_renewal", active=True)
|
||||
def test_ack_checkbox_checked(self):
|
||||
"""If user check the checkbox and submits the form,
|
||||
user should be redirected Domain Over page with an updated by 1 year expiration date"""
|
||||
|
@ -1063,6 +1051,23 @@ class TestDomainManagers(TestDomainOverview):
|
|||
success_page = success_result.follow()
|
||||
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
|
||||
@patch("registrar.views.domain.send_domain_invitation_email")
|
||||
def test_domain_invitation_created(self, mock_send_domain_email):
|
||||
|
@ -2975,26 +2980,15 @@ class TestDomainRenewal(TestWithUser):
|
|||
pass
|
||||
super().tearDown()
|
||||
|
||||
# Remove test_without_domain_renewal_flag when domain renewal is released as a feature
|
||||
@less_console_noise_decorator
|
||||
@override_flag("domain_renewal", active=False)
|
||||
def test_without_domain_renewal_flag(self):
|
||||
self.client.force_login(self.user)
|
||||
domains_page = self.client.get("/")
|
||||
self.assertNotContains(domains_page, "will expire soon")
|
||||
self.assertNotContains(domains_page, "Expiring soon")
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("domain_renewal", active=True)
|
||||
def test_domain_renewal_flag_single_domain(self):
|
||||
def test_domain_with_single_domain(self):
|
||||
self.client.force_login(self.user)
|
||||
domains_page = self.client.get("/")
|
||||
self.assertContains(domains_page, "One domain will expire soon")
|
||||
self.assertContains(domains_page, "Expiring soon")
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("domain_renewal", active=True)
|
||||
def test_with_domain_renewal_flag_mulitple_domains(self):
|
||||
def test_with_mulitple_domains(self):
|
||||
today = datetime.now()
|
||||
expiring_date = (today + timedelta(days=30)).strftime("%Y-%m-%d")
|
||||
self.domain_with_another_expiring, _ = Domain.objects.get_or_create(
|
||||
|
@ -3010,8 +3004,7 @@ class TestDomainRenewal(TestWithUser):
|
|||
self.assertContains(domains_page, "Expiring soon")
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("domain_renewal", active=True)
|
||||
def test_with_domain_renewal_flag_no_expiring_domains(self):
|
||||
def test_with_no_expiring_domains(self):
|
||||
UserDomainRole.objects.filter(user=self.user, domain=self.domain_with_expired_date).delete()
|
||||
UserDomainRole.objects.filter(user=self.user, domain=self.domain_with_expiring_soon_date).delete()
|
||||
self.client.force_login(self.user)
|
||||
|
@ -3019,18 +3012,16 @@ class TestDomainRenewal(TestWithUser):
|
|||
self.assertNotContains(domains_page, "will expire soon")
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("domain_renewal", active=True)
|
||||
@override_flag("organization_feature", active=True)
|
||||
def test_domain_renewal_flag_single_domain_w_org_feature_flag(self):
|
||||
def test_single_domain_w_org_feature_flag(self):
|
||||
self.client.force_login(self.user)
|
||||
domains_page = self.client.get("/")
|
||||
self.assertContains(domains_page, "One domain will expire soon")
|
||||
self.assertContains(domains_page, "Expiring soon")
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("domain_renewal", active=True)
|
||||
@override_flag("organization_feature", active=True)
|
||||
def test_with_domain_renewal_flag_mulitple_domains_w_org_feature_flag(self):
|
||||
def test_with_mulitple_domains_w_org_feature_flag(self):
|
||||
today = datetime.now()
|
||||
expiring_date = (today + timedelta(days=31)).strftime("%Y-%m-%d")
|
||||
self.domain_with_another_expiring_org_model, _ = Domain.objects.get_or_create(
|
||||
|
@ -3046,9 +3037,8 @@ class TestDomainRenewal(TestWithUser):
|
|||
self.assertContains(domains_page, "Expiring soon")
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("domain_renewal", active=True)
|
||||
@override_flag("organization_feature", active=True)
|
||||
def test_with_domain_renewal_flag_no_expiring_domains_w_org_feature_flag(self):
|
||||
def test_no_expiring_domains_w_org_feature_flag(self):
|
||||
UserDomainRole.objects.filter(user=self.user, domain=self.domain_with_expired_date).delete()
|
||||
UserDomainRole.objects.filter(user=self.user, domain=self.domain_with_expiring_soon_date).delete()
|
||||
self.client.force_login(self.user)
|
||||
|
|
|
@ -372,6 +372,21 @@ class GetPortfolioMembersJsonTest(MockEppLib, WebTest):
|
|||
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})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json
|
||||
|
@ -381,6 +396,7 @@ class GetPortfolioMembersJsonTest(MockEppLib, WebTest):
|
|||
self.assertIn("somedomain1.com", domain_names)
|
||||
self.assertIn("thissecondinvitetestsasubqueryinjson@lets.notbreak", domain_names)
|
||||
self.assertNotIn("somedomain3.com", domain_names)
|
||||
self.assertNotIn("somedomain4.com", domain_names)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
|
|
|
@ -22,7 +22,7 @@ from registrar.models.utility.portfolio_helper import UserPortfolioPermissionCho
|
|||
from registrar.tests.test_views import TestWithUser
|
||||
from registrar.utility.email import EmailSendingError
|
||||
from registrar.utility.errors import MissingEmailError
|
||||
from .common import MockSESClient, completed_domain_request, create_test_user, create_user
|
||||
from .common import MockEppLib, MockSESClient, completed_domain_request, create_test_user, create_user
|
||||
from waffle.testutils import override_flag
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
import boto3_mocking # type: ignore
|
||||
|
@ -1063,7 +1063,7 @@ class TestPortfolio(WebTest):
|
|||
self.assertContains(response, "Invited")
|
||||
self.assertContains(response, portfolio_invitation.email)
|
||||
self.assertContains(response, "Admin")
|
||||
self.assertContains(response, "Viewer, all")
|
||||
self.assertContains(response, "Viewer")
|
||||
self.assertContains(response, "Creator")
|
||||
self.assertContains(response, "Manager")
|
||||
self.assertContains(
|
||||
|
@ -3365,34 +3365,35 @@ class TestRequestingEntity(WebTest):
|
|||
self.assertContains(response, "kepler, AL")
|
||||
|
||||
|
||||
class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
|
||||
class TestPortfolioInviteNewMemberView(MockEppLib, WebTest):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.user = create_test_user()
|
||||
|
||||
# Create Portfolio
|
||||
cls.portfolio = Portfolio.objects.create(creator=cls.user, organization_name="Test Portfolio")
|
||||
self.portfolio = Portfolio.objects.create(creator=self.user, organization_name="Test Portfolio")
|
||||
|
||||
# Add an invited member who has been invited to manage domains
|
||||
cls.invited_member_email = "invited@example.com"
|
||||
cls.invitation = PortfolioInvitation.objects.create(
|
||||
email=cls.invited_member_email,
|
||||
portfolio=cls.portfolio,
|
||||
self.invited_member_email = "invited@example.com"
|
||||
self.invitation = PortfolioInvitation.objects.create(
|
||||
email=self.invited_member_email,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
cls.new_member_email = "newmember@example.com"
|
||||
self.new_member_email = "newmember@example.com"
|
||||
|
||||
AllowedEmail.objects.get_or_create(email=cls.new_member_email)
|
||||
AllowedEmail.objects.get_or_create(email=self.new_member_email)
|
||||
|
||||
# Assign permissions to the user making requests
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=cls.user,
|
||||
portfolio=cls.portfolio,
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
|
@ -3400,14 +3401,13 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
|
|||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
def tearDown(self):
|
||||
PortfolioInvitation.objects.all().delete()
|
||||
UserPortfolioPermission.objects.all().delete()
|
||||
Portfolio.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
AllowedEmail.objects.all().delete()
|
||||
super().tearDownClass()
|
||||
super().tearDown()
|
||||
|
||||
@boto3_mocking.patching
|
||||
@less_console_noise_decorator
|
||||
|
@ -3452,6 +3452,85 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
|
|||
# Check that an email was sent
|
||||
self.assertTrue(mock_client.send_email.called)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
@patch("registrar.views.portfolios.send_portfolio_invitation_email")
|
||||
def test_member_invite_for_previously_removed_user(self, mock_send_email):
|
||||
"""Tests the member invitation flow for an existing member which was previously removed."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
# invite, then retrieve an existing user, then remove the user from the portfolio
|
||||
retrieved_member_email = "retrieved@example.com"
|
||||
retrieved_user = User.objects.create(
|
||||
username="retrieved_user",
|
||||
first_name="Retrieved",
|
||||
last_name="User",
|
||||
email=retrieved_member_email,
|
||||
phone="8003111234",
|
||||
title="retrieved",
|
||||
)
|
||||
|
||||
retrieved_invitation = PortfolioInvitation.objects.create(
|
||||
email=retrieved_member_email,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
],
|
||||
status=PortfolioInvitation.PortfolioInvitationStatus.INVITED,
|
||||
)
|
||||
retrieved_invitation.retrieve()
|
||||
retrieved_invitation.save()
|
||||
upp = UserPortfolioPermission.objects.filter(
|
||||
user=retrieved_user,
|
||||
portfolio=self.portfolio,
|
||||
)
|
||||
upp.delete()
|
||||
|
||||
# Simulate a session to ensure continuity
|
||||
session_id = self.client.session.session_key
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
|
||||
# Simulate submission of member invite for previously retrieved/removed member
|
||||
final_response = self.client.post(
|
||||
reverse("new-member"),
|
||||
{
|
||||
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
|
||||
"domain_request_permissions": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
|
||||
"domain_permissions": UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value,
|
||||
"member_permissions": "no_access",
|
||||
"email": retrieved_member_email,
|
||||
},
|
||||
)
|
||||
|
||||
# Ensure the final submission is successful
|
||||
self.assertEqual(final_response.status_code, 302) # Redirects
|
||||
|
||||
# Validate Database Changes
|
||||
# Validate that portfolio invitation was created and retrieved
|
||||
self.assertFalse(
|
||||
PortfolioInvitation.objects.filter(
|
||||
email=retrieved_member_email,
|
||||
portfolio=self.portfolio,
|
||||
status=PortfolioInvitation.PortfolioInvitationStatus.INVITED,
|
||||
).exists()
|
||||
)
|
||||
# at least one retrieved invitation
|
||||
self.assertTrue(
|
||||
PortfolioInvitation.objects.filter(
|
||||
email=retrieved_member_email,
|
||||
portfolio=self.portfolio,
|
||||
status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED,
|
||||
).exists()
|
||||
)
|
||||
# Ensure exactly one UserPortfolioPermission exists for the retrieved user
|
||||
self.assertEqual(
|
||||
UserPortfolioPermission.objects.filter(user=retrieved_user, portfolio=self.portfolio).count(),
|
||||
1,
|
||||
"Expected exactly one UserPortfolioPermission for the retrieved user.",
|
||||
)
|
||||
|
||||
@boto3_mocking.patching
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
|
@ -3891,7 +3970,10 @@ class TestPortfolioMemberEditView(WebTest):
|
|||
@override_flag("organization_members", active=True)
|
||||
@patch("registrar.views.portfolios.send_portfolio_admin_addition_emails")
|
||||
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
|
||||
def test_edit_member_permissions_basic_to_admin(self, mock_send_removal_emails, mock_send_addition_emails):
|
||||
@patch("registrar.views.portfolios.send_portfolio_member_permission_update_email")
|
||||
def test_edit_member_permissions_basic_to_admin(
|
||||
self, mock_send_update_email, mock_send_removal_emails, mock_send_addition_emails
|
||||
):
|
||||
"""Tests converting a basic member to admin with full permissions."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
|
@ -3906,6 +3988,7 @@ class TestPortfolioMemberEditView(WebTest):
|
|||
|
||||
# return indicator that notification emails sent successfully
|
||||
mock_send_addition_emails.return_value = True
|
||||
mock_send_update_email.return_value = True
|
||||
|
||||
response = self.client.post(
|
||||
reverse("member-permissions", kwargs={"pk": basic_permission.id}),
|
||||
|
@ -3925,6 +4008,8 @@ class TestPortfolioMemberEditView(WebTest):
|
|||
mock_send_addition_emails.assert_called_once()
|
||||
# assert removal emails are not sent
|
||||
mock_send_removal_emails.assert_not_called()
|
||||
# assert update email sent
|
||||
mock_send_update_email.assert_called_once()
|
||||
|
||||
# Get the arguments passed to send_portfolio_admin_addition_emails
|
||||
_, called_kwargs = mock_send_addition_emails.call_args
|
||||
|
@ -3934,14 +4019,22 @@ class TestPortfolioMemberEditView(WebTest):
|
|||
self.assertEqual(called_kwargs["requestor"], self.user)
|
||||
self.assertEqual(called_kwargs["portfolio"], self.portfolio)
|
||||
|
||||
# Get the arguments passed to send_portfolio_member_permission_update_email
|
||||
_, called_kwargs = mock_send_update_email.call_args
|
||||
|
||||
# Assert the update notification email content
|
||||
self.assertEqual(called_kwargs["requestor"], self.user)
|
||||
self.assertEqual(called_kwargs["permissions"], basic_permission)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
@patch("django.contrib.messages.warning")
|
||||
@patch("registrar.views.portfolios.send_portfolio_admin_addition_emails")
|
||||
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
|
||||
@patch("registrar.views.portfolios.send_portfolio_member_permission_update_email")
|
||||
def test_edit_member_permissions_basic_to_admin_notification_fails(
|
||||
self, mock_send_removal_emails, mock_send_addition_emails, mock_messages_warning
|
||||
self, mock_send_update_email, mock_send_removal_emails, mock_send_addition_emails, mock_messages_warning
|
||||
):
|
||||
"""Tests converting a basic member to admin with full permissions.
|
||||
Handle when notification emails fail to send."""
|
||||
|
@ -3958,6 +4051,7 @@ class TestPortfolioMemberEditView(WebTest):
|
|||
|
||||
# At least one notification email failed to send
|
||||
mock_send_addition_emails.return_value = False
|
||||
mock_send_update_email.return_value = False
|
||||
|
||||
response = self.client.post(
|
||||
reverse("member-permissions", kwargs={"pk": basic_permission.id}),
|
||||
|
@ -3977,6 +4071,8 @@ class TestPortfolioMemberEditView(WebTest):
|
|||
mock_send_addition_emails.assert_called_once()
|
||||
# assert no removal emails are sent
|
||||
mock_send_removal_emails.assert_not_called()
|
||||
# assert update email sent
|
||||
mock_send_update_email.assert_called_once()
|
||||
|
||||
# Get the arguments passed to send_portfolio_admin_addition_emails
|
||||
_, called_kwargs = mock_send_addition_emails.call_args
|
||||
|
@ -3986,18 +4082,32 @@ class TestPortfolioMemberEditView(WebTest):
|
|||
self.assertEqual(called_kwargs["requestor"], self.user)
|
||||
self.assertEqual(called_kwargs["portfolio"], self.portfolio)
|
||||
|
||||
# Assert warning message is called correctly
|
||||
mock_messages_warning.assert_called_once()
|
||||
warning_args, _ = mock_messages_warning.call_args
|
||||
self.assertIsInstance(warning_args[0], WSGIRequest)
|
||||
self.assertEqual(warning_args[1], "Could not send email notification to existing organization admins.")
|
||||
# Get the arguments passed to send_portfolio_member_permission_update_email
|
||||
_, called_kwargs = mock_send_update_email.call_args
|
||||
|
||||
# Assert the update notification email content
|
||||
self.assertEqual(called_kwargs["requestor"], self.user)
|
||||
self.assertEqual(called_kwargs["permissions"], basic_permission)
|
||||
|
||||
# Assert that messages.warning is called twice
|
||||
self.assertEqual(mock_messages_warning.call_count, 2)
|
||||
|
||||
# Extract the actual messages sent
|
||||
warning_messages = [call_args[0][1] for call_args in mock_messages_warning.call_args_list]
|
||||
|
||||
# Check for the expected messages
|
||||
self.assertIn("Could not send email notification to existing organization admins.", warning_messages)
|
||||
self.assertIn(f"Could not send email notification to {basic_member.email}.", warning_messages)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
@patch("registrar.views.portfolios.send_portfolio_admin_addition_emails")
|
||||
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
|
||||
def test_edit_member_permissions_admin_to_admin(self, mock_send_removal_emails, mock_send_addition_emails):
|
||||
@patch("registrar.views.portfolios.send_portfolio_member_permission_update_email")
|
||||
def test_edit_member_permissions_admin_to_admin(
|
||||
self, mock_send_update_email, mock_send_removal_emails, mock_send_addition_emails
|
||||
):
|
||||
"""Tests updating an admin without changing permissions."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
|
@ -4007,6 +4117,7 @@ class TestPortfolioMemberEditView(WebTest):
|
|||
user=admin_member,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[],
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
|
@ -4019,16 +4130,20 @@ class TestPortfolioMemberEditView(WebTest):
|
|||
# Verify redirect and success message
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
# assert addition and removal emails are not sent to portfolio admins
|
||||
# assert update, addition and removal emails are not sent to portfolio admins
|
||||
mock_send_addition_emails.assert_not_called()
|
||||
mock_send_removal_emails.assert_not_called()
|
||||
mock_send_update_email.assert_not_called()
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
@patch("registrar.views.portfolios.send_portfolio_admin_addition_emails")
|
||||
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
|
||||
def test_edit_member_permissions_basic_to_basic(self, mock_send_removal_emails, mock_send_addition_emails):
|
||||
@patch("registrar.views.portfolios.send_portfolio_member_permission_update_email")
|
||||
def test_edit_member_permissions_basic_to_basic(
|
||||
self, mock_send_update_email, mock_send_removal_emails, mock_send_addition_emails
|
||||
):
|
||||
"""Tests updating an admin without changing permissions."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
|
@ -4041,6 +4156,8 @@ class TestPortfolioMemberEditView(WebTest):
|
|||
additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS],
|
||||
)
|
||||
|
||||
mock_send_update_email.return_value = True
|
||||
|
||||
response = self.client.post(
|
||||
reverse("member-permissions", kwargs={"pk": basic_permission.id}),
|
||||
{
|
||||
|
@ -4057,13 +4174,25 @@ class TestPortfolioMemberEditView(WebTest):
|
|||
# assert addition and removal emails are not sent to portfolio admins
|
||||
mock_send_addition_emails.assert_not_called()
|
||||
mock_send_removal_emails.assert_not_called()
|
||||
# assert update email is sent to updated member
|
||||
mock_send_update_email.assert_called_once()
|
||||
|
||||
# Get the arguments passed to send_portfolio_member_permission_update_email
|
||||
_, called_kwargs = mock_send_update_email.call_args
|
||||
|
||||
# Assert the email content
|
||||
self.assertEqual(called_kwargs["requestor"], self.user)
|
||||
self.assertEqual(called_kwargs["permissions"], basic_permission)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
@patch("registrar.views.portfolios.send_portfolio_admin_addition_emails")
|
||||
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
|
||||
def test_edit_member_permissions_admin_to_basic(self, mock_send_removal_emails, mock_send_addition_emails):
|
||||
@patch("registrar.views.portfolios.send_portfolio_member_permission_update_email")
|
||||
def test_edit_member_permissions_admin_to_basic(
|
||||
self, mock_send_update_email, mock_send_removal_emails, mock_send_addition_emails
|
||||
):
|
||||
"""Tests converting an admin to basic member."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
|
@ -4074,8 +4203,9 @@ class TestPortfolioMemberEditView(WebTest):
|
|||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
)
|
||||
|
||||
print(admin_permission)
|
||||
mock_send_removal_emails.return_value = True
|
||||
mock_send_update_email.return_value = True
|
||||
|
||||
response = self.client.post(
|
||||
reverse("member-permissions", kwargs={"pk": admin_permission.id}),
|
||||
|
@ -4094,7 +4224,8 @@ class TestPortfolioMemberEditView(WebTest):
|
|||
admin_permission.refresh_from_db()
|
||||
self.assertEqual(admin_permission.roles, [UserPortfolioRoleChoices.ORGANIZATION_MEMBER])
|
||||
|
||||
# assert removal emails are sent to portfolio admins
|
||||
# assert removal emails and update email are sent to portfolio admins
|
||||
mock_send_update_email.assert_called_once()
|
||||
mock_send_addition_emails.assert_not_called()
|
||||
mock_send_removal_emails.assert_called_once()
|
||||
|
||||
|
@ -4106,14 +4237,22 @@ class TestPortfolioMemberEditView(WebTest):
|
|||
self.assertEqual(called_kwargs["requestor"], self.user)
|
||||
self.assertEqual(called_kwargs["portfolio"], self.portfolio)
|
||||
|
||||
# Get the arguments passed to send_portfolio_member_permission_update_email
|
||||
_, called_kwargs = mock_send_update_email.call_args
|
||||
|
||||
# Assert the email content
|
||||
self.assertEqual(called_kwargs["requestor"], self.user)
|
||||
self.assertEqual(called_kwargs["permissions"], admin_permission)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
@patch("django.contrib.messages.warning")
|
||||
@patch("registrar.views.portfolios.send_portfolio_admin_addition_emails")
|
||||
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
|
||||
@patch("registrar.views.portfolios.send_portfolio_member_permission_update_email")
|
||||
def test_edit_member_permissions_admin_to_basic_notification_fails(
|
||||
self, mock_send_removal_emails, mock_send_addition_emails, mock_messages_warning
|
||||
self, mock_send_update_email, mock_send_removal_emails, mock_send_addition_emails, mock_messages_warning
|
||||
):
|
||||
"""Tests converting an admin to basic member."""
|
||||
self.client.force_login(self.user)
|
||||
|
@ -4129,6 +4268,7 @@ class TestPortfolioMemberEditView(WebTest):
|
|||
|
||||
# False return indicates that at least one notification email failed to send
|
||||
mock_send_removal_emails.return_value = False
|
||||
mock_send_update_email.return_value = False
|
||||
|
||||
response = self.client.post(
|
||||
reverse("member-permissions", kwargs={"pk": admin_permission.id}),
|
||||
|
@ -4147,9 +4287,10 @@ class TestPortfolioMemberEditView(WebTest):
|
|||
admin_permission.refresh_from_db()
|
||||
self.assertEqual(admin_permission.roles, [UserPortfolioRoleChoices.ORGANIZATION_MEMBER])
|
||||
|
||||
# assert removal emails are sent to portfolio admins
|
||||
# assert update email and removal emails are sent to portfolio admins
|
||||
mock_send_addition_emails.assert_not_called()
|
||||
mock_send_removal_emails.assert_called_once()
|
||||
mock_send_update_email.assert_called_once()
|
||||
|
||||
# Get the arguments passed to send_portfolio_admin_removal_emails
|
||||
_, called_kwargs = mock_send_removal_emails.call_args
|
||||
|
@ -4159,11 +4300,22 @@ class TestPortfolioMemberEditView(WebTest):
|
|||
self.assertEqual(called_kwargs["requestor"], self.user)
|
||||
self.assertEqual(called_kwargs["portfolio"], self.portfolio)
|
||||
|
||||
# Assert warning message is called correctly
|
||||
mock_messages_warning.assert_called_once()
|
||||
warning_args, _ = mock_messages_warning.call_args
|
||||
self.assertIsInstance(warning_args[0], WSGIRequest)
|
||||
self.assertEqual(warning_args[1], "Could not send email notification to existing organization admins.")
|
||||
# Get the arguments passed to send_portfolio_member_permission_update_email
|
||||
_, called_kwargs = mock_send_update_email.call_args
|
||||
|
||||
# Assert the email content
|
||||
self.assertEqual(called_kwargs["requestor"], self.user)
|
||||
self.assertEqual(called_kwargs["permissions"], admin_permission)
|
||||
|
||||
# Assert that messages.warning is called twice
|
||||
self.assertEqual(mock_messages_warning.call_count, 2)
|
||||
|
||||
# Extract the actual messages sent
|
||||
warning_messages = [call_args[0][1] for call_args in mock_messages_warning.call_args_list]
|
||||
|
||||
# Check for the expected messages
|
||||
self.assertIn("Could not send email notification to existing organization admins.", warning_messages)
|
||||
self.assertIn(f"Could not send email notification to {admin_member.email}.", warning_messages)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
import boto3
|
||||
import logging
|
||||
import textwrap
|
||||
import re
|
||||
from datetime import datetime
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
|
@ -48,6 +49,21 @@ def send_templated_email( # noqa
|
|||
No valid recipient addresses are provided
|
||||
"""
|
||||
|
||||
if context is None:
|
||||
context = {}
|
||||
|
||||
env_base_url = settings.BASE_URL
|
||||
# The regular expression is to get both http (localhost) and https (everything else)
|
||||
env_name = re.sub(r"^https?://", "", env_base_url).split(".")[0]
|
||||
# If NOT in prod, add env to the subject line
|
||||
# IE adds [GETGOV-RH] if we are in the -RH sandbox
|
||||
prefix = f"[{env_name.upper()}] " if not settings.IS_PRODUCTION else ""
|
||||
# If NOT in prod, update instances of "manage.get.gov" links to point to
|
||||
# current environment, ie "getgov-rh.app.cloud.gov"
|
||||
manage_url = env_base_url if not settings.IS_PRODUCTION else "https://manage.get.gov"
|
||||
|
||||
context["manage_url"] = manage_url
|
||||
|
||||
# by default assume we can send to all addresses (prod has no whitelist)
|
||||
sendable_cc_addresses = cc_addresses
|
||||
|
||||
|
@ -70,8 +86,10 @@ def send_templated_email( # noqa
|
|||
if email_body:
|
||||
email_body.strip().lstrip("\n")
|
||||
|
||||
# Update the subject to have prefix here versus every email
|
||||
subject_template = get_template(subject_template_name)
|
||||
subject = subject_template.render(context=context)
|
||||
subject = f"{prefix}{subject}"
|
||||
|
||||
try:
|
||||
ses_client = boto3.client(
|
||||
|
|
|
@ -226,6 +226,49 @@ def send_portfolio_invitation_email(email: str, requestor, portfolio, is_admin_i
|
|||
return all_admin_emails_sent
|
||||
|
||||
|
||||
def send_portfolio_member_permission_update_email(requestor, permissions: UserPortfolioPermission):
|
||||
"""
|
||||
Sends an email notification to a portfolio member when their permissions are updated.
|
||||
|
||||
This function retrieves the requestor's email and sends a templated email to the affected user,
|
||||
notifying them of changes to their portfolio permissions.
|
||||
|
||||
Args:
|
||||
requestor (User): The user initiating the permission update.
|
||||
permissions (UserPortfolioPermission): The updated permissions object containing the affected user
|
||||
and the portfolio details.
|
||||
|
||||
Returns:
|
||||
bool: True if the email was sent successfully, False if an EmailSendingError occurred.
|
||||
|
||||
Raises:
|
||||
MissingEmailError: If the requestor has no email associated with their account.
|
||||
"""
|
||||
requestor_email = _get_requestor_email(requestor, portfolio=permissions.portfolio)
|
||||
try:
|
||||
send_templated_email(
|
||||
"emails/portfolio_update.txt",
|
||||
"emails/portfolio_update_subject.txt",
|
||||
to_address=permissions.user.email,
|
||||
context={
|
||||
"requested_user": permissions.user,
|
||||
"portfolio": permissions.portfolio,
|
||||
"requestor_email": requestor_email,
|
||||
"permissions": permissions,
|
||||
"date": date.today(),
|
||||
},
|
||||
)
|
||||
except EmailSendingError:
|
||||
logger.warning(
|
||||
"Could not send email organization member update notification to %s " "for portfolio: %s",
|
||||
permissions.user.email,
|
||||
permissions.portfolio.organization_name,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
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
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue