diff --git a/docs/architecture/decisions/0015-use-django-fsm.md b/docs/architecture/decisions/0015-use-django-fsm.md new file mode 100644 index 000000000..60f4cf902 --- /dev/null +++ b/docs/architecture/decisions/0015-use-django-fsm.md @@ -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 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. + diff --git a/docs/developer/README.md b/docs/developer/README.md index 131780f20..d58dd31ad 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -116,3 +116,10 @@ You can also compile the sass at any time using `npx gulp compile`. Similarly, y ## 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. diff --git a/src/Pipfile b/src/Pipfile index af24b9e0d..b94d5aed8 100644 --- a/src/Pipfile +++ b/src/Pipfile @@ -19,6 +19,7 @@ django-formtools = "*" django-widget-tweaks = "*" cachetools = "*" requests = "*" +django-fsm = "*" [dev-packages] django-debug-toolbar = "*" diff --git a/src/Pipfile.lock b/src/Pipfile.lock index 70db413de..b9abf9893 100644 --- a/src/Pipfile.lock +++ b/src/Pipfile.lock @@ -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" } } diff --git a/src/api/views.py b/src/api/views.py index c25f98b97..042a447e3 100644 --- a/src/api/views.py +++ b/src/api/views.py @@ -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}(? 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/tests/test_models.py b/src/registrar/tests/test_models.py new file mode 100644 index 000000000..761f25a22 --- /dev/null +++ b/src/registrar/tests/test_models.py @@ -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)