Merge pull request #248 from cisagov/sspj/auditlog

Include audit logging
This commit is contained in:
Seamus Johnston 2022-11-14 17:53:32 +00:00 committed by GitHub
commit ebb9276ca0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 793 additions and 590 deletions

View file

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

66
src/Pipfile.lock generated
View file

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

View file

@ -1,6 +1,25 @@
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.admin import UserAdmin 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): class UserProfileInline(admin.StackedInline):
@ -18,3 +37,5 @@ class MyUserAdmin(UserAdmin):
admin.site.register(User, MyUserAdmin) 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", "django.contrib.staticfiles",
# application used for integrating with Login.gov # application used for integrating with Login.gov
"djangooidc", "djangooidc",
# audit logging of changes to models
"auditlog",
# library to simplify form templating # library to simplify form templating
"widget_tweaks", "widget_tweaks",
# library for Finite State Machine statuses # library for Finite State Machine statuses
@ -119,6 +121,8 @@ MIDDLEWARE = [
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
# django-csp: enable use of Content-Security-Policy header # django-csp: enable use of Content-Security-Policy header
"csp.middleware.CSPMiddleware", "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`) # 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 # TODO: use settings overrides to ensure this always is True during tests
INSTALLED_APPS += ("nplusone.ext.django",) INSTALLED_APPS += ("nplusone.ext.django",)
MIDDLEWARE += ("nplusone.ext.django.NPlusOneMiddleware",) MIDDLEWARE += ("nplusone.ext.django.NPlusOneMiddleware",)
NPLUSONE_RAISE = True # turned off for now, because django-auditlog has some issues
NPLUSONE_RAISE = False
NPLUSONE_WHITELIST = [ NPLUSONE_WHITELIST = [
{"model": "admin.LogEntry", "field": "user"}, {"model": "admin.LogEntry", "field": "user"},
{"model": "registrar.UserProfile"}, {"model": "registrar.UserProfile"},

View file

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

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 from django.conf import settings
import django.contrib.auth.models import django.contrib.auth.models
@ -6,6 +6,7 @@ import django.contrib.auth.validators
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import django.utils.timezone import django.utils.timezone
import django_fsm # type: ignore
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -132,7 +133,65 @@ class Migration(migrations.Migration):
], ],
), ),
migrations.CreateModel( 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=[ fields=[
( (
"id", "id",
@ -145,6 +204,228 @@ class Migration(migrations.Migration):
), ),
("created_at", models.DateTimeField(auto_now_add=True)), ("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=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)), ("street1", models.TextField(blank=True)),
("street2", models.TextField(blank=True)), ("street2", models.TextField(blank=True)),
("street3", models.TextField(blank=True)), ("street3", models.TextField(blank=True)),
@ -152,13 +433,11 @@ class Migration(migrations.Migration):
("sp", models.TextField(blank=True)), ("sp", models.TextField(blank=True)),
("pc", models.TextField(blank=True)), ("pc", models.TextField(blank=True)),
("cc", 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()), ("display_name", models.TextField()),
( (
"user", "user",
models.OneToOneField( models.OneToOneField(
blank=True,
null=True, null=True,
on_delete=django.db.models.deletion.CASCADE, on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL, to=settings.AUTH_USER_MODEL,
@ -168,5 +447,6 @@ class Migration(migrations.Migration):
options={ options={
"abstract": False, "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)