Merge branch 'main' into ik/add-form-pages

This commit is contained in:
igorkorenfeld 2022-11-17 07:50:40 -08:00
commit 49d39a5e3e
No known key found for this signature in database
GPG key ID: 826947A4B867F659
21 changed files with 853 additions and 596 deletions

View file

@ -33,8 +33,8 @@ jobs:
- name: Load fake data for staging
uses: 18f/cg-deploy-action@main
with:
cf_username: ${{ secrets.CF_USERNAME }}
cf_password: ${{ secrets.CF_PASSWORD }}
cf_username: ${{ secrets.CF_STAGING_USERNAME }}
cf_password: ${{ secrets.CF_STAGING_PASSWORD }}
cf_org: cisa-getgov-prototyping
cf_space: staging
full_command: "cf run-task getgov-staging --wait --command 'python manage.py loaddata registrar/fixtures/*' --name loaddata"

View file

@ -23,8 +23,8 @@ jobs:
- name: Run Django migrations for staging
uses: 18f/cg-deploy-action@main
with:
cf_username: ${{ secrets.CF_USERNAME }}
cf_password: ${{ secrets.CF_PASSWORD }}
cf_username: ${{ secrets.CF_STAGING_USERNAME }}
cf_password: ${{ secrets.CF_STAGING_PASSWORD }}
cf_org: cisa-getgov-prototyping
cf_space: staging
full_command: "cf run-task getgov-staging --wait --command 'python manage.py migrate' --name migrate"

View file

@ -8,6 +8,7 @@ django = "*"
cfenv = "*"
pycryptodomex = "*"
django-allow-cidr = "*"
django-auditlog = "*"
django-csp = "*"
environs = {extras=["django"]}
gunicorn = "*"

66
src/Pipfile.lock generated
View file

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "f3c73d2389ee9b1648528a855174d19d20b67f64a2337a660ebeaf613db31488"
"sha256": "4e755e3f5778ff572fba5755b966cde05d30a84c4eddb1d63ca5fe1034565283"
},
"pipfile-spec": 6,
"requires": {},
@ -126,7 +126,7 @@
"sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845",
"sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"
],
"markers": "python_version >= '3.6'",
"markers": "python_full_version >= '3.6.0'",
"version": "==2.1.1"
},
"cryptography": {
@ -199,6 +199,14 @@
"index": "pypi",
"version": "==0.5.0"
},
"django-auditlog": {
"hashes": [
"sha256:0ab57a536e02341e27c3d0431ad0e124e674507bd965a0756e29b01cb67c38ce",
"sha256:2f83389f98db4b1a9c2961f17cd9ac4a3ea94304655071f30da45d8debf59688"
],
"index": "pypi",
"version": "==2.2.0"
},
"django-cache-url": {
"hashes": [
"sha256:6cc9901a99a99751f5458aa7de08ce06e48c1441b1a94c9457d78af74fab9a26",
@ -378,6 +386,7 @@
"sha256:212757ffcecb3e1a5338d4e6761bf9c04f750e7d027117e74aa3cd8a75bb6fbd",
"sha256:215d6bf7e66732a514f47614f828d8c0aaac9a648c46a831955cb103473c7147",
"sha256:25382c7d174c679ce6927c16b6fbb68b10e56ee44b1acb40671e02d29f2fce7c",
"sha256:2abccab84d057723d2ca8f99ff7b619285d40da6814d50366f61f0fc385c3903",
"sha256:2d964eb24c8b021623df1c93c626671420c6efadbdb8655cb2bd5e0c6fa422ba",
"sha256:2ec46ed947801652c9643e0b1dc334cfb2781232e375ba97312c2fc256597632",
"sha256:2ef892cabdccefe577088a79580301f09f2a713eb239f4f9f62b2b29cafb0577",
@ -423,6 +432,7 @@
"sha256:b9c33d4aef08dfecbd1736ceab8b7b3c4358bf10a0121483e5cd60d3d308cc64",
"sha256:b9d38a4656e4e715d637abdf7296e98d6267df0cc0a8e9a016f8ba07e4aa3eeb",
"sha256:bcda1c84a1c533c528356da5490d464a139b6e84eb77cc0b432e38c5c6dd7882",
"sha256:bef7e3f9dc6f0c13afdd671008534be5744e0e682fb851584c8c3a025ec09720",
"sha256:c15ba5982c177bc4b23a7940c7e4394197e2d6a424a2d282e7c236b66da6d896",
"sha256:c5254cbd4f4855e11cebf678c1a848a3042d455a22a4ce61349c36aafd4c2267",
"sha256:c5682a45df7d9642eff590abc73157c887a68f016df0a8ad722dcc0f888f56d7",
@ -497,6 +507,14 @@
"markers": "python_full_version >= '3.6.8'",
"version": "==3.0.9"
},
"python-dateutil": {
"hashes": [
"sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86",
"sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.8.2"
},
"python-dotenv": {
"hashes": [
"sha256:1684eb44636dd462b66c3ee016599815514527ad99965de77f43e0944634a7e5",
@ -683,7 +701,7 @@
"sha256:8033ad4e853066ba6ca92050b9df2f89301b8fc8bf7e9324d412a63f8bf1a8fd",
"sha256:bac2fd45c0a1c9cf619e63a90d62bdc63892ef92387424b855792a6cabe789aa"
],
"markers": "python_full_version >= '3.6.0'",
"markers": "python_version >= '3.6'",
"version": "==4.0.9"
},
"gitpython": {
@ -699,7 +717,7 @@
"sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325",
"sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"
],
"markers": "python_full_version >= '3.6.0'",
"markers": "python_version >= '3.6'",
"version": "==0.7.0"
},
"mypy": {
@ -782,7 +800,7 @@
"sha256:2c9607871d58c76354b697b42f5d57e1ada7d261c261efac224b664affdc5785",
"sha256:d1735fc58b418fd7c5f658d28d943854f8a849b01a5d0a1e6f3f3fdd0166804b"
],
"markers": "python_full_version >= '3.6.0'",
"markers": "python_version >= '3.6'",
"version": "==2.9.1"
},
"pyflakes": {
@ -790,7 +808,7 @@
"sha256:4579f67d887f804e67edb544428f264b7b24f435b263c4614f384135cea553d2",
"sha256:491feb020dca48ccc562a8c0cbe8df07ee13078df59813b83959cbdada312ea3"
],
"markers": "python_full_version >= '3.6.0'",
"markers": "python_version >= '3.6'",
"version": "==2.5.0"
},
"pyyaml": {
@ -836,7 +854,7 @@
"sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174",
"sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"
],
"markers": "python_full_version >= '3.6.0'",
"markers": "python_version >= '3.6'",
"version": "==6.0"
},
"six": {
@ -852,7 +870,7 @@
"sha256:2aba19d6a040e78d8b09de5c57e96207b09ed71d8e55ce0959eeee6c8e190d94",
"sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"
],
"markers": "python_full_version >= '3.6.0'",
"markers": "python_version >= '3.6'",
"version": "==5.0.0"
},
"soupsieve": {
@ -860,7 +878,7 @@
"sha256:3b2503d3c7084a42b1ebd08116e5f81aadfaea95863628c80a3b774a11b7c759",
"sha256:fc53893b3da2c33de295667a0e19f078c14bf86544af307354de5fcf12a3f30d"
],
"markers": "python_full_version >= '3.6.0'",
"markers": "python_version >= '3.6'",
"version": "==2.3.2.post1"
},
"sqlparse": {
@ -873,18 +891,18 @@
},
"stevedore": {
"hashes": [
"sha256:02518a8f0d6d29be8a445b7f2ac63753ff29e8f2a2faa01777568d5500d777a6",
"sha256:3b1cbd592a87315f000d05164941ee5e164899f8fc0ce9a00bb0f321f40ef93e"
"sha256:7f8aeb6e3f90f96832c301bff21a7eb5eefbe894c88c506483d355565d88cc1a",
"sha256:aa6436565c069b2946fe4ebff07f5041e0c8bf18c7376dd29edf80cf7d524e4e"
],
"markers": "python_version >= '3.8'",
"version": "==4.1.0"
"version": "==4.1.1"
},
"tomli": {
"hashes": [
"sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc",
"sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"
],
"markers": "python_version < '3.11'",
"markers": "python_full_version < '3.11.0a7'",
"version": "==2.0.1"
},
"types-cachetools": {
@ -904,25 +922,25 @@
},
"types-pyyaml": {
"hashes": [
"sha256:70ccaafcf3fb404d57bffc1529fdd86a13e8b4f2cf9fc3ee81a6408ce0ad59d2",
"sha256:aaf5e51444c13bd34104695a89ad9c48412599a4f615d65a60e649109714f608"
"sha256:1e94e80aafee07a7e798addb2a320e32956a373f376655128ae20637adb2655b",
"sha256:6840819871c92deebe6a2067fb800c11b8a063632eb4e3e755914e7ab3604e83"
],
"version": "==6.0.12.1"
"version": "==6.0.12.2"
},
"types-requests": {
"hashes": [
"sha256:14941f8023a80b16441b3b46caffcbfce5265fd14555844d6029697824b5a2ef",
"sha256:fdcd7bd148139fb8eef72cf4a41ac7273872cad9e6ada14b11ff5dfdeee60ed3"
"sha256:bdb1f9811e53d0642c8347b09137363eb25e1a516819e190da187c29595a1df3",
"sha256:d4f342b0df432262e9e326d17638eeae96a5881e78e7a6aae46d33870d73952e"
],
"index": "pypi",
"version": "==2.28.11.2"
"version": "==2.28.11.4"
},
"types-urllib3": {
"hashes": [
"sha256:a948584944b2412c9a74b9cf64f6c48caf8652cb88b38361316f6d15d8a184cd",
"sha256:f6422596cc9ee5fdf68f9d547f541096a20c2dcfd587e37c804c9ea720bf5cb2"
"sha256:1807b87b8ee1ae0226813ba2c52330eff20fb2bf6359b1de24df08eb3090e442",
"sha256:a188c24fc61a99658c8c324c8dd7419f5b91a0d89df004e5f576869122c1db55"
],
"version": "==1.26.25.1"
"version": "==1.26.25.3"
},
"typing-extensions": {
"hashes": [
@ -937,7 +955,7 @@
"sha256:7500c9625927c8ec60f54377d590f67b30c8e70ef4b8894214ac6e4cad233d2a",
"sha256:780a4082c5fbc0fde6a2fcfe5e26e6efc1e8f425730863c04085769781f51eba"
],
"markers": "python_version >= '3.7'",
"markers": "python_full_version >= '3.7.0'",
"version": "==2.1.2"
},
"webob": {
@ -953,7 +971,7 @@
"sha256:2a001a9efa40d2a7e5d9cd8d1527c75f41814eb6afce2c3d207402547b1e5ead",
"sha256:54bd969725838d9861a9fa27f8d971f79d275d94ae255f5c501f53bb6d9929eb"
],
"markers": "python_version < '4' and python_full_version >= '3.6.0'",
"markers": "python_version >= '3.6' and python_version < '4'",
"version": "==3.0.0"
}
}

View file

@ -1,6 +1,25 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import User, UserProfile
from django.contrib.contenttypes.models import ContentType
from django.http.response import HttpResponseRedirect
from django.urls import reverse
from .models import User, UserProfile, DomainApplication, Website
class AuditedAdmin(admin.ModelAdmin):
"""Custom admin to make auditing easier."""
def history_view(self, request, object_id, extra_context=None):
"""On clicking 'History', take admin to the auditlog view for an object."""
return HttpResponseRedirect(
"{url}?resource_type={content_type}&object_id={object_id}".format(
url=reverse("admin:auditlog_logentry_changelist", args=()),
content_type=ContentType.objects.get_for_model(self.model).pk,
object_id=object_id,
)
)
class UserProfileInline(admin.StackedInline):
@ -18,3 +37,5 @@ class MyUserAdmin(UserAdmin):
admin.site.register(User, MyUserAdmin)
admin.site.register(DomainApplication, AuditedAdmin)
admin.site.register(Website, AuditedAdmin)

View file

@ -84,6 +84,8 @@ INSTALLED_APPS = [
"django.contrib.staticfiles",
# application used for integrating with Login.gov
"djangooidc",
# audit logging of changes to models
"auditlog",
# library to simplify form templating
"widget_tweaks",
# library for Finite State Machine statuses
@ -119,6 +121,8 @@ MIDDLEWARE = [
"django.middleware.clickjacking.XFrameOptionsMiddleware",
# django-csp: enable use of Content-Security-Policy header
"csp.middleware.CSPMiddleware",
# django-auditlog: obtain the request User for use in logging
"auditlog.middleware.AuditlogMiddleware",
]
# application object used by Djangos built-in servers (e.g. `runserver`)
@ -605,7 +609,8 @@ if DEBUG:
# TODO: use settings overrides to ensure this always is True during tests
INSTALLED_APPS += ("nplusone.ext.django",)
MIDDLEWARE += ("nplusone.ext.django.NPlusOneMiddleware",)
NPLUSONE_RAISE = True
# turned off for now, because django-auditlog has some issues
NPLUSONE_RAISE = False
NPLUSONE_WHITELIST = [
{"model": "admin.LogEntry", "field": "user"},
{"model": "registrar.UserProfile"},

View file

@ -30,9 +30,6 @@
"sp": "",
"pc": "",
"cc": "",
"voice": "",
"fax": "",
"email": "",
"user": 1,
"display_name": ""
}
@ -68,9 +65,6 @@
"sp": "",
"pc": "",
"cc": "",
"voice": "",
"fax": "",
"email": "",
"user": 2,
"display_name": ""
}
@ -106,9 +100,6 @@
"sp": "",
"pc": "",
"cc": "",
"voice": "",
"fax": "",
"email": "",
"user": 3,
"display_name": ""
}

View file

@ -3,12 +3,15 @@
import logging
from django import forms
from django.shortcuts import redirect
from django.contrib.auth.mixins import LoginRequiredMixin
from formtools.wizard.views import NamedUrlSessionWizardView # type: ignore
from registrar.models import DomainApplication
from registrar.models import DomainApplication, Website
logger = logging.getLogger(__name__)
@ -320,5 +323,33 @@ class ApplicationWizard(LoginRequiredMixin, NamedUrlSessionWizardView):
context["form_titles"] = TITLES
return context
def done(self, form_list, **kwargs):
logger.info("Application form submitted.")
def forms_to_object(self, form_dict: dict) -> DomainApplication:
"""Unpack the form responses onto the model object properties."""
application = DomainApplication.objects.create(creator=self.request.user)
# organization information
organization_data = form_dict["organization"].cleaned_data
application.organization_type = organization_data["organization_type"]
application.federal_branch = organization_data["federal_type"]
application.is_election_office = organization_data["is_election_board"]
# contact information
contact_data = form_dict["contact"].cleaned_data
application.organization_name = contact_data["organization_name"]
application.street_address = contact_data["street_address"]
# TODO: add the rest of these fields when they are created in the forms
# This isn't really the requested_domain field
# but we need something in this field to make the form submittable
requested_site, _ = Website.objects.get_or_create(
website=contact_data["organization_name"] + ".gov"
)
application.requested_domain = requested_site
return application
def done(self, form_list, form_dict, **kwargs):
application = self.forms_to_object(form_dict)
application.submit() # change the status to submitted
application.save()
logger.debug("Application object saved:", application.id)
return redirect("home")

View file

@ -0,0 +1,10 @@
from django.core.management.commands import loaddata
from auditlog.context import disable_auditlog # type: ignore
class Command(loaddata.Command):
def handle(self, *args, **options):
# django-auditlog has some bugs with fixtures
# https://github.com/jazzband/django-auditlog/issues/17
with disable_auditlog():
super(Command, self).handle(*args, **options)

View file

@ -1,4 +1,4 @@
# Generated by Django 4.1.1 on 2022-09-26 15:26
# Generated by Django 4.1.3 on 2022-11-10 14:23
from django.conf import settings
import django.contrib.auth.models
@ -6,6 +6,7 @@ import django.contrib.auth.validators
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import django_fsm # type: ignore
class Migration(migrations.Migration):
@ -132,7 +133,65 @@ class Migration(migrations.Migration):
],
),
migrations.CreateModel(
name="UserProfile",
name="Contact",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"first_name",
models.TextField(
blank=True, db_index=True, help_text="First name", null=True
),
),
(
"middle_name",
models.TextField(blank=True, help_text="Middle name", null=True),
),
(
"last_name",
models.TextField(
blank=True, db_index=True, help_text="Last name", null=True
),
),
("title", models.TextField(blank=True, help_text="Title", null=True)),
(
"email",
models.TextField(
blank=True, db_index=True, help_text="Email", null=True
),
),
(
"phone",
models.TextField(
blank=True, db_index=True, help_text="Phone", null=True
),
),
],
),
migrations.CreateModel(
name="Website",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("website", models.CharField(max_length=255)),
],
),
migrations.CreateModel(
name="DomainApplication",
fields=[
(
"id",
@ -145,6 +204,228 @@ class Migration(migrations.Migration):
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"status",
django_fsm.FSMField(
choices=[
("started", "started"),
("submitted", "submitted"),
("investigating", "investigating"),
("approved", "approved"),
],
default="started",
max_length=50,
),
),
(
"organization_type",
models.CharField(
blank=True,
choices=[
("federal", "a federal agency"),
("interstate", "an organization of two or more states"),
(
"state_or_territory",
"one of the 50 U.S. states, the District of Columbia, American Samoa, Guam, Northern Mariana Islands, Puerto Rico, or the U.S. Virgin Islands",
),
(
"tribal",
"a tribal government recognized by the federal or state government",
),
("county", "a county, parish, or borough"),
("city", "a city, town, township, village, etc."),
(
"special_district",
"an independent organization within a single state",
),
],
help_text="Type of Organization",
max_length=255,
null=True,
),
),
(
"federal_branch",
models.CharField(
blank=True,
choices=[
("Executive", "Executive"),
("Judicial", "Judicial"),
("Legislative", "Legislative"),
],
help_text="Branch of federal government",
max_length=50,
null=True,
),
),
(
"is_election_office",
models.BooleanField(
blank=True,
help_text="Is your ogranization an election office?",
null=True,
),
),
(
"organization_name",
models.TextField(
blank=True,
db_index=True,
help_text="Organization name",
null=True,
),
),
(
"street_address",
models.TextField(blank=True, help_text="Street Address", null=True),
),
(
"unit_type",
models.CharField(
blank=True, help_text="Unit type", max_length=15, null=True
),
),
(
"unit_number",
models.CharField(
blank=True, help_text="Unit number", max_length=255, null=True
),
),
(
"state_territory",
models.CharField(
blank=True, help_text="State/Territory", max_length=2, null=True
),
),
(
"zip_code",
models.CharField(
blank=True,
db_index=True,
help_text="ZIP code",
max_length=10,
null=True,
),
),
(
"purpose",
models.TextField(
blank=True, help_text="Purpose of the domain", null=True
),
),
(
"security_email",
models.CharField(
blank=True,
help_text="Security email for public use",
max_length=320,
null=True,
),
),
(
"anything_else",
models.TextField(
blank=True, help_text="Anything else we should know?", null=True
),
),
(
"acknowledged_policy",
models.BooleanField(
blank=True,
help_text="Acknowledged .gov acceptable use policy",
null=True,
),
),
(
"alternative_domains",
models.ManyToManyField(
blank=True, related_name="alternatives+", to="registrar.website"
),
),
(
"authorizing_official",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="authorizing_official",
to="registrar.contact",
),
),
(
"creator",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="applications_created",
to=settings.AUTH_USER_MODEL,
),
),
(
"current_websites",
models.ManyToManyField(
blank=True, related_name="current+", to="registrar.website"
),
),
(
"investigator",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="applications_investigating",
to=settings.AUTH_USER_MODEL,
),
),
(
"other_contacts",
models.ManyToManyField(
blank=True,
related_name="contact_applications",
to="registrar.contact",
),
),
(
"requested_domain",
models.ForeignKey(
blank=True,
help_text="The requested domain",
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="requested+",
to="registrar.website",
),
),
(
"submitter",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="submitted_applications",
to="registrar.contact",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="UserProfile",
fields=[
(
"contact_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="registrar.contact",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("street1", models.TextField(blank=True)),
("street2", models.TextField(blank=True)),
("street3", models.TextField(blank=True)),
@ -152,13 +433,11 @@ class Migration(migrations.Migration):
("sp", models.TextField(blank=True)),
("pc", models.TextField(blank=True)),
("cc", models.TextField(blank=True)),
("voice", models.TextField(blank=True)),
("fax", models.TextField(blank=True)),
("email", models.TextField(blank=True)),
("display_name", models.TextField()),
(
"user",
models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
@ -168,5 +447,6 @@ class Migration(migrations.Migration):
options={
"abstract": False,
},
bases=("registrar.contact", models.Model),
),
]

View file

@ -1,258 +0,0 @@
# Generated by Django 4.1.3 on 2022-11-08 20:17
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django_fsm # type: ignore
class Migration(migrations.Migration):
dependencies = [
("registrar", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="Contact",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"first_name",
models.TextField(db_index=True, help_text="First name", null=True),
),
("middle_name", models.TextField(help_text="Middle name", null=True)),
(
"last_name",
models.TextField(db_index=True, help_text="Last name", null=True),
),
("title", models.TextField(help_text="Title", null=True)),
(
"email",
models.TextField(db_index=True, help_text="Email", null=True),
),
(
"phone",
models.TextField(db_index=True, help_text="Phone", null=True),
),
],
),
migrations.CreateModel(
name="Website",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("website", models.CharField(max_length=255)),
],
),
migrations.CreateModel(
name="DomainApplication",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"status",
django_fsm.FSMField(
choices=[
("started", "started"),
("submitted", "submitted"),
("investigating", "investigating"),
("approved", "approved"),
],
default="started",
max_length=50,
),
),
(
"organization_type",
models.CharField(
choices=[
("federal", "a federal agency"),
("interstate", "an organization of two or more states"),
(
"state_or_territory",
"one of the 50 U.S. states, the District of Columbia, American Samoa, Guam, Northern Mariana Islands, Puerto Rico, or the U.S. Virgin Islands",
),
(
"tribal",
"a tribal government recognized by the federal or state government",
),
("county", "a county, parish, or borough"),
("city", "a city, town, township, village, etc."),
(
"special_district",
"an independent organization within a single state",
),
],
help_text="Type of Organization",
max_length=255,
),
),
(
"federal_branch",
models.CharField(
choices=[
("Executive", "Executive"),
("Judicial", "Judicial"),
("Legislative", "Legislative"),
],
help_text="Branch of federal government",
max_length=50,
null=True,
),
),
(
"is_election_office",
models.BooleanField(
help_text="Is your ogranization an election office?", null=True
),
),
(
"organization_name",
models.TextField(
db_index=True, help_text="Organization name", null=True
),
),
(
"street_address",
models.TextField(help_text="Street Address", null=True),
),
(
"unit_type",
models.CharField(help_text="Unit type", max_length=15, null=True),
),
(
"unit_number",
models.CharField(
help_text="Unit number", max_length=255, null=True
),
),
(
"state_territory",
models.CharField(
help_text="State/Territory", max_length=2, null=True
),
),
(
"zip_code",
models.CharField(
db_index=True, help_text="ZIP code", max_length=10, null=True
),
),
(
"purpose",
models.TextField(help_text="Purpose of the domain", null=True),
),
(
"security_email",
models.CharField(
help_text="Security email for public use",
max_length=320,
null=True,
),
),
(
"anything_else",
models.TextField(
help_text="Anything else we should know?", null=True
),
),
(
"acknowledged_policy",
models.BooleanField(
help_text="Acknowledged .gov acceptable use policy", null=True
),
),
(
"alternative_domains",
models.ManyToManyField(
related_name="alternatives+", to="registrar.website"
),
),
(
"authorizing_official",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="authorizing_official",
to="registrar.contact",
),
),
(
"creator",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="applications_created",
to=settings.AUTH_USER_MODEL,
),
),
(
"current_websites",
models.ManyToManyField(
related_name="current+", to="registrar.website"
),
),
(
"investigator",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="applications_investigating",
to=settings.AUTH_USER_MODEL,
),
),
(
"other_contacts",
models.ManyToManyField(
related_name="contact_applications", to="registrar.contact"
),
),
(
"requested_domain",
models.ForeignKey(
help_text="The requested domain",
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="requested+",
to="registrar.website",
),
),
(
"submitter",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="submitted_applications",
to="registrar.contact",
),
),
],
options={
"abstract": False,
},
),
]

View file

@ -1,3 +1,21 @@
from .models import User, UserProfile, Contact, Website, DomainApplication
from auditlog.registry import auditlog # type: ignore
__all__ = ["User", "UserProfile", "Contact", "Website", "DomainApplication"]
from .contact import Contact
from .domain_application import DomainApplication
from .user_profile import UserProfile
from .user import User
from .website import Website
__all__ = [
"Contact",
"DomainApplication",
"UserProfile",
"User",
"Website",
]
auditlog.register(Contact)
auditlog.register(DomainApplication)
auditlog.register(UserProfile)
auditlog.register(User)
auditlog.register(Website)

View file

@ -0,0 +1,51 @@
from django.db import models
class Contact(models.Model):
"""Contact information follows a similar pattern for each contact."""
first_name = models.TextField(
null=True,
blank=True,
help_text="First name",
db_index=True,
)
middle_name = models.TextField(
null=True,
blank=True,
help_text="Middle name",
)
last_name = models.TextField(
null=True,
blank=True,
help_text="Last name",
db_index=True,
)
title = models.TextField(
null=True,
blank=True,
help_text="Title",
)
email = models.TextField(
null=True,
blank=True,
help_text="Email",
db_index=True,
)
phone = models.TextField(
null=True,
blank=True,
help_text="Phone",
db_index=True,
)
def __str__(self):
if self.first_name or self.last_name:
return f"{self.title or ''} {self.first_name or ''} {self.last_name or ''}"
elif self.email:
return self.email
elif self.pk:
return str(self.pk)
else:
return ""

View file

@ -0,0 +1,227 @@
from django.db import models
from django_fsm import FSMField, transition # type: ignore
from .utility.time_stamped_model import TimeStampedModel
from .contact import Contact
from .user import User
from .website import Website
class DomainApplication(TimeStampedModel):
"""A registrant's application for a new domain."""
# #### Contants for choice fields ####
STARTED = "started"
SUBMITTED = "submitted"
INVESTIGATING = "investigating"
APPROVED = "approved"
STATUS_CHOICES = [
(STARTED, STARTED),
(SUBMITTED, SUBMITTED),
(INVESTIGATING, INVESTIGATING),
(APPROVED, APPROVED),
]
FEDERAL = "federal"
INTERSTATE = "interstate"
STATE_OR_TERRITORY = "state_or_territory"
TRIBAL = "tribal"
COUNTY = "county"
CITY = "city"
SPECIAL_DISTRICT = "special_district"
ORGANIZATION_CHOICES = [
(FEDERAL, "a federal agency"),
(INTERSTATE, "an organization of two or more states"),
(
STATE_OR_TERRITORY,
"one of the 50 U.S. states, the District of "
"Columbia, American Samoa, Guam, Northern Mariana Islands, "
"Puerto Rico, or the U.S. Virgin Islands",
),
(
TRIBAL,
"a tribal government recognized by the federal or " "state government",
),
(COUNTY, "a county, parish, or borough"),
(CITY, "a city, town, township, village, etc."),
(SPECIAL_DISTRICT, "an independent organization within a single state"),
]
EXECUTIVE = "Executive"
JUDICIAL = "Judicial"
LEGISLATIVE = "Legislative"
BRANCH_CHOICES = [(x, x) for x in (EXECUTIVE, JUDICIAL, LEGISLATIVE)]
# #### Internal fields about the application #####
status = FSMField(
choices=STATUS_CHOICES, # possible states as an array of constants
default=STARTED, # sensible default
protected=False, # can change state directly, particularly in Django admin
)
# This is the application user who created this application. The contact
# information that they gave is in the `submitter` field
creator = models.ForeignKey(
User, on_delete=models.PROTECT, related_name="applications_created"
)
investigator = models.ForeignKey(
User,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="applications_investigating",
)
# ##### data fields from the initial form #####
organization_type = models.CharField(
max_length=255,
choices=ORGANIZATION_CHOICES,
null=True,
blank=True,
help_text="Type of Organization",
)
federal_branch = models.CharField(
max_length=50,
choices=BRANCH_CHOICES,
null=True,
blank=True,
help_text="Branch of federal government",
)
is_election_office = models.BooleanField(
null=True,
blank=True,
help_text="Is your ogranization an election office?",
)
organization_name = models.TextField(
null=True,
blank=True,
help_text="Organization name",
db_index=True,
)
street_address = models.TextField(
null=True,
blank=True,
help_text="Street Address",
)
unit_type = models.CharField(
max_length=15,
null=True,
blank=True,
help_text="Unit type",
)
unit_number = models.CharField(
max_length=255,
null=True,
blank=True,
help_text="Unit number",
)
state_territory = models.CharField(
max_length=2,
null=True,
blank=True,
help_text="State/Territory",
)
zip_code = models.CharField(
max_length=10,
null=True,
blank=True,
help_text="ZIP code",
db_index=True,
)
authorizing_official = models.ForeignKey(
Contact,
null=True,
blank=True,
related_name="authorizing_official",
on_delete=models.PROTECT,
)
# "+" means no reverse relation to lookup applications from Website
current_websites = models.ManyToManyField(
Website,
blank=True,
related_name="current+",
)
requested_domain = models.ForeignKey(
Website,
null=True,
blank=True,
help_text="The requested domain",
related_name="requested+",
on_delete=models.PROTECT,
)
alternative_domains = models.ManyToManyField(
Website,
blank=True,
related_name="alternatives+",
)
# This is the contact information provided by the applicant. The
# application user who created it is in the `creator` field.
submitter = models.ForeignKey(
Contact,
null=True,
blank=True,
related_name="submitted_applications",
on_delete=models.PROTECT,
)
purpose = models.TextField(
null=True,
blank=True,
help_text="Purpose of the domain",
)
other_contacts = models.ManyToManyField(
Contact,
blank=True,
related_name="contact_applications",
)
security_email = models.CharField(
max_length=320,
null=True,
blank=True,
help_text="Security email for public use",
)
anything_else = models.TextField(
null=True,
blank=True,
help_text="Anything else we should know?",
)
acknowledged_policy = models.BooleanField(
null=True,
blank=True,
help_text="Acknowledged .gov acceptable use policy",
)
def __str__(self):
try:
if self.requested_domain and self.requested_domain.website:
return self.requested_domain.website
else:
return f"{self.status} application created by {self.creator}"
except Exception:
return ""
@transition(field="status", source=STARTED, target=SUBMITTED)
def submit(self):
"""Submit an application that is started."""
# check our conditions here inside the `submit` method so that we
# can raise more informative exceptions
# requested_domain could be None here
if (not self.requested_domain) or (not self.requested_domain.could_be_domain()):
raise ValueError("Requested domain is not a legal domain name.")
# if no exception was raised, then we don't need to do anything
# inside this method, keep the `pass` here to remind us of that
pass

View file

@ -1,290 +0,0 @@
import re
from django.core.exceptions import ObjectDoesNotExist
from django.contrib.auth.models import AbstractUser
from django.db import models
from django_fsm import FSMField, transition # type: ignore
class User(AbstractUser):
"""
A custom user model that performs identically to the default user model
but can be customized later.
"""
def __str__(self):
try:
return self.userprofile.display_name
except ObjectDoesNotExist:
return self.username
class TimeStampedModel(models.Model):
"""
An abstract base model that provides self-updating
`created_at` and `updated_at` fields.
"""
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
# don't put anything else here, it will be ignored
class AddressModel(models.Model):
"""
An abstract base model that provides common fields
for postal addresses.
"""
# contact's street (null ok)
street1 = models.TextField(blank=True)
# contact's street (null ok)
street2 = models.TextField(blank=True)
# contact's street (null ok)
street3 = models.TextField(blank=True)
# contact's city
city = models.TextField(blank=True)
# contact's state or province (null ok)
sp = models.TextField(blank=True)
# contact's postal code (null ok)
pc = models.TextField(blank=True)
# contact's country code
cc = models.TextField(blank=True)
class Meta:
abstract = True
# don't put anything else here, it will be ignored
class ContactInfo(models.Model):
"""
An abstract base model that provides common fields
for contact information.
"""
voice = models.TextField(blank=True)
fax = models.TextField(blank=True)
email = models.TextField(blank=True)
class Meta:
abstract = True
# don't put anything else here, it will be ignored
class UserProfile(TimeStampedModel, ContactInfo, AddressModel):
user = models.OneToOneField(User, null=True, on_delete=models.CASCADE)
display_name = models.TextField()
def __str__(self):
if self.display_name:
return self.display_name
else:
try:
return self.user.username
except ObjectDoesNotExist:
return "No username"
class Website(models.Model):
"""Keep domain names in their own table so that applications can refer to
many of them."""
# domain names have strictly limited lengths, 255 characters is more than
# enough.
website = models.CharField(max_length=255, null=False, help_text="")
# a domain name is alphanumeric or hyphen, up to 63 characters, doesn't
# begin or end with a hyphen, followed by a TLD of 2-6 alphabetic characters
DOMAIN_REGEX = re.compile(r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)\.[A-Za-z]{2,6}")
@classmethod
def string_could_be_domain(cls, domain: str) -> bool:
"""Return True if the string could be a domain name, otherwise False.
TODO: when we have a Domain class, this could be a classmethod there.
"""
if cls.DOMAIN_REGEX.match(domain):
return True
return False
def could_be_domain(self) -> bool:
"""Could this instance be a domain?"""
# short-circuit if self.website is null/None
if not self.website:
return False
return self.string_could_be_domain(str(self.website))
def __str__(self) -> str:
return str(self.website)
class Contact(models.Model):
"""Contact information follows a similar pattern for each contact."""
first_name = models.TextField(null=True, help_text="First name", db_index=True)
middle_name = models.TextField(null=True, help_text="Middle name")
last_name = models.TextField(null=True, help_text="Last name", db_index=True)
title = models.TextField(null=True, help_text="Title")
email = models.TextField(null=True, help_text="Email", db_index=True)
phone = models.TextField(null=True, help_text="Phone", db_index=True)
class DomainApplication(TimeStampedModel):
"""A registrant's application for a new domain."""
# #### Contants for choice fields ####
STARTED = "started"
SUBMITTED = "submitted"
INVESTIGATING = "investigating"
APPROVED = "approved"
STATUS_CHOICES = [
(STARTED, STARTED),
(SUBMITTED, SUBMITTED),
(INVESTIGATING, INVESTIGATING),
(APPROVED, APPROVED),
]
FEDERAL = "federal"
INTERSTATE = "interstate"
STATE_OR_TERRITORY = "state_or_territory"
TRIBAL = "tribal"
COUNTY = "county"
CITY = "city"
SPECIAL_DISTRICT = "special_district"
ORGANIZATION_CHOICES = [
(FEDERAL, "a federal agency"),
(INTERSTATE, "an organization of two or more states"),
(
STATE_OR_TERRITORY,
"one of the 50 U.S. states, the District of "
"Columbia, American Samoa, Guam, Northern Mariana Islands, "
"Puerto Rico, or the U.S. Virgin Islands",
),
(
TRIBAL,
"a tribal government recognized by the federal or " "state government",
),
(COUNTY, "a county, parish, or borough"),
(CITY, "a city, town, township, village, etc."),
(SPECIAL_DISTRICT, "an independent organization within a single state"),
]
EXECUTIVE = "Executive"
JUDICIAL = "Judicial"
LEGISLATIVE = "Legislative"
BRANCH_CHOICES = [(x, x) for x in (EXECUTIVE, JUDICIAL, LEGISLATIVE)]
# #### Internal fields about the application #####
status = FSMField(
choices=STATUS_CHOICES, # possible states as an array of constants
default=STARTED, # sensible default
protected=False, # can change state directly, particularly in Django admin
)
# This is the application user who created this application. The contact
# information that they gave is in the `submitter` field
creator = models.ForeignKey(
User, on_delete=models.PROTECT, related_name="applications_created"
)
investigator = models.ForeignKey(
User,
null=True,
on_delete=models.SET_NULL,
related_name="applications_investigating",
)
# ##### data fields from the initial form #####
organization_type = models.CharField(
max_length=255, choices=ORGANIZATION_CHOICES, help_text="Type of Organization"
)
federal_branch = models.CharField(
max_length=50,
choices=BRANCH_CHOICES,
null=True,
help_text="Branch of federal government",
)
is_election_office = models.BooleanField(
null=True, help_text="Is your ogranization an election office?"
)
organization_name = models.TextField(
null=True, help_text="Organization name", db_index=True
)
street_address = models.TextField(null=True, help_text="Street Address")
unit_type = models.CharField(max_length=15, null=True, help_text="Unit type")
unit_number = models.CharField(max_length=255, null=True, help_text="Unit number")
state_territory = models.CharField(
max_length=2, null=True, help_text="State/Territory"
)
zip_code = models.CharField(
max_length=10, null=True, help_text="ZIP code", db_index=True
)
authorizing_official = models.ForeignKey(
Contact,
null=True,
related_name="authorizing_official",
on_delete=models.PROTECT,
)
# "+" means no reverse relation to lookup applications from Website
current_websites = models.ManyToManyField(Website, related_name="current+")
requested_domain = models.ForeignKey(
Website,
null=True,
help_text="The requested domain",
related_name="requested+",
on_delete=models.PROTECT,
)
alternative_domains = models.ManyToManyField(Website, related_name="alternatives+")
# This is the contact information provided by the applicant. The
# application user who created it is in the `creator` field.
submitter = models.ForeignKey(
Contact,
null=True,
related_name="submitted_applications",
on_delete=models.PROTECT,
)
purpose = models.TextField(null=True, help_text="Purpose of the domain")
other_contacts = models.ManyToManyField(
Contact, related_name="contact_applications"
)
security_email = models.CharField(
max_length=320, null=True, help_text="Security email for public use"
)
anything_else = models.TextField(
null=True, help_text="Anything else we should know?"
)
acknowledged_policy = models.BooleanField(
null=True, help_text="Acknowledged .gov acceptable use policy"
)
@transition(field="status", source=STARTED, target=SUBMITTED)
def submit(self):
"""Submit an application that is started."""
# check our conditions here inside the `submit` method so that we
# can raise more informative exceptions
# requested_domain could be None here
if (not self.requested_domain) or (not self.requested_domain.could_be_domain()):
raise ValueError("Requested domain is not a legal domain name.")
# if no exception was raised, then we don't need to do anything
# inside this method, keep the `pass` here to remind us of that
pass

View file

@ -0,0 +1,17 @@
from django.contrib.auth.models import AbstractUser
class User(AbstractUser):
"""
A custom user model that performs identically to the default user model
but can be customized later.
"""
def __str__(self):
# this info is pulled from Login.gov
if self.first_name or self.last_name:
return f"{self.first_name or ''} {self.last_name or ''}"
elif self.email:
return self.email
else:
return self.username

View file

@ -0,0 +1,29 @@
from django.db import models
from .utility.time_stamped_model import TimeStampedModel
from .utility.address_model import AddressModel
from .contact import Contact
from .user import User
class UserProfile(TimeStampedModel, Contact, AddressModel):
"""User information, unrelated to their login/auth details."""
user = models.OneToOneField(
User,
null=True,
blank=True,
on_delete=models.CASCADE,
)
display_name = models.TextField()
def __str__(self):
# use info stored in User rather than Contact,
# because Contact is user-editable while User
# pulls from identity-verified Login.gov
try:
return str(self.user)
except Exception:
return "Orphaned account"

View file

@ -0,0 +1,27 @@
from django.db import models
class AddressModel(models.Model):
"""
An abstract base model that provides common fields
for postal addresses.
"""
# contact's street (null ok)
street1 = models.TextField(blank=True)
# contact's street (null ok)
street2 = models.TextField(blank=True)
# contact's street (null ok)
street3 = models.TextField(blank=True)
# contact's city
city = models.TextField(blank=True)
# contact's state or province (null ok)
sp = models.TextField(blank=True)
# contact's postal code (null ok)
pc = models.TextField(blank=True)
# contact's country code
cc = models.TextField(blank=True)
class Meta:
abstract = True
# don't put anything else here, it will be ignored

View file

@ -0,0 +1,15 @@
from django.db import models
class TimeStampedModel(models.Model):
"""
An abstract base model that provides self-updating
`created_at` and `updated_at` fields.
"""
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
# don't put anything else here, it will be ignored

View file

@ -0,0 +1,41 @@
import re
from django.db import models
class Website(models.Model):
"""Keep domain names in their own table so that applications can refer to
many of them."""
# domain names have strictly limited lengths, 255 characters is more than
# enough.
website = models.CharField(
max_length=255,
null=False,
help_text="",
)
# a domain name is alphanumeric or hyphen, up to 63 characters, doesn't
# begin or end with a hyphen, followed by a TLD of 2-6 alphabetic characters
DOMAIN_REGEX = re.compile(r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)\.[A-Za-z]{2,6}")
@classmethod
def string_could_be_domain(cls, domain: str) -> bool:
"""Return True if the string could be a domain name, otherwise False.
TODO: when we have a Domain class, this could be a classmethod there.
"""
if cls.DOMAIN_REGEX.match(domain):
return True
return False
def could_be_domain(self) -> bool:
"""Could this instance be a domain?"""
# short-circuit if self.website is null/None
if not self.website:
return False
return self.string_could_be_domain(str(self.website))
def __str__(self) -> str:
return str(self.website)

View file

@ -96,3 +96,26 @@ class FormTests(TestWithUser, WebTest):
result = page.form.submit().follow()
# Got the next form page
self.assertIn("contact information", result)
def test_application_form_submission(self):
"""Can fill out the entire form and submit.
As we add additional form pages, we need to include them here to make
this test work.
"""
page = self.app.get(reverse("application")).follow()
form = page.form
form["organization-organization_type"] = "Federal"
form["organization-federal_type"] = "Executive"
result = page.form.submit().follow()
# Got the next form page
contact_form = result.form
contact_form["contact-organization_name"] = "test"
contact_form["contact-street_address"] = "100 Main Street"
result = page.form.submit()
# final submission results in a redirect
self.assertEquals(result.status_code, 302)
page = result.follow()
self.assertContains(page, "registrar")
# TODO: when we have a page that lists applications, visit it and
# make sure that the new one exists