mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-05-16 17:47:02 +02:00
Merge branch 'main' into za/806-analyst-view-domain-management-data
This commit is contained in:
commit
7e0a4aea76
29 changed files with 803 additions and 95 deletions
|
@ -1,6 +1,6 @@
|
|||
# Get (your very own) .gov
|
||||
# Infrastructure as a (public) service
|
||||
|
||||
Welcome to the repo for a WIP brand new registrar for .gov domains. Get.gov intends to serve all government entities in the United States looking for a .gov domain to use publicly (for a website, for an email address, etc.). Here you can find the code for the registrar and other artifacts about our product strategy and research.
|
||||
The .gov domain helps U.S.-based government organizations gain public trust by being easily recognized online. This repo contains the code for the new .gov registrar – where governments request and manage domains – and other artifacts about our product strategy and research.
|
||||
|
||||
## Onboarding
|
||||
|
||||
|
|
|
@ -20,8 +20,8 @@ applications:
|
|||
DJANGO_BASE_URL: https://getgov-ab.app.cloud.gov
|
||||
# Tell Django how much stuff to log
|
||||
DJANGO_LOG_LEVEL: INFO
|
||||
# Public site base URL
|
||||
GETGOV_PUBLIC_SITE_URL: https://federalist-877ab29f-16f6-4f12-961c-96cf064cf070.sites.pages.cloud.gov/site/cisagov/getgov-home/
|
||||
# default public site location
|
||||
GETGOV_PUBLIC_SITE_URL: https://beta.get.gov
|
||||
routes:
|
||||
- route: getgov-ab.app.cloud.gov
|
||||
services:
|
||||
|
|
|
@ -20,8 +20,8 @@ applications:
|
|||
DJANGO_BASE_URL: https://getgov-bl.app.cloud.gov
|
||||
# Tell Django how much stuff to log
|
||||
DJANGO_LOG_LEVEL: INFO
|
||||
# Public site base URL
|
||||
GETGOV_PUBLIC_SITE_URL: https://federalist-877ab29f-16f6-4f12-961c-96cf064cf070.sites.pages.cloud.gov/site/cisagov/getgov-home/
|
||||
# default public site location
|
||||
GETGOV_PUBLIC_SITE_URL: https://beta.get.gov
|
||||
routes:
|
||||
- route: getgov-bl.app.cloud.gov
|
||||
services:
|
||||
|
|
|
@ -20,8 +20,8 @@ applications:
|
|||
DJANGO_BASE_URL: https://getgov-dk.app.cloud.gov
|
||||
# Tell Django how much stuff to log
|
||||
DJANGO_LOG_LEVEL: INFO
|
||||
# Public site base URL
|
||||
GETGOV_PUBLIC_SITE_URL: https://federalist-877ab29f-16f6-4f12-961c-96cf064cf070.sites.pages.cloud.gov/site/cisagov/getgov-home/
|
||||
# default public site location
|
||||
GETGOV_PUBLIC_SITE_URL: https://beta.get.gov
|
||||
routes:
|
||||
- route: getgov-dk.app.cloud.gov
|
||||
services:
|
||||
|
|
|
@ -20,8 +20,8 @@ applications:
|
|||
DJANGO_BASE_URL: https://getgov-rjm.app.cloud.gov
|
||||
# Tell Django how much stuff to log
|
||||
DJANGO_LOG_LEVEL: INFO
|
||||
# Public site base URL
|
||||
GETGOV_PUBLIC_SITE_URL: https://federalist-877ab29f-16f6-4f12-961c-96cf064cf070.sites.pages.cloud.gov/site/cisagov/getgov-home/
|
||||
# default public site location
|
||||
GETGOV_PUBLIC_SITE_URL: https://beta.get.gov
|
||||
routes:
|
||||
- route: getgov-rjm.app.cloud.gov
|
||||
services:
|
||||
|
|
|
@ -20,8 +20,8 @@ applications:
|
|||
DJANGO_BASE_URL: https://getgov-stable.app.cloud.gov
|
||||
# Tell Django how much stuff to log
|
||||
DJANGO_LOG_LEVEL: INFO
|
||||
# Public site base URL
|
||||
GETGOV_PUBLIC_SITE_URL: https://federalist-877ab29f-16f6-4f12-961c-96cf064cf070.sites.pages.cloud.gov/site/cisagov/getgov-home/
|
||||
# default public site location
|
||||
GETGOV_PUBLIC_SITE_URL: https://beta.get.gov
|
||||
routes:
|
||||
- route: getgov-stable.app.cloud.gov
|
||||
services:
|
||||
|
|
128
ops/scripts/create_environment_migrate.sh
Executable file
128
ops/scripts/create_environment_migrate.sh
Executable file
|
@ -0,0 +1,128 @@
|
|||
# This script sets up a completely new Cloud.gov CF Space with all the corresponding
|
||||
# infrastructure needed to run get.gov. It can serve for documentation for running
|
||||
# NOTE: This script was written for MacOS and to be run at the root directory
|
||||
# of the repository. It uses `docker compose` to compile assets, so it is
|
||||
# safest to `docker compose down` before using this script.
|
||||
|
||||
if [ -z "$1" ]; then
|
||||
echo 'Please specify a name on the command line for the new space (i.e. lmm)' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! $(command -v gh) ] || [ ! $(command -v jq) ] || [ ! $(command -v cf) ]; then
|
||||
echo "jq, cf, and gh packages must be installed. Please install via your preferred manager."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
upcase_name=$(printf "%s" "$1" | tr '[:lower:]' '[:upper:]')
|
||||
|
||||
read -p "Are you on a new branch? We will have to commit this work. (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]
|
||||
then
|
||||
git checkout -b new-environment-$1
|
||||
fi
|
||||
|
||||
cf target -o cisa-dotgov
|
||||
|
||||
read -p "Are you logged in to the cisa-dotgov CF org above? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]
|
||||
then
|
||||
cf login -a https://api.fr.cloud.gov --sso
|
||||
fi
|
||||
|
||||
gh auth status
|
||||
read -p "Are you logged into a Github account with access to cisagov/getgov? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]
|
||||
then
|
||||
gh auth login
|
||||
fi
|
||||
|
||||
echo "Creating manifest for $1..."
|
||||
cp ops/scripts/manifest-sandbox-template-migrate.yaml ops/manifests/manifest-$1.yaml
|
||||
sed -i '' "s/ENVIRONMENT/$1/" "ops/manifests/manifest-$1.yaml"
|
||||
|
||||
echo "Creating new cloud.gov space for $1..."
|
||||
cf create-space $1
|
||||
cf target -o "cisa-dotgov" -s $1
|
||||
cf bind-security-group public_networks_egress cisa-dotgov --space $1
|
||||
cf bind-security-group trusted_local_networks_egress cisa-dotgov --space $1
|
||||
|
||||
echo "Creating new cloud.gov DB for $1. This usually takes about 5 minutes..."
|
||||
cf create-service aws-rds micro-psql getgov-$1-database
|
||||
|
||||
until cf service getgov-$1-database | grep -q 'The service instance status is succeeded'
|
||||
do
|
||||
echo "Database not up yet, waiting..."
|
||||
sleep 30
|
||||
done
|
||||
|
||||
echo "Creating new cloud.gov credentials for $1..."
|
||||
django_key=$(python3 -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())')
|
||||
openssl req -nodes -x509 -days 365 -newkey rsa:2048 -keyout private-$1.pem -out public-$1.crt
|
||||
login_key=$(base64 -i private-$1.pem)
|
||||
jq -n --arg django_key "$django_key" --arg login_key "$login_key" '{"DJANGO_SECRET_KEY":$django_key,"DJANGO_SECRET_LOGIN_KEY":$login_key}' > credentials-$1.json
|
||||
cf cups getgov-credentials -p credentials-$1.json
|
||||
|
||||
echo "Now you will need to update some things for Login. Please sign-in to https://dashboard.int.identitysandbox.gov/."
|
||||
echo "Navigate to our application config: https://dashboard.int.identitysandbox.gov/service_providers/2640/edit?"
|
||||
echo "There are two things to update."
|
||||
echo "1. You need to upload the public-$1.crt file generated as part of the previous command."
|
||||
echo "2. You need to add two redirect URIs: https://getgov-$1.app.cloud.gov/openid/callback/login/ and
|
||||
https://getgov-$1.app.cloud.gov/openid/callback/logout/ to the list of URIs."
|
||||
read -p "Please confirm when this is done (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]
|
||||
then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Database create succeeded and credentials created. Deploying the get.gov application to the new space $1..."
|
||||
echo "Building assets..."
|
||||
open -a Docker
|
||||
cd src/
|
||||
./build.sh
|
||||
cd ..
|
||||
cf push getgov-$1 -f ops/manifests/manifest-$1.yaml
|
||||
|
||||
read -p "Please provide the email of the space developer: " -r
|
||||
cf set-space-role $REPLY cisa-dotgov $1 SpaceDeveloper
|
||||
|
||||
read -p "Should we run migrations? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]
|
||||
then
|
||||
cf run-task getgov-$1 --command 'python manage.py migrate' --name migrate
|
||||
fi
|
||||
|
||||
echo "Alright, your app is up and running at https://getgov-$1.app.cloud.gov!"
|
||||
echo
|
||||
echo "Moving on to setup Github automation..."
|
||||
|
||||
echo "Creating space deployer for Github deploys..."
|
||||
cf create-service cloud-gov-service-account space-deployer github-cd-account
|
||||
cf create-service-key github-cd-account github-cd-key
|
||||
cf service-key github-cd-account github-cd-key
|
||||
read -p "Please confirm we should set the above username and key to Github secrets. (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]
|
||||
then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cf service-key github-cd-account github-cd-key | sed 1,2d | jq -r '[.credentials.username, .credentials.password]|@tsv' |
|
||||
while read -r username password; do
|
||||
gh secret --repo cisagov/getgov set CF_${upcase_name}_USERNAME --body $username
|
||||
gh secret --repo cisagov/getgov set CF_${upcase_name}_PASSWORD --body $password
|
||||
done
|
||||
|
||||
read -p "All done! Should we open a PR with these changes? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]
|
||||
then
|
||||
git add ops/manifests/manifest-$1.yaml .github/workflows/ src/registrar/config/settings.py
|
||||
git commit -m "Add new developer sandbox '"$1"' infrastructure"
|
||||
gh pr create
|
||||
fi
|
30
ops/scripts/manifest-sandbox-template-migrate.yaml
Normal file
30
ops/scripts/manifest-sandbox-template-migrate.yaml
Normal file
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
applications:
|
||||
- name: getgov-ENVIRONMENT
|
||||
buildpacks:
|
||||
- python_buildpack
|
||||
path: ../../src
|
||||
instances: 1
|
||||
memory: 512M
|
||||
stack: cflinuxfs4
|
||||
timeout: 180
|
||||
command: ./run.sh
|
||||
health-check-type: http
|
||||
health-check-http-endpoint: /health
|
||||
env:
|
||||
# Send stdout and stderr straight to the terminal without buffering
|
||||
PYTHONUNBUFFERED: yup
|
||||
# Tell Django where to find its configuration
|
||||
DJANGO_SETTINGS_MODULE: registrar.config.settings
|
||||
# Tell Django where it is being hosted
|
||||
DJANGO_BASE_URL: https://getgov-ENVIRONMENT.app.cloud.gov
|
||||
# Tell Django how much stuff to log
|
||||
DJANGO_LOG_LEVEL: INFO
|
||||
# default public site location
|
||||
GETGOV_PUBLIC_SITE_URL: https://beta.get.gov
|
||||
# use a non-default route to avoid conflicts
|
||||
routes:
|
||||
- route: getgov-ENVIRONMENT-migrate.app.cloud.gov
|
||||
services:
|
||||
- getgov-credentials
|
||||
- getgov-ENVIRONMENT-database
|
36
ops/scripts/migrate_new_old.sh
Executable file
36
ops/scripts/migrate_new_old.sh
Executable file
|
@ -0,0 +1,36 @@
|
|||
# This script sets up a completely new Cloud.gov CF Space with all the corresponding
|
||||
# infrastructure needed to run get.gov. It can serve for documentation for running
|
||||
# NOTE: This script was written for MacOS and to be run at the root directory
|
||||
# of the repository.
|
||||
|
||||
if [ -z "$1" ]; then
|
||||
echo 'Please specify a name on the command line for the new space (i.e. lmm)' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The user running this script has to be a SpaceDeveloper on both the
|
||||
# new and old org/spaces.
|
||||
|
||||
OLD_ORG="cisa-getgov-prototyping"
|
||||
NEW_ORG="cisa-dotgov"
|
||||
|
||||
#
|
||||
# delete old route
|
||||
cf target -o $OLD_ORG -s $1
|
||||
cf delete-route app.cloud.gov -n getgov-$1
|
||||
|
||||
# re-claim the route on new orf
|
||||
cf target -o $NEW_ORG -s $1
|
||||
cf map-route getgov-$1 app.cloud.gov -n getgov-$1
|
||||
cf delete-route app.cloud.gov -n getgov-$1-migrate
|
||||
|
||||
# delete old app and services
|
||||
cf target -o $OLD_ORG -s $1
|
||||
cf delete getgov-$1
|
||||
cf delete-service getgov-$1-database
|
||||
cf delete-service getgov-credentials
|
||||
cf delete-service getgov-cd-account
|
||||
cf delete-space $1
|
||||
|
||||
|
||||
printf "Remove -migrate from ops/manifests/manifest-$1.yaml"
|
|
@ -103,6 +103,36 @@ class MyUserAdmin(BaseUserAdmin):
|
|||
|
||||
inlines = [UserContactInline]
|
||||
|
||||
list_display = (
|
||||
"email",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"status",
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{"fields": ("username", "password", "status")},
|
||||
),
|
||||
("Personal Info", {"fields": ("first_name", "last_name", "email")}),
|
||||
(
|
||||
"Permissions",
|
||||
{
|
||||
"fields": (
|
||||
"is_active",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"groups",
|
||||
"user_permissions",
|
||||
)
|
||||
},
|
||||
),
|
||||
("Important dates", {"fields": ("last_login", "date_joined")}),
|
||||
)
|
||||
|
||||
def get_list_display(self, request):
|
||||
if not request.user.is_superuser:
|
||||
# Customize the list display for staff users
|
||||
|
@ -198,6 +228,10 @@ class DomainApplicationAdmin(ListHeaderAdmin):
|
|||
|
||||
"""Customize the applications listing view."""
|
||||
|
||||
# Set multi-selects 'read-only' (hide selects and show data)
|
||||
# based on user perms and application creator's status
|
||||
# form = DomainApplicationForm
|
||||
|
||||
# Columns
|
||||
list_display = [
|
||||
"requested_domain",
|
||||
|
@ -271,7 +305,7 @@ class DomainApplicationAdmin(ListHeaderAdmin):
|
|||
]
|
||||
|
||||
# Read only that we'll leverage for CISA Analysts
|
||||
readonly_fields = [
|
||||
analyst_readonly_fields = [
|
||||
"creator",
|
||||
"type_of_work",
|
||||
"more_organization_information",
|
||||
|
@ -289,49 +323,81 @@ class DomainApplicationAdmin(ListHeaderAdmin):
|
|||
|
||||
# Trigger action when a fieldset is changed
|
||||
def save_model(self, request, obj, form, change):
|
||||
if change: # Check if the application is being edited
|
||||
# Get the original application from the database
|
||||
original_obj = models.DomainApplication.objects.get(pk=obj.pk)
|
||||
if obj and obj.creator.status != models.User.RESTRICTED:
|
||||
if change: # Check if the application is being edited
|
||||
# Get the original application from the database
|
||||
original_obj = models.DomainApplication.objects.get(pk=obj.pk)
|
||||
|
||||
if obj.status != original_obj.status:
|
||||
if obj.status == models.DomainApplication.STARTED:
|
||||
# No conditions
|
||||
pass
|
||||
elif obj.status == models.DomainApplication.SUBMITTED:
|
||||
# This is an fsm in model which will throw an error if the
|
||||
# transition condition is violated, so we roll back the
|
||||
# status to what it was before the admin user changed it and
|
||||
# let the fsm method set it. Same comment applies to
|
||||
# transition method calls below.
|
||||
obj.status = original_obj.status
|
||||
obj.submit()
|
||||
elif obj.status == models.DomainApplication.IN_REVIEW:
|
||||
obj.status = original_obj.status
|
||||
obj.in_review()
|
||||
elif obj.status == models.DomainApplication.ACTION_NEEDED:
|
||||
obj.status = original_obj.status
|
||||
obj.action_needed()
|
||||
elif obj.status == models.DomainApplication.APPROVED:
|
||||
obj.status = original_obj.status
|
||||
obj.approve()
|
||||
elif obj.status == models.DomainApplication.WITHDRAWN:
|
||||
obj.status = original_obj.status
|
||||
obj.withdraw()
|
||||
elif obj.status == models.DomainApplication.REJECTED:
|
||||
obj.status = original_obj.status
|
||||
obj.reject()
|
||||
else:
|
||||
logger.warning("Unknown status selected in django admin")
|
||||
if obj.status != original_obj.status:
|
||||
status_method_mapping = {
|
||||
models.DomainApplication.STARTED: None,
|
||||
models.DomainApplication.SUBMITTED: obj.submit,
|
||||
models.DomainApplication.IN_REVIEW: obj.in_review,
|
||||
models.DomainApplication.ACTION_NEEDED: obj.action_needed,
|
||||
models.DomainApplication.APPROVED: obj.approve,
|
||||
models.DomainApplication.WITHDRAWN: obj.withdraw,
|
||||
models.DomainApplication.REJECTED: obj.reject,
|
||||
models.DomainApplication.INELIGIBLE: obj.reject_with_prejudice,
|
||||
}
|
||||
selected_method = status_method_mapping.get(obj.status)
|
||||
if selected_method is None:
|
||||
logger.warning("Unknown status selected in django admin")
|
||||
else:
|
||||
# This is an fsm in model which will throw an error if the
|
||||
# transition condition is violated, so we roll back the
|
||||
# status to what it was before the admin user changed it and
|
||||
# let the fsm method set it.
|
||||
obj.status = original_obj.status
|
||||
selected_method()
|
||||
|
||||
super().save_model(request, obj, form, change)
|
||||
super().save_model(request, obj, form, change)
|
||||
else:
|
||||
# Clear the success message
|
||||
messages.set_level(request, messages.ERROR)
|
||||
|
||||
messages.error(
|
||||
request,
|
||||
"This action is not permitted for applications "
|
||||
+ "with a restricted creator.",
|
||||
)
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
"""Set the read-only state on form elements.
|
||||
We have 2 conditions that determine which fields are read-only:
|
||||
admin user permissions and the application creator's status, so
|
||||
we'll use the baseline readonly_fields and extend it as needed.
|
||||
"""
|
||||
|
||||
readonly_fields = list(self.readonly_fields)
|
||||
|
||||
# Check if the creator is restricted
|
||||
if obj and obj.creator.status == models.User.RESTRICTED:
|
||||
# For fields like CharField, IntegerField, etc., the widget used is
|
||||
# straightforward and the readonly_fields list can control their behavior
|
||||
readonly_fields.extend([field.name for field in self.model._meta.fields])
|
||||
# Add the multi-select fields to readonly_fields:
|
||||
# Complex fields like ManyToManyField require special handling
|
||||
readonly_fields.extend(
|
||||
["current_websites", "other_contacts", "alternative_domains"]
|
||||
)
|
||||
|
||||
if request.user.is_superuser:
|
||||
# Superusers have full access, no fields are read-only
|
||||
return []
|
||||
return readonly_fields
|
||||
else:
|
||||
# Regular users can only view the specified fields
|
||||
return self.readonly_fields
|
||||
readonly_fields.extend([field for field in self.analyst_readonly_fields])
|
||||
return readonly_fields
|
||||
|
||||
def display_restricted_warning(self, request, obj):
|
||||
if obj and obj.creator.status == models.User.RESTRICTED:
|
||||
messages.warning(
|
||||
request,
|
||||
"Cannot edit an application with a restricted creator.",
|
||||
)
|
||||
|
||||
def change_view(self, request, object_id, form_url="", extra_context=None):
|
||||
obj = self.get_object(request, object_id)
|
||||
self.display_restricted_warning(request, obj)
|
||||
return super().change_view(request, object_id, form_url, extra_context)
|
||||
|
||||
|
||||
admin.site.register(models.User, MyUserAdmin)
|
||||
|
|
|
@ -251,8 +251,7 @@ AWS_MAX_ATTEMPTS = 3
|
|||
BOTO_CONFIG = Config(retries={"mode": AWS_RETRY_MODE, "max_attempts": AWS_MAX_ATTEMPTS})
|
||||
|
||||
# email address to use for various automated correspondence
|
||||
# TODO: pick something sensible here
|
||||
DEFAULT_FROM_EMAIL = "registrar@get.gov"
|
||||
DEFAULT_FROM_EMAIL = "help@get.gov <help@get.gov>"
|
||||
|
||||
# connect to an (external) SMTP server for sending email
|
||||
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
||||
|
|
|
@ -72,6 +72,11 @@ class UserFixture:
|
|||
"first_name": "Rebecca",
|
||||
"last_name": "Hsieh",
|
||||
},
|
||||
{
|
||||
"username": "fa69c8e8-da83-4798-a4f2-263c9ce93f52",
|
||||
"first_name": "David",
|
||||
"last_name": "Kennedy",
|
||||
},
|
||||
]
|
||||
|
||||
STAFF = [
|
||||
|
@ -101,6 +106,11 @@ class UserFixture:
|
|||
"first_name": "Rebecca-Analyst",
|
||||
"last_name": "Hsieh-Analyst",
|
||||
},
|
||||
{
|
||||
"username": "5dc6c9a6-61d9-42b4-ba54-4beff28bac3c",
|
||||
"first_name": "David-Analyst",
|
||||
"last_name": "Kennedy-Analyst",
|
||||
},
|
||||
]
|
||||
|
||||
STAFF_PERMISSIONS = [
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
# Generated by Django 4.2.1 on 2023-08-18 16:59
|
||||
|
||||
from django.db import migrations, models
|
||||
import django_fsm
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("registrar", "0028_alter_domainapplication_status"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[("ineligible", "ineligible")],
|
||||
default=None,
|
||||
max_length=10,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="domainapplication",
|
||||
name="status",
|
||||
field=django_fsm.FSMField(
|
||||
choices=[
|
||||
("started", "started"),
|
||||
("submitted", "submitted"),
|
||||
("in review", "in review"),
|
||||
("action needed", "action needed"),
|
||||
("approved", "approved"),
|
||||
("withdrawn", "withdrawn"),
|
||||
("rejected", "rejected"),
|
||||
("ineligible", "ineligible"),
|
||||
],
|
||||
default="started",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
]
|
23
src/registrar/migrations/0030_alter_user_status.py
Normal file
23
src/registrar/migrations/0030_alter_user_status.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 4.2.1 on 2023-08-29 17:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("registrar", "0029_user_status_alter_domainapplication_status"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[("restricted", "restricted")],
|
||||
default=None,
|
||||
max_length=10,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -26,6 +26,7 @@ class DomainApplication(TimeStampedModel):
|
|||
APPROVED = "approved"
|
||||
WITHDRAWN = "withdrawn"
|
||||
REJECTED = "rejected"
|
||||
INELIGIBLE = "ineligible"
|
||||
STATUS_CHOICES = [
|
||||
(STARTED, STARTED),
|
||||
(SUBMITTED, SUBMITTED),
|
||||
|
@ -34,6 +35,7 @@ class DomainApplication(TimeStampedModel):
|
|||
(APPROVED, APPROVED),
|
||||
(WITHDRAWN, WITHDRAWN),
|
||||
(REJECTED, REJECTED),
|
||||
(INELIGIBLE, INELIGIBLE),
|
||||
]
|
||||
|
||||
class StateTerritoryChoices(models.TextChoices):
|
||||
|
@ -554,7 +556,9 @@ class DomainApplication(TimeStampedModel):
|
|||
)
|
||||
|
||||
@transition(
|
||||
field="status", source=[SUBMITTED, IN_REVIEW, REJECTED], target=APPROVED
|
||||
field="status",
|
||||
source=[SUBMITTED, IN_REVIEW, REJECTED, INELIGIBLE],
|
||||
target=APPROVED,
|
||||
)
|
||||
def approve(self):
|
||||
"""Approve an application that has been submitted.
|
||||
|
@ -608,6 +612,17 @@ class DomainApplication(TimeStampedModel):
|
|||
"emails/status_change_rejected_subject.txt",
|
||||
)
|
||||
|
||||
@transition(field="status", source=[IN_REVIEW, APPROVED], target=INELIGIBLE)
|
||||
def reject_with_prejudice(self):
|
||||
"""The applicant is a bad actor, reject with prejudice.
|
||||
|
||||
No email As a side effect, but we block the applicant from editing
|
||||
any existing domains/applications and from submitting new aplications.
|
||||
We do this by setting an ineligible status on the user, which the
|
||||
permissions classes test against"""
|
||||
|
||||
self.creator.restrict_user()
|
||||
|
||||
# ## Form policies ###
|
||||
#
|
||||
# These methods control what questions need to be answered by applicants
|
||||
|
|
|
@ -17,6 +17,18 @@ class User(AbstractUser):
|
|||
but can be customized later.
|
||||
"""
|
||||
|
||||
# #### Constants for choice fields ####
|
||||
RESTRICTED = "restricted"
|
||||
STATUS_CHOICES = ((RESTRICTED, RESTRICTED),)
|
||||
|
||||
status = models.CharField(
|
||||
max_length=10,
|
||||
choices=STATUS_CHOICES,
|
||||
default=None, # Set the default value to None
|
||||
null=True, # Allow the field to be null
|
||||
blank=True, # Allow the field to be blank
|
||||
)
|
||||
|
||||
domains = models.ManyToManyField(
|
||||
"registrar.Domain",
|
||||
through="registrar.UserDomainRole",
|
||||
|
@ -39,6 +51,17 @@ class User(AbstractUser):
|
|||
else:
|
||||
return self.username
|
||||
|
||||
def restrict_user(self):
|
||||
self.status = self.RESTRICTED
|
||||
self.save()
|
||||
|
||||
def unrestrict_user(self):
|
||||
self.status = None
|
||||
self.save()
|
||||
|
||||
def is_restricted(self):
|
||||
return self.status == self.RESTRICTED
|
||||
|
||||
def first_login(self):
|
||||
"""Callback when the user is authenticated for the very first time.
|
||||
|
||||
|
|
|
@ -51,7 +51,7 @@
|
|||
|
||||
{% with attr_aria_describedby="domain_instructions domain_instructions2" %}
|
||||
{# attr_validate / validate="domain" invokes code in get-gov.js #}
|
||||
{% with www_gov=True attr_validate="domain" add_label_class="usa-sr-only" %}
|
||||
{% with append_gov=True attr_validate="domain" add_label_class="usa-sr-only" %}
|
||||
{% input_with_errors forms.0.requested_domain %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
|
@ -75,7 +75,7 @@
|
|||
{% with attr_aria_describedby="alt_domain_instructions" %}
|
||||
{# attr_validate / validate="domain" invokes code in get-gov.js #}
|
||||
{# attr_auto_validate likewise triggers behavior in get-gov.js #}
|
||||
{% with www_gov=True attr_validate="domain" attr_auto_validate=True %}
|
||||
{% with append_gov=True attr_validate="domain" attr_auto_validate=True %}
|
||||
{% for form in forms.1 %}
|
||||
{% input_with_errors form.alternative_domain %}
|
||||
{% endfor %}
|
||||
|
|
|
@ -23,6 +23,7 @@
|
|||
{% elif domainapplication.status == 'in review' %} In Review
|
||||
{% elif domainapplication.status == 'rejected' %} Rejected
|
||||
{% elif domainapplication.status == 'submitted' %} Submitted
|
||||
{% elif domainapplication.status == 'ineligible' %} Ineligible
|
||||
{% else %}ERROR Please contact technical support/dev
|
||||
{% endif %}
|
||||
</p>
|
||||
|
|
|
@ -149,12 +149,12 @@
|
|||
<button type="button" class="usa-nav__close">
|
||||
<img src="/public/img/usa-icons/close.svg" role="img" alt="Close" />
|
||||
</button>
|
||||
<ul class="usa-nav__primary usa-accordion">
|
||||
<ul class="usa-nav__primary usa-accordion display-flex flex-align-center">
|
||||
<li class="usa-nav__primary-item">
|
||||
{% if user.is_authenticated %}
|
||||
<span>{{ user.email }}</span>
|
||||
</li>
|
||||
<li class="usa-nav__primary-item display-flex flex-align-center">
|
||||
<li class="usa-nav__primary-item display-flex flex-align-center margin-left-2">
|
||||
<span class="text-base"> | </span>
|
||||
<a href="{% url 'logout' %}"><span class="text-primary">Sign out</span></a>
|
||||
</li>
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
</p>
|
||||
|
||||
<section class="section--outlined tablet:grid-col-11 desktop:grid-col-10">
|
||||
<h2>Registered domains</h2>
|
||||
<h2>Domains</h2>
|
||||
{% if domains %}
|
||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked">
|
||||
<caption class="sr-only">Your registered domains</caption>
|
||||
|
@ -69,7 +69,7 @@
|
|||
</section>
|
||||
|
||||
<section class="section--outlined tablet:grid-col-11 desktop:grid-col-10">
|
||||
<h2>Active domain requests</h2>
|
||||
<h2>Domain requests</h2>
|
||||
{% if domain_applications %}
|
||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked">
|
||||
<caption class="sr-only">Your domain applications</caption>
|
||||
|
|
|
@ -55,15 +55,13 @@ error messages, if necessary.
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if www_gov %}
|
||||
{% if append_gov %}
|
||||
<div class="display-flex flex-align-center">
|
||||
<span class="padding-top-05 padding-right-2px">www.</span>
|
||||
{% endif %}
|
||||
|
||||
{# this is the input field, itself #}
|
||||
{% include widget.template_name %}
|
||||
|
||||
{% if www_gov %}
|
||||
{% if append_gov %}
|
||||
<span class="padding-top-05 padding-left-2px">.gov </span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
|
@ -26,6 +26,8 @@ from .common import (
|
|||
)
|
||||
from django.contrib.sessions.backends.db import SessionStore
|
||||
from django.contrib.auth import get_user_model
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.conf import settings
|
||||
from unittest.mock import MagicMock
|
||||
import boto3_mocking # type: ignore
|
||||
|
@ -38,6 +40,11 @@ class TestDomainApplicationAdmin(TestCase):
|
|||
def setUp(self):
|
||||
self.site = AdminSite()
|
||||
self.factory = RequestFactory()
|
||||
self.admin = DomainApplicationAdmin(
|
||||
model=DomainApplication, admin_site=self.site
|
||||
)
|
||||
self.superuser = create_superuser()
|
||||
self.staffuser = create_user()
|
||||
|
||||
@boto3_mocking.patching
|
||||
def test_save_model_sends_submitted_email(self):
|
||||
|
@ -57,14 +64,11 @@ class TestDomainApplicationAdmin(TestCase):
|
|||
"/admin/registrar/domainapplication/{}/change/".format(application.pk)
|
||||
)
|
||||
|
||||
# Create an instance of the model admin
|
||||
model_admin = DomainApplicationAdmin(DomainApplication, self.site)
|
||||
|
||||
# Modify the application's property
|
||||
application.status = DomainApplication.SUBMITTED
|
||||
|
||||
# Use the model admin's save_model method
|
||||
model_admin.save_model(request, application, form=None, change=True)
|
||||
self.admin.save_model(request, application, form=None, change=True)
|
||||
|
||||
# Access the arguments passed to send_email
|
||||
call_args = mock_client_instance.send_email.call_args
|
||||
|
@ -103,14 +107,11 @@ class TestDomainApplicationAdmin(TestCase):
|
|||
"/admin/registrar/domainapplication/{}/change/".format(application.pk)
|
||||
)
|
||||
|
||||
# Create an instance of the model admin
|
||||
model_admin = DomainApplicationAdmin(DomainApplication, self.site)
|
||||
|
||||
# Modify the application's property
|
||||
application.status = DomainApplication.IN_REVIEW
|
||||
|
||||
# Use the model admin's save_model method
|
||||
model_admin.save_model(request, application, form=None, change=True)
|
||||
self.admin.save_model(request, application, form=None, change=True)
|
||||
|
||||
# Access the arguments passed to send_email
|
||||
call_args = mock_client_instance.send_email.call_args
|
||||
|
@ -149,14 +150,11 @@ class TestDomainApplicationAdmin(TestCase):
|
|||
"/admin/registrar/domainapplication/{}/change/".format(application.pk)
|
||||
)
|
||||
|
||||
# Create an instance of the model admin
|
||||
model_admin = DomainApplicationAdmin(DomainApplication, self.site)
|
||||
|
||||
# Modify the application's property
|
||||
application.status = DomainApplication.APPROVED
|
||||
|
||||
# Use the model admin's save_model method
|
||||
model_admin.save_model(request, application, form=None, change=True)
|
||||
self.admin.save_model(request, application, form=None, change=True)
|
||||
|
||||
# Access the arguments passed to send_email
|
||||
call_args = mock_client_instance.send_email.call_args
|
||||
|
@ -190,14 +188,11 @@ class TestDomainApplicationAdmin(TestCase):
|
|||
"/admin/registrar/domainapplication/{}/change/".format(application.pk)
|
||||
)
|
||||
|
||||
# Create an instance of the model admin
|
||||
model_admin = DomainApplicationAdmin(DomainApplication, self.site)
|
||||
|
||||
# Modify the application's property
|
||||
application.status = DomainApplication.APPROVED
|
||||
|
||||
# Use the model admin's save_model method
|
||||
model_admin.save_model(request, application, form=None, change=True)
|
||||
self.admin.save_model(request, application, form=None, change=True)
|
||||
|
||||
# Test that approved domain exists and equals requested domain
|
||||
self.assertEqual(
|
||||
|
@ -222,14 +217,11 @@ class TestDomainApplicationAdmin(TestCase):
|
|||
"/admin/registrar/domainapplication/{}/change/".format(application.pk)
|
||||
)
|
||||
|
||||
# Create an instance of the model admin
|
||||
model_admin = DomainApplicationAdmin(DomainApplication, self.site)
|
||||
|
||||
# Modify the application's property
|
||||
application.status = DomainApplication.ACTION_NEEDED
|
||||
|
||||
# Use the model admin's save_model method
|
||||
model_admin.save_model(request, application, form=None, change=True)
|
||||
self.admin.save_model(request, application, form=None, change=True)
|
||||
|
||||
# Access the arguments passed to send_email
|
||||
call_args = mock_client_instance.send_email.call_args
|
||||
|
@ -271,14 +263,11 @@ class TestDomainApplicationAdmin(TestCase):
|
|||
"/admin/registrar/domainapplication/{}/change/".format(application.pk)
|
||||
)
|
||||
|
||||
# Create an instance of the model admin
|
||||
model_admin = DomainApplicationAdmin(DomainApplication, self.site)
|
||||
|
||||
# Modify the application's property
|
||||
application.status = DomainApplication.REJECTED
|
||||
|
||||
# Use the model admin's save_model method
|
||||
model_admin.save_model(request, application, form=None, change=True)
|
||||
self.admin.save_model(request, application, form=None, change=True)
|
||||
|
||||
# Access the arguments passed to send_email
|
||||
call_args = mock_client_instance.send_email.call_args
|
||||
|
@ -299,6 +288,155 @@ class TestDomainApplicationAdmin(TestCase):
|
|||
# Perform assertions on the mock call itself
|
||||
mock_client_instance.send_email.assert_called_once()
|
||||
|
||||
def test_save_model_sets_restricted_status_on_user(self):
|
||||
# make sure there is no user with this email
|
||||
EMAIL = "mayor@igorville.gov"
|
||||
User.objects.filter(email=EMAIL).delete()
|
||||
|
||||
# Create a sample application
|
||||
application = completed_application(status=DomainApplication.IN_REVIEW)
|
||||
|
||||
# Create a mock request
|
||||
request = self.factory.post(
|
||||
"/admin/registrar/domainapplication/{}/change/".format(application.pk)
|
||||
)
|
||||
|
||||
# Modify the application's property
|
||||
application.status = DomainApplication.INELIGIBLE
|
||||
|
||||
# Use the model admin's save_model method
|
||||
self.admin.save_model(request, application, form=None, change=True)
|
||||
|
||||
# Test that approved domain exists and equals requested domain
|
||||
self.assertEqual(application.creator.status, "restricted")
|
||||
|
||||
def test_readonly_when_restricted_creator(self):
|
||||
application = completed_application(status=DomainApplication.IN_REVIEW)
|
||||
application.creator.status = User.RESTRICTED
|
||||
application.creator.save()
|
||||
|
||||
request = self.factory.get("/")
|
||||
request.user = self.superuser
|
||||
|
||||
readonly_fields = self.admin.get_readonly_fields(request, application)
|
||||
|
||||
expected_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"status",
|
||||
"creator",
|
||||
"investigator",
|
||||
"organization_type",
|
||||
"federally_recognized_tribe",
|
||||
"state_recognized_tribe",
|
||||
"tribe_name",
|
||||
"federal_agency",
|
||||
"federal_type",
|
||||
"is_election_board",
|
||||
"organization_name",
|
||||
"address_line1",
|
||||
"address_line2",
|
||||
"city",
|
||||
"state_territory",
|
||||
"zipcode",
|
||||
"urbanization",
|
||||
"type_of_work",
|
||||
"more_organization_information",
|
||||
"authorizing_official",
|
||||
"approved_domain",
|
||||
"requested_domain",
|
||||
"submitter",
|
||||
"purpose",
|
||||
"no_other_contacts_rationale",
|
||||
"anything_else",
|
||||
"is_policy_acknowledged",
|
||||
"current_websites",
|
||||
"other_contacts",
|
||||
"alternative_domains",
|
||||
]
|
||||
|
||||
self.assertEqual(readonly_fields, expected_fields)
|
||||
|
||||
def test_readonly_fields_for_analyst(self):
|
||||
request = self.factory.get("/") # Use the correct method and path
|
||||
request.user = self.staffuser
|
||||
|
||||
readonly_fields = self.admin.get_readonly_fields(request)
|
||||
|
||||
expected_fields = [
|
||||
"creator",
|
||||
"type_of_work",
|
||||
"more_organization_information",
|
||||
"address_line1",
|
||||
"address_line2",
|
||||
"zipcode",
|
||||
"requested_domain",
|
||||
"alternative_domains",
|
||||
"purpose",
|
||||
"submitter",
|
||||
"no_other_contacts_rationale",
|
||||
"anything_else",
|
||||
"is_policy_acknowledged",
|
||||
]
|
||||
|
||||
self.assertEqual(readonly_fields, expected_fields)
|
||||
|
||||
def test_readonly_fields_for_superuser(self):
|
||||
request = self.factory.get("/") # Use the correct method and path
|
||||
request.user = self.superuser
|
||||
|
||||
readonly_fields = self.admin.get_readonly_fields(request)
|
||||
|
||||
expected_fields = []
|
||||
|
||||
self.assertEqual(readonly_fields, expected_fields)
|
||||
|
||||
def test_saving_when_restricted_creator(self):
|
||||
# Create an instance of the model
|
||||
application = completed_application(status=DomainApplication.IN_REVIEW)
|
||||
application.creator.status = User.RESTRICTED
|
||||
application.creator.save()
|
||||
|
||||
# Create a request object with a superuser
|
||||
request = self.factory.get("/")
|
||||
request.user = self.superuser
|
||||
|
||||
with patch("django.contrib.messages.error") as mock_error:
|
||||
# Simulate saving the model
|
||||
self.admin.save_model(request, application, None, False)
|
||||
|
||||
# Assert that the error message was called with the correct argument
|
||||
mock_error.assert_called_once_with(
|
||||
request,
|
||||
"This action is not permitted for applications "
|
||||
+ "with a restricted creator.",
|
||||
)
|
||||
|
||||
# Assert that the status has not changed
|
||||
self.assertEqual(application.status, DomainApplication.IN_REVIEW)
|
||||
|
||||
def test_change_view_with_restricted_creator(self):
|
||||
# Create an instance of the model
|
||||
application = completed_application(status=DomainApplication.IN_REVIEW)
|
||||
application.creator.status = User.RESTRICTED
|
||||
application.creator.save()
|
||||
|
||||
with patch("django.contrib.messages.warning") as mock_warning:
|
||||
# Create a request object with a superuser
|
||||
request = self.factory.get(
|
||||
"/admin/your_app/domainapplication/{}/change/".format(application.pk)
|
||||
)
|
||||
request.user = self.superuser
|
||||
|
||||
self.admin.display_restricted_warning(request, application)
|
||||
|
||||
# Assert that the error message was called with the correct argument
|
||||
mock_warning.assert_called_once_with(
|
||||
request,
|
||||
"Cannot edit an application with a restricted creator.",
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
DomainInformation.objects.all().delete()
|
||||
DomainApplication.objects.all().delete()
|
||||
|
@ -378,7 +516,6 @@ class ListHeaderAdminTest(TestCase):
|
|||
DomainInformation.objects.all().delete()
|
||||
DomainApplication.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
self.superuser.delete()
|
||||
|
||||
|
||||
class MyUserAdminTest(TestCase):
|
||||
|
|
|
@ -171,6 +171,15 @@ class TestDomainApplication(TestCase):
|
|||
with self.assertRaises(TransitionNotAllowed):
|
||||
application.submit()
|
||||
|
||||
def test_transition_not_allowed_ineligible_submitted(self):
|
||||
"""Create an application with status ineligible and call submit
|
||||
against transition rules"""
|
||||
|
||||
application = completed_application(status=DomainApplication.INELIGIBLE)
|
||||
|
||||
with self.assertRaises(TransitionNotAllowed):
|
||||
application.submit()
|
||||
|
||||
def test_transition_not_allowed_started_in_review(self):
|
||||
"""Create an application with status started and call in_review
|
||||
against transition rules"""
|
||||
|
@ -225,6 +234,15 @@ class TestDomainApplication(TestCase):
|
|||
with self.assertRaises(TransitionNotAllowed):
|
||||
application.in_review()
|
||||
|
||||
def test_transition_not_allowed_ineligible_in_review(self):
|
||||
"""Create an application with status ineligible and call in_review
|
||||
against transition rules"""
|
||||
|
||||
application = completed_application(status=DomainApplication.INELIGIBLE)
|
||||
|
||||
with self.assertRaises(TransitionNotAllowed):
|
||||
application.in_review()
|
||||
|
||||
def test_transition_not_allowed_started_action_needed(self):
|
||||
"""Create an application with status started and call action_needed
|
||||
against transition rules"""
|
||||
|
@ -270,6 +288,15 @@ class TestDomainApplication(TestCase):
|
|||
with self.assertRaises(TransitionNotAllowed):
|
||||
application.action_needed()
|
||||
|
||||
def test_transition_not_allowed_ineligible_action_needed(self):
|
||||
"""Create an application with status ineligible and call action_needed
|
||||
against transition rules"""
|
||||
|
||||
application = completed_application(status=DomainApplication.INELIGIBLE)
|
||||
|
||||
with self.assertRaises(TransitionNotAllowed):
|
||||
application.action_needed()
|
||||
|
||||
def test_transition_not_allowed_started_approved(self):
|
||||
"""Create an application with status started and call approve
|
||||
against transition rules"""
|
||||
|
@ -351,6 +378,15 @@ class TestDomainApplication(TestCase):
|
|||
with self.assertRaises(TransitionNotAllowed):
|
||||
application.withdraw()
|
||||
|
||||
def test_transition_not_allowed_ineligible_withdrawn(self):
|
||||
"""Create an application with status ineligible and call withdraw
|
||||
against transition rules"""
|
||||
|
||||
application = completed_application(status=DomainApplication.INELIGIBLE)
|
||||
|
||||
with self.assertRaises(TransitionNotAllowed):
|
||||
application.withdraw()
|
||||
|
||||
def test_transition_not_allowed_started_rejected(self):
|
||||
"""Create an application with status started and call reject
|
||||
against transition rules"""
|
||||
|
@ -396,6 +432,69 @@ class TestDomainApplication(TestCase):
|
|||
with self.assertRaises(TransitionNotAllowed):
|
||||
application.reject()
|
||||
|
||||
def test_transition_not_allowed_ineligible_rejected(self):
|
||||
"""Create an application with status ineligible and call reject
|
||||
against transition rules"""
|
||||
|
||||
application = completed_application(status=DomainApplication.INELIGIBLE)
|
||||
|
||||
with self.assertRaises(TransitionNotAllowed):
|
||||
application.reject_with_prejudice()
|
||||
|
||||
def test_transition_not_allowed_started_ineligible(self):
|
||||
"""Create an application with status started and call reject
|
||||
against transition rules"""
|
||||
|
||||
application = completed_application(status=DomainApplication.STARTED)
|
||||
|
||||
with self.assertRaises(TransitionNotAllowed):
|
||||
application.reject_with_prejudice()
|
||||
|
||||
def test_transition_not_allowed_submitted_ineligible(self):
|
||||
"""Create an application with status submitted and call reject
|
||||
against transition rules"""
|
||||
|
||||
application = completed_application(status=DomainApplication.SUBMITTED)
|
||||
|
||||
with self.assertRaises(TransitionNotAllowed):
|
||||
application.reject_with_prejudice()
|
||||
|
||||
def test_transition_not_allowed_action_needed_ineligible(self):
|
||||
"""Create an application with status action needed and call reject
|
||||
against transition rules"""
|
||||
|
||||
application = completed_application(status=DomainApplication.ACTION_NEEDED)
|
||||
|
||||
with self.assertRaises(TransitionNotAllowed):
|
||||
application.reject_with_prejudice()
|
||||
|
||||
def test_transition_not_allowed_withdrawn_ineligible(self):
|
||||
"""Create an application with status withdrawn and call reject
|
||||
against transition rules"""
|
||||
|
||||
application = completed_application(status=DomainApplication.WITHDRAWN)
|
||||
|
||||
with self.assertRaises(TransitionNotAllowed):
|
||||
application.reject_with_prejudice()
|
||||
|
||||
def test_transition_not_allowed_rejected_ineligible(self):
|
||||
"""Create an application with status rejected and call reject
|
||||
against transition rules"""
|
||||
|
||||
application = completed_application(status=DomainApplication.REJECTED)
|
||||
|
||||
with self.assertRaises(TransitionNotAllowed):
|
||||
application.reject_with_prejudice()
|
||||
|
||||
def test_transition_not_allowed_ineligible_ineligible(self):
|
||||
"""Create an application with status ineligible and call reject
|
||||
against transition rules"""
|
||||
|
||||
application = completed_application(status=DomainApplication.INELIGIBLE)
|
||||
|
||||
with self.assertRaises(TransitionNotAllowed):
|
||||
application.reject_with_prejudice()
|
||||
|
||||
|
||||
class TestPermissions(TestCase):
|
||||
|
||||
|
|
|
@ -101,6 +101,18 @@ class LoggedInTests(TestWithUser):
|
|||
"What kind of U.S.-based government organization do you represent?",
|
||||
)
|
||||
|
||||
def test_domain_application_form_with_ineligible_user(self):
|
||||
"""Application form not accessible for an ineligible user.
|
||||
This test should be solid enough since all application wizard
|
||||
views share the same permissions class"""
|
||||
self.user.status = User.RESTRICTED
|
||||
self.user.save()
|
||||
|
||||
with less_console_noise():
|
||||
response = self.client.get("/register/", follow=True)
|
||||
print(response.status_code)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
|
||||
class DomainApplicationTests(TestWithUser, WebTest):
|
||||
|
||||
|
@ -1423,6 +1435,18 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest):
|
|||
success_page, "The security email for this domain have been updated"
|
||||
)
|
||||
|
||||
def test_domain_overview_blocked_for_ineligible_user(self):
|
||||
"""We could easily duplicate this test for all domain management
|
||||
views, but a single url test should be solid enough since all domain
|
||||
management pages share the same permissions class"""
|
||||
self.user.status = User.RESTRICTED
|
||||
self.user.save()
|
||||
home_page = self.app.get("/")
|
||||
self.assertContains(home_page, "igorville.gov")
|
||||
with less_console_noise():
|
||||
response = self.client.get(reverse("domain", kwargs={"pk": self.domain.id}))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
|
||||
class TestApplicationStatus(TestWithUser, WebTest):
|
||||
def setUp(self):
|
||||
|
@ -1447,6 +1471,27 @@ class TestApplicationStatus(TestWithUser, WebTest):
|
|||
self.assertContains(detail_page, "Admin Tester")
|
||||
self.assertContains(detail_page, "Status:")
|
||||
|
||||
def test_application_status_with_ineligible_user(self):
|
||||
"""Checking application status page whith a blocked user.
|
||||
The user should still have access to view."""
|
||||
self.user.status = "ineligible"
|
||||
self.user.save()
|
||||
|
||||
application = completed_application(
|
||||
status=DomainApplication.SUBMITTED, user=self.user
|
||||
)
|
||||
application.save()
|
||||
|
||||
home_page = self.app.get("/")
|
||||
self.assertContains(home_page, "city.gov")
|
||||
# click the "Manage" link
|
||||
detail_page = home_page.click("Manage")
|
||||
self.assertContains(detail_page, "city.gov")
|
||||
self.assertContains(detail_page, "Chief Tester")
|
||||
self.assertContains(detail_page, "testy@town.com")
|
||||
self.assertContains(detail_page, "Admin Tester")
|
||||
self.assertContains(detail_page, "Status:")
|
||||
|
||||
def test_application_withdraw(self):
|
||||
"""Checking application status page"""
|
||||
application = completed_application(
|
||||
|
@ -1500,3 +1545,18 @@ class TestApplicationStatus(TestWithUser, WebTest):
|
|||
reverse(url_name, kwargs={"pk": application.pk})
|
||||
)
|
||||
self.assertEqual(page.status_code, 403)
|
||||
|
||||
def test_approved_application_not_in_active_requests(self):
|
||||
"""An approved application is not shown in the Active
|
||||
Requests table on home.html."""
|
||||
application = completed_application(
|
||||
status=DomainApplication.APPROVED, user=self.user
|
||||
)
|
||||
application.save()
|
||||
|
||||
home_page = self.app.get("/")
|
||||
# This works in our test environment because creating
|
||||
# an approved application here does not generate a
|
||||
# domain object, so we do not expect to see 'city.gov'
|
||||
# in either the Domains or Requests tables.
|
||||
self.assertNotContains(home_page, "city.gov")
|
||||
|
|
|
@ -12,7 +12,7 @@ from registrar.models import DomainApplication
|
|||
from registrar.utility import StrEnum
|
||||
from registrar.views.utility import StepsHelper
|
||||
|
||||
from .utility import DomainApplicationPermissionView
|
||||
from .utility import DomainApplicationPermissionView, ApplicationWizardPermissionView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -43,7 +43,7 @@ class Step(StrEnum):
|
|||
REVIEW = "review"
|
||||
|
||||
|
||||
class ApplicationWizard(TemplateView):
|
||||
class ApplicationWizard(ApplicationWizardPermissionView, TemplateView):
|
||||
"""
|
||||
A common set of methods and configuration.
|
||||
|
||||
|
@ -60,6 +60,8 @@ class ApplicationWizard(TemplateView):
|
|||
although not without consulting the base implementation, first.
|
||||
"""
|
||||
|
||||
template_name = ""
|
||||
|
||||
# uniquely namespace the wizard in urls.py
|
||||
# (this is not seen _in_ urls, only for Django's internal naming)
|
||||
# NB: this is included here for reference. Do not change it without
|
||||
|
|
|
@ -9,7 +9,10 @@ def index(request):
|
|||
context = {}
|
||||
if request.user.is_authenticated:
|
||||
applications = DomainApplication.objects.filter(creator=request.user)
|
||||
context["domain_applications"] = applications
|
||||
# Let's exclude the approved applications since our
|
||||
# domain_applications context will be used to populate
|
||||
# the active applications table
|
||||
context["domain_applications"] = applications.exclude(status="approved")
|
||||
|
||||
domains = request.user.permissions.values(
|
||||
"role",
|
||||
|
|
|
@ -5,4 +5,5 @@ from .permission_views import (
|
|||
DomainPermissionView,
|
||||
DomainApplicationPermissionView,
|
||||
DomainInvitationPermissionDeleteView,
|
||||
ApplicationWizardPermissionView,
|
||||
)
|
||||
|
|
|
@ -87,9 +87,9 @@ class DomainPermission(PermissionsLoginMixin):
|
|||
if can_do_action and user_is_analyst_or_superuser:
|
||||
return True
|
||||
|
||||
# ticket 796
|
||||
# if domain.application__status != 'approved'
|
||||
# return false
|
||||
# The user has an ineligible flag
|
||||
if self.request.user.is_restricted():
|
||||
return False
|
||||
|
||||
# if we need to check more about the nature of role, do it here.
|
||||
return False
|
||||
|
@ -119,6 +119,23 @@ class DomainApplicationPermission(PermissionsLoginMixin):
|
|||
return True
|
||||
|
||||
|
||||
class ApplicationWizardPermission(PermissionsLoginMixin):
|
||||
|
||||
"""Does the logged-in user have permission to start or edit an application?"""
|
||||
|
||||
def has_permission(self):
|
||||
"""Check if this user has permission to start or edit an application.
|
||||
|
||||
The user is in self.request.user
|
||||
"""
|
||||
|
||||
# The user has an ineligible flag
|
||||
if self.request.user.is_restricted():
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class DomainInvitationPermission(PermissionsLoginMixin):
|
||||
|
||||
"""Does the logged-in user have access to this domain invitation?
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import abc # abstract base class
|
||||
|
||||
from django.views.generic import DetailView, DeleteView
|
||||
from django.views.generic import DetailView, DeleteView, TemplateView
|
||||
|
||||
from registrar.models import Domain, DomainApplication, DomainInvitation
|
||||
|
||||
|
@ -11,6 +11,7 @@ from .mixins import (
|
|||
DomainPermission,
|
||||
DomainApplicationPermission,
|
||||
DomainInvitationPermission,
|
||||
ApplicationWizardPermission,
|
||||
)
|
||||
import logging
|
||||
|
||||
|
@ -120,6 +121,23 @@ class DomainApplicationPermissionView(DomainApplicationPermission, DetailView, a
|
|||
raise NotImplementedError
|
||||
|
||||
|
||||
class ApplicationWizardPermissionView(
|
||||
ApplicationWizardPermission, TemplateView, abc.ABC
|
||||
):
|
||||
|
||||
"""Abstract base view for the application form that enforces permissions
|
||||
|
||||
This abstract view cannot be instantiated. Actual views must specify
|
||||
`template_name`.
|
||||
"""
|
||||
|
||||
# Abstract property enforces NotImplementedError on an attribute.
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def template_name(self):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class DomainInvitationPermissionDeleteView(
|
||||
DomainInvitationPermission, DeleteView, abc.ABC
|
||||
):
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue