diff --git a/src/Pipfile b/src/Pipfile index af24b9e0d..2a94867a7 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 = "*" @@ -31,3 +32,4 @@ types-requests = "*" django-stubs = "*" django-webtest = "*" types-cachetools = "*" +graphviz = ">=0.4" diff --git a/src/Pipfile.lock b/src/Pipfile.lock index 70db413de..25ce7a6ae 100644 --- a/src/Pipfile.lock +++ b/src/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "1b7689dae771eaeee047ef75ed1da344ebc9d40fbb9ade689e9dba885e20ec59" + "sha256": "80ebab4c3aa382d11cc102ff541e23fd3a43e7943eb66ad58b2805304dbaf4fe" }, "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", @@ -507,11 +515,11 @@ }, "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": [ @@ -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": [ @@ -686,6 +694,14 @@ "markers": "python_version >= '3.7'", "version": "==3.1.29" }, + "graphviz": { + "hashes": [ + "sha256:587c58a223b51611c0cf461132da386edd896a029524ca61a1462b880bf97977", + "sha256:8c58f14adaa3b947daf26c19bc1e98c4e0702cdc31cf99153e6f06904d492bf8" + ], + "index": "pypi", + "version": "==0.20.1" + }, "mccabe": { "hashes": [ "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", @@ -696,33 +712,39 @@ }, "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,11 +779,11 @@ }, "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": [ @@ -870,7 +892,7 @@ "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" ], - "markers": "python_version >= '3.7'", + "markers": "python_full_version < '3.11.0a7'", "version": "==2.0.1" }, "types-cachetools": { @@ -883,10 +905,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": [ diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index 8f8441579..ae39f5819 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -90,6 +90,7 @@ INSTALLED_APPS = [ "registrar", # Our internal API application "api", + "django_fsm", ] # Middleware are routines for processing web requests. diff --git a/src/registrar/management/commands/graph_transitions.py b/src/registrar/management/commands/graph_transitions.py new file mode 100644 index 000000000..e21c98aab --- /dev/null +++ b/src/registrar/management/commands/graph_transitions.py @@ -0,0 +1,237 @@ +# -*- coding: utf-8; mode: django -*- +import graphviz +from optparse import make_option +from itertools import chain + +from django.core.management.base import BaseCommand +try: + from django.utils.encoding import force_text + _requires_system_checks = True +except ImportError: # Django >= 4.0 + from django.utils.encoding import force_str as force_text + from django.core.management.base import ALL_CHECKS + _requires_system_checks = ALL_CHECKS + +from django_fsm import FSMFieldMixin, GET_STATE, RETURN_VALUE + +try: + from django.db.models import get_apps, get_app, get_models, get_model + + NEW_META_API = False +except ImportError: + from django.apps import apps + + NEW_META_API = True + +from django import VERSION + +HAS_ARGPARSE = VERSION >= (1, 10) + + +def all_fsm_fields_data(model): + if NEW_META_API: + return [(field, model) for field in model._meta.get_fields() if isinstance(field, FSMFieldMixin)] + else: + return [(field, model) for field in model._meta.fields if isinstance(field, FSMFieldMixin)] + + +def node_name(field, state): + opts = field.model._meta + return "%s.%s.%s.%s" % (opts.app_label, opts.verbose_name.replace(" ", "_"), field.name, state) + + +def node_label(field, state): + if type(state) == int or (type(state) == bool and hasattr(field, "choices")): + return force_text(dict(field.choices).get(state)) + else: + return state + + +def generate_dot(fields_data): + result = graphviz.Digraph() + + for field, model in fields_data: + sources, targets, edges, any_targets, any_except_targets = set(), set(), set(), set(), set() + + # dump nodes and edges + for transition in field.get_all_transitions(model): + if transition.source == "*": + any_targets.add((transition.target, transition.name)) + elif transition.source == "+": + any_except_targets.add((transition.target, transition.name)) + else: + _targets = ( + (state for state in transition.target.allowed_states) + if isinstance(transition.target, (GET_STATE, RETURN_VALUE)) + else (transition.target,) + ) + source_name_pair = ( + ((state, node_name(field, state)) for state in transition.source.allowed_states) + if isinstance(transition.source, (GET_STATE, RETURN_VALUE)) + else ((transition.source, node_name(field, transition.source)),) + ) + for source, source_name in source_name_pair: + if transition.on_error: + on_error_name = node_name(field, transition.on_error) + targets.add((on_error_name, node_label(field, transition.on_error))) + edges.add((source_name, on_error_name, (("style", "dotted"),))) + for target in _targets: + add_transition(source, target, transition.name, source_name, field, sources, targets, edges) + + targets.update( + set((node_name(field, target), node_label(field, target)) for target, _ in chain(any_targets, any_except_targets)) + ) + for target, name in any_targets: + target_name = node_name(field, target) + all_nodes = sources | targets + for source_name, label in all_nodes: + sources.add((source_name, label)) + edges.add((source_name, target_name, (("label", name),))) + + for target, name in any_except_targets: + target_name = node_name(field, target) + all_nodes = sources | targets + all_nodes.remove(((target_name, node_label(field, target)))) + for source_name, label in all_nodes: + sources.add((source_name, label)) + edges.add((source_name, target_name, (("label", name),))) + + # construct subgraph + opts = field.model._meta + subgraph = graphviz.Digraph( + name="cluster_%s_%s_%s" % (opts.app_label, opts.object_name, field.name), + graph_attr={"label": "%s.%s.%s" % (opts.app_label, opts.object_name, field.name)}, + ) + + final_states = targets - sources + for name, label in final_states: + subgraph.node(name, label=label, shape="doublecircle") + for name, label in (sources | targets) - final_states: + subgraph.node(name, label=label, shape="circle") + if field.default: # Adding initial state notation + if label == field.default: + initial_name = node_name(field, "_initial") + subgraph.node(name=initial_name, label="", shape="point") + subgraph.edge(initial_name, name) + for source_name, target_name, attrs in edges: + subgraph.edge(source_name, target_name, **dict(attrs)) + + result.subgraph(subgraph) + + return result + + +def add_transition(transition_source, transition_target, transition_name, source_name, field, sources, targets, edges): + target_name = node_name(field, transition_target) + sources.add((source_name, node_label(field, transition_source))) + targets.add((target_name, node_label(field, transition_target))) + edges.add((source_name, target_name, (("label", transition_name),))) + + +def get_graphviz_layouts(): + try: + import graphviz + + return graphviz.backend.ENGINES + except Exception: + return {"sfdp", "circo", "twopi", "dot", "neato", "fdp", "osage", "patchwork"} + + +class Command(BaseCommand): + requires_system_checks = _requires_system_checks + + if not HAS_ARGPARSE: + option_list = BaseCommand.option_list + ( + make_option( + "--output", + "-o", + action="store", + dest="outputfile", + help=( + "Render output file. Type of output dependent on file extensions. " "Use png or jpg to render graph to image." + ), + ), + # NOQA + make_option( + "--layout", + "-l", + action="store", + dest="layout", + default="dot", + help=("Layout to be used by GraphViz for visualization. " "Layouts: %s." % " ".join(get_graphviz_layouts())), + ), + ) + args = "[appname[.model[.field]]]" + else: + + def add_arguments(self, parser): + parser.add_argument( + "--output", + "-o", + action="store", + dest="outputfile", + help=( + "Render output file. Type of output dependent on file extensions. " "Use png or jpg to render graph to image." + ), + ) + parser.add_argument( + "--layout", + "-l", + action="store", + dest="layout", + default="dot", + help=("Layout to be used by GraphViz for visualization. " "Layouts: %s." % " ".join(get_graphviz_layouts())), + ) + parser.add_argument("args", nargs="*", help=("[appname[.model[.field]]]")) + + help = "Creates a GraphViz dot file with transitions for selected fields" + + def render_output(self, graph, **options): + filename, format = options["outputfile"].rsplit(".", 1) + + graph.engine = options["layout"] + graph.format = format + graph.render(filename) + + def handle(self, *args, **options): + fields_data = [] + if len(args) != 0: + for arg in args: + field_spec = arg.split(".") + + if len(field_spec) == 1: + if NEW_META_API: + app = apps.get_app(field_spec[0]) + models = apps.get_models(app) + else: + app = get_app(field_spec[0]) + models = get_models(app) + for model in models: + fields_data += all_fsm_fields_data(model) + elif len(field_spec) == 2: + if NEW_META_API: + model = apps.get_model(field_spec[0], field_spec[1]) + else: + model = get_model(field_spec[0], field_spec[1]) + fields_data += all_fsm_fields_data(model) + elif len(field_spec) == 3: + if NEW_META_API: + model = apps.get_model(field_spec[0], field_spec[1]) + else: + model = get_model(field_spec[0], field_spec[1]) + fields_data += all_fsm_fields_data(model) + else: + if NEW_META_API: + for model in apps.get_models(): + fields_data += all_fsm_fields_data(model) + else: + for app in get_apps(): + for model in get_models(app): + fields_data += all_fsm_fields_data(model) + + dotdata = generate_dot(fields_data) + + if options["outputfile"]: + self.render_output(dotdata, **options) + else: + print(dotdata) diff --git a/src/registrar/migrations/0002_contact_website_domainapplication.py b/src/registrar/migrations/0002_contact_website_domainapplication.py new file mode 100644 index 000000000..2da8d2598 --- /dev/null +++ b/src/registrar/migrations/0002_contact_website_domainapplication.py @@ -0,0 +1,243 @@ +# Generated by Django 4.1.3 on 2022-11-07 19:39 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django_fsm + + +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(help_text="First name", null=True)), + ("middle_name", models.TextField(help_text="Middle name", null=True)), + ("last_name", models.TextField(help_text="Last name", null=True)), + ("title", models.TextField(help_text="Title", null=True)), + ("email", models.TextField(help_text="Email", null=True)), + ("phone", models.TextField(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, + protected=True, + ), + ), + ( + "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(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(help_text="ZIP code", max_length=10, null=True), + ), + ( + "purpose", + models.TextField(help_text="Purpose of the domain", null=True), + ), + ( + "security_email", + models.CharField( + help_text="Security email for public use", + max_length=320, + null=True, + ), + ), + ( + "anything_else", + models.TextField( + help_text="Anything else we should know?", null=True + ), + ), + ( + "acknowledged_policy", + models.BooleanField( + help_text="Acknowledged .gov acceptable use policy", null=True + ), + ), + ( + "alternative_domains", + models.ManyToManyField( + related_name="alternatives+", to="registrar.website" + ), + ), + ( + "authorizing_official", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="authorizing_official", + to="registrar.contact", + ), + ), + ( + "creator", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="applications_created", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "current_websites", + models.ManyToManyField( + related_name="current+", to="registrar.website" + ), + ), + ( + "investigator", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="applications_investigating", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "other_contacts", + models.ManyToManyField( + related_name="contact_applications", to="registrar.contact" + ), + ), + ( + "requested_domain", + models.ForeignKey( + help_text="The requested domain", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="requested+", + to="registrar.website", + ), + ), + ( + "submitter", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="submitted_applications", + to="registrar.contact", + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/src/registrar/models/__init__.py b/src/registrar/models/__init__.py index 4723061e4..65e7eb32d 100644 --- a/src/registrar/models/__init__.py +++ b/src/registrar/models/__init__.py @@ -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"] diff --git a/src/registrar/models/models.py b/src/registrar/models/models.py index bf6148dbf..39f9d0b8a 100644 --- a/src/registrar/models/models.py +++ b/src/registrar/models/models.py @@ -2,6 +2,10 @@ from django.core.exceptions import ObjectDoesNotExist from django.contrib.auth.models import AbstractUser from django.db import models +from django_fsm import FSMField, transition + +from ..api.views.available import string_could_be_domain + 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,165 @@ 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="") + + +class Contact(models.Model): + + """Contact information follows a similar pattern for each contact.""" + + first_name = models.TextField(null=True, help_text="First name") + middle_name = models.TextField(null=True, help_text="Middle name") + last_name = models.TextField(null=True, help_text="Last name") + title = models.TextField(null=True, help_text="Title") + email = models.TextField(null=True, help_text="Email") + phone = models.TextField(null=True, help_text="Phone") + + +class DomainApplication(TimeStampedModel): + + STARTED = "started" + SUBMITTED = "submitted" + INVESTIGATING = "investigating" + APPROVED = "approved" + STATUS_CHOICES = [ + (STARTED, STARTED), + (SUBMITTED, SUBMITTED), + (INVESTIGATING, INVESTIGATING), + (APPROVED, APPROVED), + ] + status = FSMField( + choices=STATUS_CHOICES, # possible states as an array of constants + default=STARTED, # sensible default + protected=True, # cannot change state directly, must use methods! + ) + 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 + + 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"), + ] + organization_type = models.CharField( + max_length=255, choices=ORGANIZATION_CHOICES, help_text="Type of Organization" + ) + + EXECUTIVE = "Executive" + JUDICIAL = "Judicial" + LEGISLATIVE = "Legislative" + BRANCH_CHOICES = [(x, x) for x in (EXECUTIVE, JUDICIAL, LEGISLATIVE)] + 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") + 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") + + 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+") + + 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" + ) + + def can_submit(self): + """Return True if this instance can be marked as submitted.""" + if not string_could_be_domain(requested_domain): + return False + return True + + @transition( + field="status", source=STARTED, target=SUBMITTED, conditions=[can_submit] + ) + def submit(self): + """Submit an application that is started.""" + # don't need to do anything inside this method although we could + pass + + diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py new file mode 100644 index 000000000..f1e31041f --- /dev/null +++ b/src/registrar/tests/test_models.py @@ -0,0 +1,48 @@ +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()