diff --git a/README.md b/README.md
index 2685a8ec3..f457e7e69 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/ops/manifests/manifest-ab.yaml b/ops/manifests/manifest-ab.yaml
index 463563a15..fb8b02b03 100644
--- a/ops/manifests/manifest-ab.yaml
+++ b/ops/manifests/manifest-ab.yaml
@@ -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:
diff --git a/ops/manifests/manifest-bl.yaml b/ops/manifests/manifest-bl.yaml
index ec6d627ce..bff347709 100644
--- a/ops/manifests/manifest-bl.yaml
+++ b/ops/manifests/manifest-bl.yaml
@@ -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:
diff --git a/ops/manifests/manifest-dk.yaml b/ops/manifests/manifest-dk.yaml
index 87de8a496..249eab119 100644
--- a/ops/manifests/manifest-dk.yaml
+++ b/ops/manifests/manifest-dk.yaml
@@ -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:
diff --git a/ops/manifests/manifest-rjm.yaml b/ops/manifests/manifest-rjm.yaml
index 9622f09bc..1942414ef 100644
--- a/ops/manifests/manifest-rjm.yaml
+++ b/ops/manifests/manifest-rjm.yaml
@@ -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:
diff --git a/ops/manifests/manifest-stable.yaml b/ops/manifests/manifest-stable.yaml
index 72726cd08..7cfa1417d 100644
--- a/ops/manifests/manifest-stable.yaml
+++ b/ops/manifests/manifest-stable.yaml
@@ -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:
diff --git a/ops/scripts/create_environment_migrate.sh b/ops/scripts/create_environment_migrate.sh
new file mode 100755
index 000000000..0e9d8aff7
--- /dev/null
+++ b/ops/scripts/create_environment_migrate.sh
@@ -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
diff --git a/ops/scripts/manifest-sandbox-template-migrate.yaml b/ops/scripts/manifest-sandbox-template-migrate.yaml
new file mode 100644
index 000000000..8789effa5
--- /dev/null
+++ b/ops/scripts/manifest-sandbox-template-migrate.yaml
@@ -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
diff --git a/ops/scripts/migrate_new_old.sh b/ops/scripts/migrate_new_old.sh
new file mode 100755
index 000000000..1ed56dcbf
--- /dev/null
+++ b/ops/scripts/migrate_new_old.sh
@@ -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"
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index d2df04d3a..d6109a0cc 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -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)
diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py
index 8b53fa82a..e272e6622 100644
--- a/src/registrar/config/settings.py
+++ b/src/registrar/config/settings.py
@@ -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 "
# connect to an (external) SMTP server for sending email
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
diff --git a/src/registrar/fixtures.py b/src/registrar/fixtures.py
index 0b1b8926d..f7168dbf3 100644
--- a/src/registrar/fixtures.py
+++ b/src/registrar/fixtures.py
@@ -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 = [
diff --git a/src/registrar/migrations/0029_user_status_alter_domainapplication_status.py b/src/registrar/migrations/0029_user_status_alter_domainapplication_status.py
new file mode 100644
index 000000000..504358665
--- /dev/null
+++ b/src/registrar/migrations/0029_user_status_alter_domainapplication_status.py
@@ -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,
+ ),
+ ),
+ ]
diff --git a/src/registrar/migrations/0030_alter_user_status.py b/src/registrar/migrations/0030_alter_user_status.py
new file mode 100644
index 000000000..7dd27bfa4
--- /dev/null
+++ b/src/registrar/migrations/0030_alter_user_status.py
@@ -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,
+ ),
+ ),
+ ]
diff --git a/src/registrar/models/domain_application.py b/src/registrar/models/domain_application.py
index 32657a49d..b1230b703 100644
--- a/src/registrar/models/domain_application.py
+++ b/src/registrar/models/domain_application.py
@@ -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
diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py
index 4cd8b6c90..5cf1dd71f 100644
--- a/src/registrar/models/user.py
+++ b/src/registrar/models/user.py
@@ -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.
diff --git a/src/registrar/templates/application_dotgov_domain.html b/src/registrar/templates/application_dotgov_domain.html
index b2ee6cab6..fd6ce9604 100644
--- a/src/registrar/templates/application_dotgov_domain.html
+++ b/src/registrar/templates/application_dotgov_domain.html
@@ -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 %}
diff --git a/src/registrar/templates/application_status.html b/src/registrar/templates/application_status.html
index 99f6a1d4c..c95f6a98d 100644
--- a/src/registrar/templates/application_status.html
+++ b/src/registrar/templates/application_status.html
@@ -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 %}
diff --git a/src/registrar/templates/includes/input_with_errors.html b/src/registrar/templates/includes/input_with_errors.html
index b35ab1b7a..2adc08984 100644
--- a/src/registrar/templates/includes/input_with_errors.html
+++ b/src/registrar/templates/includes/input_with_errors.html
@@ -55,15 +55,13 @@ error messages, if necessary.
{% endif %}
- {% if www_gov %}
+ {% if append_gov %}
- www.
{% endif %}
-
{# this is the input field, itself #}
{% include widget.template_name %}
- {% if www_gov %}
+ {% if append_gov %}
.gov
{% endif %}
diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py
index 7d9b1d9ae..8cd1988f2 100644
--- a/src/registrar/tests/test_admin.py
+++ b/src/registrar/tests/test_admin.py
@@ -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):
diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py
index 997d5f4e2..ca1191061 100644
--- a/src/registrar/tests/test_models.py
+++ b/src/registrar/tests/test_models.py
@@ -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):
diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py
index feb553bf7..9ce44f674 100644
--- a/src/registrar/tests/test_views.py
+++ b/src/registrar/tests/test_views.py
@@ -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")
diff --git a/src/registrar/views/application.py b/src/registrar/views/application.py
index 256f4be40..23d7348e9 100644
--- a/src/registrar/views/application.py
+++ b/src/registrar/views/application.py
@@ -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
diff --git a/src/registrar/views/index.py b/src/registrar/views/index.py
index 35a67bceb..186535aa3 100644
--- a/src/registrar/views/index.py
+++ b/src/registrar/views/index.py
@@ -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",
diff --git a/src/registrar/views/utility/__init__.py b/src/registrar/views/utility/__init__.py
index 6c656c614..71d3edb91 100644
--- a/src/registrar/views/utility/__init__.py
+++ b/src/registrar/views/utility/__init__.py
@@ -5,4 +5,5 @@ from .permission_views import (
DomainPermissionView,
DomainApplicationPermissionView,
DomainInvitationPermissionDeleteView,
+ ApplicationWizardPermissionView,
)
diff --git a/src/registrar/views/utility/mixins.py b/src/registrar/views/utility/mixins.py
index 38d4233f9..8617f8acb 100644
--- a/src/registrar/views/utility/mixins.py
+++ b/src/registrar/views/utility/mixins.py
@@ -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?
diff --git a/src/registrar/views/utility/permission_views.py b/src/registrar/views/utility/permission_views.py
index 0d88d8e06..a42238150 100644
--- a/src/registrar/views/utility/permission_views.py
+++ b/src/registrar/views/utility/permission_views.py
@@ -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
):