Merge branch 'main' into za/806-analyst-view-domain-management-data

This commit is contained in:
zandercymatics 2023-08-30 07:47:37 -06:00
commit 7e0a4aea76
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
29 changed files with 803 additions and 95 deletions

View file

@ -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

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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:

View 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

View 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
View 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"

View file

@ -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)

View file

@ -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"

View file

@ -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 = [

View file

@ -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,
),
),
]

View 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,
),
),
]

View file

@ -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

View file

@ -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.

View file

@ -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 %}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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 %}

View file

@ -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):

View file

@ -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):

View file

@ -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")

View file

@ -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

View file

@ -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",

View file

@ -5,4 +5,5 @@ from .permission_views import (
DomainPermissionView,
DomainApplicationPermissionView,
DomainInvitationPermissionDeleteView,
ApplicationWizardPermissionView,
)

View file

@ -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?

View file

@ -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
):