diff --git a/.github/workflows/loaddata.yaml b/.github/workflows/loaddata.yaml index 5d0e2c462..96385a7cb 100644 --- a/.github/workflows/loaddata.yaml +++ b/.github/workflows/loaddata.yaml @@ -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" diff --git a/.github/workflows/migrate.yaml b/.github/workflows/migrate.yaml index f53e7564b..9f4240a18 100644 --- a/.github/workflows/migrate.yaml +++ b/.github/workflows/migrate.yaml @@ -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" diff --git a/src/Pipfile b/src/Pipfile index b94d5aed8..6aa54ed72 100644 --- a/src/Pipfile +++ b/src/Pipfile @@ -8,6 +8,7 @@ django = "*" cfenv = "*" pycryptodomex = "*" django-allow-cidr = "*" +django-auditlog = "*" django-csp = "*" environs = {extras=["django"]} gunicorn = "*" diff --git a/src/Pipfile.lock b/src/Pipfile.lock index b9abf9893..c70ae828b 100644 --- a/src/Pipfile.lock +++ b/src/Pipfile.lock @@ -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" } } diff --git a/src/registrar/admin.py b/src/registrar/admin.py index f2ada24e5..0ffcaaedc 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -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) diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index eb4eea848..048dfb108 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -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 Django’s 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"}, diff --git a/src/registrar/fixtures/users.json b/src/registrar/fixtures/users.json index da09598b5..712fde168 100644 --- a/src/registrar/fixtures/users.json +++ b/src/registrar/fixtures/users.json @@ -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": "" } diff --git a/src/registrar/forms/application_wizard.py b/src/registrar/forms/application_wizard.py index 79430e8e5..910105d88 100644 --- a/src/registrar/forms/application_wizard.py +++ b/src/registrar/forms/application_wizard.py @@ -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") diff --git a/src/registrar/management/commands/loaddata.py b/src/registrar/management/commands/loaddata.py new file mode 100644 index 000000000..7bc340bd3 --- /dev/null +++ b/src/registrar/management/commands/loaddata.py @@ -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) diff --git a/src/registrar/migrations/0001_initial.py b/src/registrar/migrations/0001_initial.py index 4440576a1..119e4db44 100644 --- a/src/registrar/migrations/0001_initial.py +++ b/src/registrar/migrations/0001_initial.py @@ -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), ), ] diff --git a/src/registrar/migrations/0002_contact_website_domainapplication.py b/src/registrar/migrations/0002_contact_website_domainapplication.py deleted file mode 100644 index 0f58437ea..000000000 --- a/src/registrar/migrations/0002_contact_website_domainapplication.py +++ /dev/null @@ -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, - }, - ), - ] diff --git a/src/registrar/models/__init__.py b/src/registrar/models/__init__.py index 65e7eb32d..ef2f2e587 100644 --- a/src/registrar/models/__init__.py +++ b/src/registrar/models/__init__.py @@ -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) diff --git a/src/registrar/models/contact.py b/src/registrar/models/contact.py new file mode 100644 index 000000000..01cdcc769 --- /dev/null +++ b/src/registrar/models/contact.py @@ -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 "" diff --git a/src/registrar/models/domain_application.py b/src/registrar/models/domain_application.py new file mode 100644 index 000000000..736735ace --- /dev/null +++ b/src/registrar/models/domain_application.py @@ -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 diff --git a/src/registrar/models/models.py b/src/registrar/models/models.py deleted file mode 100644 index dd2e00cf8..000000000 --- a/src/registrar/models/models.py +++ /dev/null @@ -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}(? 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 diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py new file mode 100644 index 000000000..acfd1769e --- /dev/null +++ b/src/registrar/models/user.py @@ -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 diff --git a/src/registrar/models/user_profile.py b/src/registrar/models/user_profile.py new file mode 100644 index 000000000..8d7a7d4b0 --- /dev/null +++ b/src/registrar/models/user_profile.py @@ -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" diff --git a/src/registrar/models/utility/address_model.py b/src/registrar/models/utility/address_model.py new file mode 100644 index 000000000..c158ce085 --- /dev/null +++ b/src/registrar/models/utility/address_model.py @@ -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 diff --git a/src/registrar/models/utility/time_stamped_model.py b/src/registrar/models/utility/time_stamped_model.py new file mode 100644 index 000000000..bbc20e7d5 --- /dev/null +++ b/src/registrar/models/utility/time_stamped_model.py @@ -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 diff --git a/src/registrar/models/website.py b/src/registrar/models/website.py new file mode 100644 index 000000000..83c4b8222 --- /dev/null +++ b/src/registrar/models/website.py @@ -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}(? 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) diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py index f1eed65c0..2510473a7 100644 --- a/src/registrar/tests/test_views.py +++ b/src/registrar/tests/test_views.py @@ -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