Finish merge

This commit is contained in:
igorkorenfeld 2022-11-14 12:15:36 -05:00
commit b781ae732e
No known key found for this signature in database
GPG key ID: 826947A4B867F659
12 changed files with 823 additions and 121 deletions

View file

@ -4,8 +4,13 @@ name: Build and deploy
# a merged pull request) and on pushes of tagged commits.
# Pushes to main will deploy to Staging
# This will also deploy nightly
on:
schedule:
# 3am UTC
- cron: '0 3 * * *'
push:
paths-ignore:
- 'docs/**'

View file

@ -0,0 +1,42 @@
# 15. Use Django-FSM library for domain application state
Date: 2022-11-03
## Status
Accepted
## Context
The applications that registrants submit for domains move through a variety of
different states or stages as they are processed by CISA staff. Traditionally,
there would be a “domain application” data model with a “status” field. The
rules in the application code that control what changes are permitted to the
statuses are called “domain logic”.
In a large piece of software, domain logic often spreads around the code base
because while handling a single request like “mark this application as
approved”, requirements can be enforced at many different points during the
process.
Finite state machines <https://en.wikipedia.org/wiki/Finite-state_machine> are
a mathematical model where an object can be in exactly one of a fixed set of
states and can change states (or “transition”) according to fixed rules.
## Decision
We will use the django-fsm library to represent the status of our domain
registration applications as a finite state machine. The library allows us to
list what statuses are possible and describe which state transitions are
possible (e.g. Can an approved application ever be marked as “in-process”?).
## Consequences
This should help us to keep domain logic localized within each model class. It
should also make it easier to design the workflow stages and translate them
clearly into application code.
There is a possible negative impact that our finite state machine library might
be unfamiliar to developers, but we can mitigate the impact by documenting this
decision and lavishly commenting our business logic and transition methods.

View file

@ -112,3 +112,14 @@ Within the `registrar/assets` folder, the `_theme` folder contains three files i
3. `styles.css` a entry point or index for the styles, forwards all of the other style files used in the project (i.e. the USWDS source code, the settings, and all custom stylesheets).
You can also compile the sass at any time using `npx gulp compile`. Similarly, you can copy over other static assets (images and javascript files), using `npx gulp copyAssets`.
## Nightly Builds
We run a deploy to staging from `main` nightly. You can see the deploys [here](https://github.com/cisagov/getgov/actions/workflows/deploy.yaml).
## Finite State Machines
In an effort to keep our domain logic centralized, we are representing the state of
objects in the application using the [django-fsm](https://github.com/viewflow/django-fsm)
library. See the [ADR number 15](../architecture/decisions/0015-use-django-fs.md) for
more information on the topic.

View file

@ -19,6 +19,7 @@ django-formtools = "*"
django-widget-tweaks = "*"
cachetools = "*"
requests = "*"
django-fsm = "*"
[dev-packages]
django-debug-toolbar = "*"

192
src/Pipfile.lock generated
View file

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "1b7689dae771eaeee047ef75ed1da344ebc9d40fbb9ade689e9dba885e20ec59"
"sha256": "f3c73d2389ee9b1648528a855174d19d20b67f64a2337a660ebeaf613db31488"
},
"pipfile-spec": 6,
"requires": {},
@ -131,35 +131,35 @@
},
"cryptography": {
"hashes": [
"sha256:0297ffc478bdd237f5ca3a7dc96fc0d315670bfa099c04dc3a4a2172008a405a",
"sha256:10d1f29d6292fc95acb597bacefd5b9e812099d75a6469004fd38ba5471a977f",
"sha256:16fa61e7481f4b77ef53991075de29fc5bacb582a1244046d2e8b4bb72ef66d0",
"sha256:194044c6b89a2f9f169df475cc167f6157eb9151cc69af8a2a163481d45cc407",
"sha256:1db3d807a14931fa317f96435695d9ec386be7b84b618cc61cfa5d08b0ae33d7",
"sha256:3261725c0ef84e7592597606f6583385fed2a5ec3909f43bc475ade9729a41d6",
"sha256:3b72c360427889b40f36dc214630e688c2fe03e16c162ef0aa41da7ab1455153",
"sha256:3e3a2599e640927089f932295a9a247fc40a5bdf69b0484532f530471a382750",
"sha256:3fc26e22840b77326a764ceb5f02ca2d342305fba08f002a8c1f139540cdfaad",
"sha256:5067ee7f2bce36b11d0e334abcd1ccf8c541fc0bbdaf57cdd511fdee53e879b6",
"sha256:52e7bee800ec869b4031093875279f1ff2ed12c1e2f74923e8f49c916afd1d3b",
"sha256:64760ba5331e3f1794d0bcaabc0d0c39e8c60bf67d09c93dc0e54189dfd7cfe5",
"sha256:765fa194a0f3372d83005ab83ab35d7c5526c4e22951e46059b8ac678b44fa5a",
"sha256:79473cf8a5cbc471979bd9378c9f425384980fcf2ab6534b18ed7d0d9843987d",
"sha256:896dd3a66959d3a5ddcfc140a53391f69ff1e8f25d93f0e2e7830c6de90ceb9d",
"sha256:89ed49784ba88c221756ff4d4755dbc03b3c8d2c5103f6d6b4f83a0fb1e85294",
"sha256:ac7e48f7e7261207d750fa7e55eac2d45f720027d5703cd9007e9b37bbb59ac0",
"sha256:ad7353f6ddf285aeadfaf79e5a6829110106ff8189391704c1d8801aa0bae45a",
"sha256:b0163a849b6f315bf52815e238bc2b2346604413fa7c1601eea84bcddb5fb9ac",
"sha256:b6c9b706316d7b5a137c35e14f4103e2115b088c412140fdbd5f87c73284df61",
"sha256:c2e5856248a416767322c8668ef1845ad46ee62629266f84a8f007a317141013",
"sha256:ca9f6784ea96b55ff41708b92c3f6aeaebde4c560308e5fbbd3173fbc466e94e",
"sha256:d1a5bd52d684e49a36582193e0b89ff267704cd4025abefb9e26803adeb3e5fb",
"sha256:d3971e2749a723e9084dd507584e2a2761f78ad2c638aa31e80bc7a15c9db4f9",
"sha256:d4ef6cc305394ed669d4d9eebf10d3a101059bdcf2669c366ec1d14e4fb227bd",
"sha256:d9e69ae01f99abe6ad646947bba8941e896cb3aa805be2597a0400e0764b5818"
"sha256:068147f32fa662c81aebab95c74679b401b12b57494872886eb5c1139250ec5d",
"sha256:06fc3cc7b6f6cca87bd56ec80a580c88f1da5306f505876a71c8cfa7050257dd",
"sha256:25c1d1f19729fb09d42e06b4bf9895212292cb27bb50229f5aa64d039ab29146",
"sha256:402852a0aea73833d982cabb6d0c3bb582c15483d29fb7085ef2c42bfa7e38d7",
"sha256:4e269dcd9b102c5a3d72be3c45d8ce20377b8076a43cbed6f660a1afe365e436",
"sha256:5419a127426084933076132d317911e3c6eb77568a1ce23c3ac1e12d111e61e0",
"sha256:554bec92ee7d1e9d10ded2f7e92a5d70c1f74ba9524947c0ba0c850c7b011828",
"sha256:5e89468fbd2fcd733b5899333bc54d0d06c80e04cd23d8c6f3e0542358c6060b",
"sha256:65535bc550b70bd6271984d9863a37741352b4aad6fb1b3344a54e6950249b55",
"sha256:6ab9516b85bebe7aa83f309bacc5f44a61eeb90d0b4ec125d2d003ce41932d36",
"sha256:6addc3b6d593cd980989261dc1cce38263c76954d758c3c94de51f1e010c9a50",
"sha256:728f2694fa743a996d7784a6194da430f197d5c58e2f4e278612b359f455e4a2",
"sha256:785e4056b5a8b28f05a533fab69febf5004458e20dad7e2e13a3120d8ecec75a",
"sha256:78cf5eefac2b52c10398a42765bfa981ce2372cbc0457e6bf9658f41ec3c41d8",
"sha256:7f836217000342d448e1c9a342e9163149e45d5b5eca76a30e84503a5a96cab0",
"sha256:8d41a46251bf0634e21fac50ffd643216ccecfaf3701a063257fe0b2be1b6548",
"sha256:984fe150f350a3c91e84de405fe49e688aa6092b3525f407a18b9646f6612320",
"sha256:9b24bcff7853ed18a63cfb0c2b008936a9554af24af2fb146e16d8e1aed75748",
"sha256:b1b35d9d3a65542ed2e9d90115dfd16bbc027b3f07ee3304fc83580f26e43249",
"sha256:b1b52c9e5f8aa2b802d48bd693190341fae201ea51c7a167d69fc48b60e8a959",
"sha256:bbf203f1a814007ce24bd4d51362991d5cb90ba0c177a9c08825f2cc304d871f",
"sha256:be243c7e2bfcf6cc4cb350c0d5cdf15ca6383bbcb2a8ef51d3c9411a9d4386f0",
"sha256:bfbe6ee19615b07a98b1d2287d6a6073f734735b49ee45b11324d85efc4d5cbd",
"sha256:c46837ea467ed1efea562bbeb543994c2d1f6e800785bd5a2c98bc096f5cb220",
"sha256:dfb4f4dd568de1b6af9f4cda334adf7d72cf5bc052516e1b2608b683375dd95c",
"sha256:ed7b00096790213e09eb11c97cc6e2b757f15f3d2f85833cd2d3ec3fe37c1722"
],
"markers": "python_version >= '3.6'",
"version": "==38.0.1"
"version": "==38.0.3"
},
"defusedxml": {
"hashes": [
@ -185,11 +185,11 @@
},
"django": {
"hashes": [
"sha256:26dc24f99c8956374a054bcbf58aab8dc0cad2e6ac82b0fe036b752c00eee793",
"sha256:b8d843714810ab88d59344507d4447be8b2cf12a49031363b6eed9f1b9b2280f"
"sha256:678bbfc8604eb246ed54e2063f0765f13b321a50526bdc8cb1f943eda7fa31f1",
"sha256:6b1de6886cae14c7c44d188f580f8ba8da05750f544c80ae5ad43375ab293cd5"
],
"index": "pypi",
"version": "==4.1.2"
"version": "==4.1.3"
},
"django-allow-cidr": {
"hashes": [
@ -222,6 +222,14 @@
"index": "pypi",
"version": "==2.4"
},
"django-fsm": {
"hashes": [
"sha256:e2c02cbf273fb9691aa9a907c29990afdd21a4adea09c5640344c93fbe03f8d9",
"sha256:fd9f8de9f33188e50f876ce53908fbd7289e5031a44ffdb97d43909e56699ef8"
],
"index": "pypi",
"version": "==2.8.1"
},
"django-widget-tweaks": {
"hashes": [
"sha256:9bfc5c705684754a83cc81da328b39ad1b80f32bd0f4340e2a810cbab4b0c00e",
@ -252,7 +260,7 @@
"hashes": [
"sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"
],
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'",
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==0.18.2"
},
"gunicorn": {
@ -507,18 +515,18 @@
},
"setuptools": {
"hashes": [
"sha256:512e5536220e38146176efb833d4a62aa726b7bbff82cfbc8ba9eaa3996e0b17",
"sha256:f62ea9da9ed6289bfe868cd6845968a2c854d1427f8548d52cae02a42b4f0356"
"sha256:d0b9a8433464d5800cbe05094acf5c6d52a91bfac9b52bcfc4d41382be5d5d31",
"sha256:e197a19aa8ec9722928f2206f8de752def0e4c9fc6953527360d1c36d94ddb2f"
],
"markers": "python_version >= '3.7'",
"version": "==65.5.0"
"version": "==65.5.1"
},
"six": {
"hashes": [
"sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
"sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'",
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.16.0"
},
"sqlparse": {
@ -576,7 +584,7 @@
"sha256:58d5c3d29f5a36ffeb94f02f0d786cd53014cf9b3b3951d42e0080d8a9498d30",
"sha256:ad9aa55b65ef2808eb405f46cf74df7fcb7044d5cbc26487f96eb2ef2e436693"
],
"markers": "python_version >= '3.6'",
"markers": "python_full_version >= '3.6.0'",
"version": "==4.11.1"
},
"black": {
@ -624,11 +632,11 @@
},
"django": {
"hashes": [
"sha256:26dc24f99c8956374a054bcbf58aab8dc0cad2e6ac82b0fe036b752c00eee793",
"sha256:b8d843714810ab88d59344507d4447be8b2cf12a49031363b6eed9f1b9b2280f"
"sha256:678bbfc8604eb246ed54e2063f0765f13b321a50526bdc8cb1f943eda7fa31f1",
"sha256:6b1de6886cae14c7c44d188f580f8ba8da05750f544c80ae5ad43375ab293cd5"
],
"index": "pypi",
"version": "==4.1.2"
"version": "==4.1.3"
},
"django-debug-toolbar": {
"hashes": [
@ -640,19 +648,19 @@
},
"django-stubs": {
"hashes": [
"sha256:0dff8ec0ba3abe046450b3d8a29ce9e72629893d2c1ef679189cc2bfdb6d2f64",
"sha256:ea8b35d0da49f7b2ee99a79125f1943e033431dd114726d6643cc35de619230e"
"sha256:424fdd1935f859a802365056f9ccf4db12d1d93a5ab3de6d5633dddba0c5fc76",
"sha256:eaecc1fc71532c1148f0c9687556651d880165476d7629bf318ff86a903a150c"
],
"index": "pypi",
"version": "==1.12.0"
"version": "==1.13.0"
},
"django-stubs-ext": {
"hashes": [
"sha256:9bd7418376ab00b7f88d6d56be9fece85bfa0c7c348ac621155fa4d7a91146f2",
"sha256:c5d8db53d29c756e7e3d0820a5a079a43bc38d8fab0e1b8bd5df2f3366c54b5a"
"sha256:4fd8cdbc68d1a421f21bb7e0d9e76d50f6a4b504d350ba786405daf536e90c21",
"sha256:d729fbc7fe8970a7e26b35956c35b48502516f011d523c0577bdfb02ed956284"
],
"markers": "python_version >= '3.6'",
"version": "==0.5.0"
"markers": "python_version >= '3.7'",
"version": "==0.7.0"
},
"django-webtest": {
"hashes": [
@ -675,7 +683,7 @@
"sha256:8033ad4e853066ba6ca92050b9df2f89301b8fc8bf7e9324d412a63f8bf1a8fd",
"sha256:bac2fd45c0a1c9cf619e63a90d62bdc63892ef92387424b855792a6cabe789aa"
],
"markers": "python_version >= '3.6'",
"markers": "python_full_version >= '3.6.0'",
"version": "==4.0.9"
},
"gitpython": {
@ -691,38 +699,44 @@
"sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325",
"sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"
],
"markers": "python_version >= '3.6'",
"markers": "python_full_version >= '3.6.0'",
"version": "==0.7.0"
},
"mypy": {
"hashes": [
"sha256:1021c241e8b6e1ca5a47e4d52601274ac078a89845cfde66c6d5f769819ffa1d",
"sha256:14d53cdd4cf93765aa747a7399f0961a365bcddf7855d9cef6306fa41de01c24",
"sha256:175f292f649a3af7082fe36620369ffc4661a71005aa9f8297ea473df5772046",
"sha256:26ae64555d480ad4b32a267d10cab7aec92ff44de35a7cd95b2b7cb8e64ebe3e",
"sha256:41fd1cf9bc0e1c19b9af13a6580ccb66c381a5ee2cf63ee5ebab747a4badeba3",
"sha256:5085e6f442003fa915aeb0a46d4da58128da69325d8213b4b35cc7054090aed5",
"sha256:58f27ebafe726a8e5ccb58d896451dd9a662a511a3188ff6a8a6a919142ecc20",
"sha256:6389af3e204975d6658de4fb8ac16f58c14e1bacc6142fee86d1b5b26aa52bda",
"sha256:724d36be56444f569c20a629d1d4ee0cb0ad666078d59bb84f8f887952511ca1",
"sha256:75838c649290d83a2b83a88288c1eb60fe7a05b36d46cbea9d22efc790002146",
"sha256:7b35ce03a289480d6544aac85fa3674f493f323d80ea7226410ed065cd46f206",
"sha256:85f7a343542dc8b1ed0a888cdd34dca56462654ef23aa673907305b260b3d746",
"sha256:86ebe67adf4d021b28c3f547da6aa2cce660b57f0432617af2cca932d4d378a6",
"sha256:8ee8c2472e96beb1045e9081de8e92f295b89ac10c4109afdf3a23ad6e644f3e",
"sha256:91781eff1f3f2607519c8b0e8518aad8498af1419e8442d5d0afb108059881fc",
"sha256:a692a8e7d07abe5f4b2dd32d731812a0175626a90a223d4b58f10f458747dd8a",
"sha256:a705a93670c8b74769496280d2fe6cd59961506c64f329bb179970ff1d24f9f8",
"sha256:c6e564f035d25c99fd2b863e13049744d96bd1947e3d3d2f16f5828864506763",
"sha256:cebca7fd333f90b61b3ef7f217ff75ce2e287482206ef4a8b18f32b49927b1a2",
"sha256:d6af646bd46f10d53834a8e8983e130e47d8ab2d4b7a97363e35b24e1d588947",
"sha256:e7aeaa763c7ab86d5b66ff27f68493d672e44c8099af636d433a7f3fa5596d40",
"sha256:eaa97b9ddd1dd9901a22a879491dbb951b5dec75c3b90032e2baa7336777363b",
"sha256:eb7a068e503be3543c4bd329c994103874fa543c1727ba5288393c21d912d795",
"sha256:f793e3dd95e166b66d50e7b63e69e58e88643d80a3dcc3bcd81368e0478b089c"
"sha256:0680389c34284287fe00e82fc8bccdea9aff318f7e7d55b90d967a13a9606013",
"sha256:1767830da2d1afa4e62b684647af0ff79b401f004d7fa08bc5b0ce2d45bcd5ec",
"sha256:1ee5f99817ee70254e7eb5cf97c1b11dda29c6893d846c8b07bce449184e9466",
"sha256:262c543ef24deb10470a3c1c254bb986714e2b6b1a67d66daf836a548a9f316c",
"sha256:269f0dfb6463b8780333310ff4b5134425157ef0d2b1d614015adaf6d6a7eabd",
"sha256:2a3150d409609a775c8cb65dbe305c4edd7fe576c22ea79d77d1454acd9aeda8",
"sha256:2b6f85c2ad378e3224e017904a051b26660087b3b76490d533b7344f1546d3ff",
"sha256:3227f14fe943524f5794679156488f18bf8d34bfecd4623cf76bc55958d229c5",
"sha256:3ff201a0c6d3ea029d73b1648943387d75aa052491365b101f6edd5570d018ea",
"sha256:46897755f944176fbc504178422a5a2875bbf3f7436727374724842c0987b5af",
"sha256:47a9955214615108c3480a500cfda8513a0b1cd3c09a1ed42764ca0dd7b931dd",
"sha256:49082382f571c3186ce9ea0bd627cb1345d4da8d44a8377870f4442401f0a706",
"sha256:4a8a6c10f4c63fbf6ad6c03eba22c9331b3946a4cec97f008e9ffb4d3b31e8e2",
"sha256:6826d9c4d85bbf6d68cb279b561de6a4d8d778ca8e9ab2d00ee768ab501a9852",
"sha256:72382cb609142dba3f04140d016c94b4092bc7b4d98ca718740dc989e5271b8d",
"sha256:7da0005e47975287a92b43276e460ac1831af3d23032c34e67d003388a0ce8d0",
"sha256:8798c8ed83aa809f053abff08664bdca056038f5a02af3660de00b7290b64c47",
"sha256:8f1940325a8ed460ba03d19ab83742260fa9534804c317224e5d4e5aa588e2d6",
"sha256:8f694d6d09a460b117dccb6857dda269188e3437c880d7b60fa0014fa872d1e9",
"sha256:9b8f4a8213b1fd4b751e26b59ae0e0c12896568d7e805861035c7a15ed6dc9eb",
"sha256:9d851c09b981a65d9d283a8ccb5b1d0b698e580493416a10942ef1a04b19fd37",
"sha256:aaf1be63e0207d7d17be942dcf9a6b641745581fe6c64df9a38deb562a7dbafa",
"sha256:aba38e3dd66bdbafbbfe9c6e79637841928ea4c79b32e334099463c17b0d90ef",
"sha256:b08541a06eed35b543ae1a6b301590eb61826a1eb099417676ddc5a42aa151c5",
"sha256:be88d665e76b452c26fb2bdc3d54555c01226fba062b004ede780b190a50f9db",
"sha256:c76c769c46a1e6062a84837badcb2a7b0cdb153d68601a61f60739c37d41cc74",
"sha256:cc6019808580565040cd2a561b593d7c3c646badd7e580e07d875eb1bf35c695",
"sha256:cd2dd3730ba894ec2a2082cc703fbf3e95a08479f7be84912e3131fc68809d46",
"sha256:d555aa7f44cecb7ea3c0ac69d58b1a5afb92caa017285a8e9c4efbf0518b61b4",
"sha256:d847dd23540e2912d9667602271e5ebf25e5788e7da46da5ffd98e7872616e8e"
],
"index": "pypi",
"version": "==0.982"
"version": "==0.990"
},
"mypy-extensions": {
"hashes": [
@ -757,18 +771,18 @@
},
"platformdirs": {
"hashes": [
"sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788",
"sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"
"sha256:0cb405749187a194f444c25c82ef7225232f11564721eabffc6ec70df83b11cb",
"sha256:6e52c21afff35cb659c6e52d8b4d61b9bd544557180440538f255d9382c8cbe0"
],
"markers": "python_version >= '3.7'",
"version": "==2.5.2"
"version": "==2.5.3"
},
"pycodestyle": {
"hashes": [
"sha256:2c9607871d58c76354b697b42f5d57e1ada7d261c261efac224b664affdc5785",
"sha256:d1735fc58b418fd7c5f658d28d943854f8a849b01a5d0a1e6f3f3fdd0166804b"
],
"markers": "python_version >= '3.6'",
"markers": "python_full_version >= '3.6.0'",
"version": "==2.9.1"
},
"pyflakes": {
@ -776,7 +790,7 @@
"sha256:4579f67d887f804e67edb544428f264b7b24f435b263c4614f384135cea553d2",
"sha256:491feb020dca48ccc562a8c0cbe8df07ee13078df59813b83959cbdada312ea3"
],
"markers": "python_version >= '3.6'",
"markers": "python_full_version >= '3.6.0'",
"version": "==2.5.0"
},
"pyyaml": {
@ -822,7 +836,7 @@
"sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174",
"sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"
],
"markers": "python_version >= '3.6'",
"markers": "python_full_version >= '3.6.0'",
"version": "==6.0"
},
"six": {
@ -830,7 +844,7 @@
"sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
"sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'",
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.16.0"
},
"smmap": {
@ -838,7 +852,7 @@
"sha256:2aba19d6a040e78d8b09de5c57e96207b09ed71d8e55ce0959eeee6c8e190d94",
"sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"
],
"markers": "python_version >= '3.6'",
"markers": "python_full_version >= '3.6.0'",
"version": "==5.0.0"
},
"soupsieve": {
@ -846,7 +860,7 @@
"sha256:3b2503d3c7084a42b1ebd08116e5f81aadfaea95863628c80a3b774a11b7c759",
"sha256:fc53893b3da2c33de295667a0e19f078c14bf86544af307354de5fcf12a3f30d"
],
"markers": "python_version >= '3.6'",
"markers": "python_full_version >= '3.6.0'",
"version": "==2.3.2.post1"
},
"sqlparse": {
@ -870,7 +884,7 @@
"sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc",
"sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"
],
"markers": "python_version >= '3.7'",
"markers": "python_version < '3.11'",
"version": "==2.0.1"
},
"types-cachetools": {
@ -883,10 +897,10 @@
},
"types-pytz": {
"hashes": [
"sha256:0c163b15d3e598e6cc7074a99ca9ec72b25dc1b446acc133b827667af0b7b09a",
"sha256:a8e1fe6a1b270fbfaf2553b20ad0f1316707cc320e596da903bb17d7373fed2d"
"sha256:bea605ce5d5a5d52a8e1afd7656c9b42476e18a0f888de6be91587355313ddf4",
"sha256:d078196374d1277e9f9984d49373ea043cf2c64d5d5c491fbc86c258557bd46f"
],
"version": "==2022.5.0.0"
"version": "==2022.6.0.1"
},
"types-pyyaml": {
"hashes": [
@ -931,7 +945,7 @@
"sha256:73aae30359291c14fa3b956f8b5ca31960e420c28c1bec002547fb04928cf89b",
"sha256:b64ef5141be559cfade448f044fa45c2260351edcb6a8ef6b7e00c7dcef0c323"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'",
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.8.7"
},
"webtest": {
@ -939,7 +953,7 @@
"sha256:2a001a9efa40d2a7e5d9cd8d1527c75f41814eb6afce2c3d207402547b1e5ead",
"sha256:54bd969725838d9861a9fa27f8d971f79d275d94ae255f5c501f53bb6d9929eb"
],
"markers": "python_version >= '3.6' and python_version < '4'",
"markers": "python_version < '4' and python_full_version >= '3.6.0'",
"version": "==3.0.0"
}
}

View file

@ -1,8 +1,6 @@
"""Internal API views"""
import re
from django.core.exceptions import BadRequest
from django.views.decorators.http import require_http_methods
from django.http import JsonResponse
@ -13,22 +11,11 @@ import requests
from cachetools.func import ttl_cache
from registrar.models import Website
DOMAIN_FILE_URL = (
"https://raw.githubusercontent.com/cisagov/dotgov-data/main/current-full.csv"
)
# 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}")
def string_could_be_domain(domain):
"""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 DOMAIN_REGEX.match(domain):
return True
return False
# this file doesn't change that often, nor is it that big, so cache the result
@ -48,7 +35,7 @@ def _domains():
# get the domain before the first comma
domain = line.split(",", 1)[0]
# sanity-check the string we got from the file here
if string_could_be_domain(domain):
if Website.string_could_be_domain(domain):
# lowercase everything when we put it in domains
domains.add(domain.lower())
return domains
@ -80,7 +67,10 @@ def available(request, domain=""):
"""
# validate that the given domain could be a domain name and fail early if
# not.
if not (string_could_be_domain(domain) or string_could_be_domain(domain + ".gov")):
if not (
Website.string_could_be_domain(domain)
or Website.string_could_be_domain(domain + ".gov")
):
raise BadRequest("Invalid request.")
# a domain is available if it is NOT in the list of current domains
return JsonResponse({"available": not in_domains(domain)})

View file

@ -1,13 +1,26 @@
/**
* @file get-gov.js includes custom code for the .gov registrar.
*
* Constants and helper functions are at the top.
* Event handlers are in the middle.
* Initialization (run-on-load) stuff goes at the bottom.
*/
/** Strings announced to assistive technology users. */
var ARIA = {
QUESTION_REMOVED: "Previous follow-up question removed",
QUESTION_ADDED: "New follow-up question required"
}
var DEFAULT_ERROR = "Please check this field for errors.";
var REQUIRED = "required";
var INPUT = "input";
/** Helper function. Makes an element invisible. */
// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>>
// Helper functions.
/** Makes an element invisible. */
function makeHidden(el) {
el.style.position = "absolute";
el.style.left = "-100vw";
@ -18,7 +31,7 @@ function makeHidden(el) {
el.style.visibility = "hidden";
}
/** Helper function. Makes visible a perviously hidden element. */
/** Makes visible a perviously hidden element. */
function makeVisible(el) {
el.style.position = "relative";
el.style.left = "unset";
@ -33,19 +46,19 @@ function forEachChild(el, selector, func) {
}
}
/** Helper function. Removes `required` attribute from input. */
/** Removes `required` attribute from input. */
const removeRequired = input => input.removeAttribute(REQUIRED);
/** Helper function. Adds `required` attribute to input. */
/** Adds `required` attribute to input. */
const setRequired = input => input.setAttribute(REQUIRED, "");
/** Helper function. Removes `checked` attribute from input. */
/** Removes `checked` attribute from input. */
const removeChecked = input => input.checked = false;
/** Helper function. Adds `checked` attribute to input. */
/** Adds `checked` attribute to input. */
const setChecked = input => input.checked = true;
/** Helper function. Creates and returns a live region element. */
/** Creates and returns a live region element. */
function createLiveRegion(id) {
const liveRegion = document.createElement("div");
liveRegion.setAttribute("role", "region");
@ -64,8 +77,8 @@ var radioToggles = {};
/**
* Helper function. Tracks state of selected radio button.
*
* Tracks state of selected radio button.
*
* This is required due to JavaScript not having a native
* event trigger for "deselect" on radio buttons. Tracking
* which button has been deselected (and hiding the associated
@ -75,7 +88,7 @@ function rememberSelected(radioButton) {
selected[radioButton.name] = radioButton;
}
/** Helper function. Announces changes to assistive technology users. */
/** Announces changes to assistive technology users. */
function announce(id, text) {
const liveRegion = document.getElementById(id + "-live-region");
liveRegion.innerHTML = text;
@ -123,6 +136,70 @@ function revealToggleable(e) {
}
}
/**
* Slow down event handlers by limiting how frequently they fire.
*
* A wait period must occur with no activity (activity means "this
* debounce function being called") before the handler is invoked.
*
* @param {Function} handler - any JS function
* @param {number} cooldown - the wait period, in milliseconds
*/
function debounce(handler, cooldown=600) {
let timeout;
return function(...args) {
const context = this;
clearTimeout(timeout);
timeout = setTimeout(() => handler.apply(context, args), cooldown);
}
}
/** Asyncronously fetches JSON. No error handling. */
function fetchJSON(endpoint, callback, url="/api/v1/") {
const xhr = new XMLHttpRequest();
xhr.open('GET', url + endpoint);
xhr.send();
xhr.onload = function() {
if (xhr.status != 200) return;
callback(JSON.parse(xhr.response));
};
// nothing, don't care
// xhr.onerror = function() { };
}
/** Modifies CSS and HTML when an input is valid/invalid. */
function toggleInputValidity(el, valid, msg=DEFAULT_ERROR) {
if (valid) {
el.setCustomValidity("");
el.removeAttribute("aria-invalid");
el.classList.remove('usa-input--error');
} else {
el.classList.remove('usa-input--success');
el.setAttribute("aria-invalid", "true");
el.setCustomValidity(msg);
// this is here for testing: in actual use, we might not want to
// visually display these errors until the user tries to submit
el.classList.add('usa-input--error');
}
}
function _checkDomainAvailability(e) {
const callback = (response) => {
toggleInputValidity(e.target, (response && response.available));
if (e.target.validity.valid) {
e.target.classList.add('usa-input--success');
// do other stuff, like display a toast?
}
}
fetchJSON(`available/${e.target.value}`, callback);
}
/** Call the API to see if the domain is good. */
const checkDomainAvailability = debounce(_checkDomainAvailability);
// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>>
// Event handlers.
/** On radio button selection change, handles associated toggleables. */
function handleToggle(e) {
// hide any previously visible HTML associated with previously selected radio buttons
@ -131,9 +208,42 @@ function handleToggle(e) {
revealToggleable(e);
}
/** On input change, handles running any associated validators. */
function handleInputValidation(e) {
const attribute = e.target.getAttribute("validate") || "";
if (!attribute.length) return;
const validators = attribute.split(" ");
let isInvalid = false;
for (const validator of validators) {
switch (validator) {
case "domain":
checkDomainAvailability(e);
break;
}
}
toggleInputValidity(e.target, !isInvalid);
}
// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>>
// Initialization code.
/**
* An IIFE that will attach validators to inputs.
*
* It looks for elements with `validate="<type> <type>"` and adds
* change handlers for each known type.
*/
(function validatorsInit() {
"use strict";
const needsValidation = document.querySelectorAll('[validate]');
for(const input of needsValidation) {
input.addEventListener('input', handleInputValidation);
}
})();
/**
* An IIFE that will hide any elements with `hide-on-load` attribute.
*
*
* Why not start with `hidden`? Because this is for use with form questions:
* if Javascript fails, users will still need access to those questions.
*/

View file

@ -86,6 +86,8 @@ INSTALLED_APPS = [
"djangooidc",
# library to simplify form templating
"widget_tweaks",
# library for Finite State Machine statuses
"django_fsm",
# let's be sure to install our own application!
"registrar",
# Our internal API application

View file

@ -0,0 +1,258 @@
# 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,3 @@
from .models import User, UserProfile
from .models import User, UserProfile, Contact, Website, DomainApplication
__all__ = ["User", "UserProfile"]
__all__ = ["User", "UserProfile", "Contact", "Website", "DomainApplication"]

View file

@ -1,7 +1,11 @@
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):
"""
@ -56,7 +60,7 @@ class AddressModel(models.Model):
# don't put anything else here, it will be ignored
class ContactModel(models.Model):
class ContactInfo(models.Model):
"""
An abstract base model that provides common fields
for contact information.
@ -71,7 +75,7 @@ class ContactModel(models.Model):
# don't put anything else here, it will be ignored
class UserProfile(TimeStampedModel, ContactModel, AddressModel):
class UserProfile(TimeStampedModel, ContactInfo, AddressModel):
user = models.OneToOneField(User, null=True, on_delete=models.CASCADE)
display_name = models.TextField()
@ -83,3 +87,204 @@ class UserProfile(TimeStampedModel, ContactModel, AddressModel):
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,64 @@
from django.test import TestCase
from django.db.utils import IntegrityError
from registrar.models import Contact, DomainApplication, User, Website
class TestDomainApplication(TestCase):
def test_empty_create_fails(self):
"""Can't create a completely empty domain application."""
with self.assertRaisesRegex(IntegrityError, "creator"):
DomainApplication.objects.create()
def test_minimal_create(self):
"""Can create with just a creator."""
user, _ = User.objects.get_or_create()
application = DomainApplication.objects.create(creator=user)
self.assertEquals(application.status, DomainApplication.STARTED)
def test_full_create(self):
"""Can create with all fields."""
user, _ = User.objects.get_or_create()
contact = Contact.objects.create()
com_website, _ = Website.objects.get_or_create(website="igorville.com")
gov_website, _ = Website.objects.get_or_create(website="igorville.gov")
application = DomainApplication.objects.create(
creator=user,
investigator=user,
organization_type=DomainApplication.FEDERAL,
federal_branch=DomainApplication.EXECUTIVE,
is_election_office=False,
organization_name="Test",
street_address="100 Main St.",
unit_type="APT",
unit_number="1A",
state_territory="CA",
zip_code="12345-6789",
authorizing_official=contact,
requested_domain=gov_website,
submitter=contact,
purpose="Igorville rules!",
security_email="security@igorville.gov",
anything_else="All of Igorville loves the dotgov program.",
acknowledged_policy=True,
)
application.current_websites.add(com_website)
application.alternative_domains.add(gov_website)
application.other_contacts.add(contact)
application.save()
def test_status_fsm_submit_fail(self):
user, _ = User.objects.get_or_create()
application = DomainApplication.objects.create(creator=user)
with self.assertRaises(ValueError):
# can't submit an application with a null domain name
application.submit()
def test_status_fsm_submit_succeed(self):
user, _ = User.objects.get_or_create()
site = Website.objects.create(website="igorville.gov")
application = DomainApplication.objects.create(
creator=user, requested_domain=site
)
application.submit()
self.assertEqual(application.status, application.SUBMITTED)