diff --git a/src/Pipfile b/src/Pipfile index 07b1db7151..80aab67c0f 100644 --- a/src/Pipfile +++ b/src/Pipfile @@ -22,6 +22,7 @@ django-widget-tweaks = "*" cachetools = "*" requests = "*" django-fsm = "2.8.1" +django-viewflow = "*" django-phonenumber-field = {extras = ["phonenumberslite"], version = "*"} boto3 = "*" typing-extensions ='*' diff --git a/src/Pipfile.lock b/src/Pipfile.lock index 76f2c914d9..71da6d0486 100644 --- a/src/Pipfile.lock +++ b/src/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "07f7bc9bda4099f96b18f8f063b487b121b82ae01de06a7f2e9013d56098a421" + "sha256": "723e4f6e15004caeb7bc55b5e32ce7981a51cf491abb9a0703831a0fa49fc07c" }, "pipfile-spec": 6, "requires": {}, @@ -32,37 +32,37 @@ }, "boto3": { "hashes": [ - "sha256:ba391982f6cada136c5bba99e85d7fe1bc4e157c53a22a78e4aca35d1b39152e", - "sha256:eecef248f8743ab30036cd9c916808a0892fc9036e1a35434d8222060c08bbd2" + "sha256:006800604c34382873521b20890b758eea7109d699696ece932131259d0a4658", + "sha256:d59642672b1f35f55f47b317693241ce53333816f47c9e72fcc8fd0e9adc6a87" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.35.91" + "version": "==1.36.23" }, "botocore": { "hashes": [ - "sha256:7b0b9c5954701fff4d2c516918f45641b04ff4ca92bbd9f5b37c0b80f8c14220", - "sha256:93de9d0f52f7e36a2c190d55520d3b2654f32c5a628fdd484bffa00bc7865e1d" + "sha256:886730e79495a2e153842725ebdf85185c8277cdf255b3b5879cd097ddc7fcc3", + "sha256:9feaa2d876f487e718a5fd80a35fa401042b518c0c75117d3e1ea39a567439e7" ], "markers": "python_version >= '3.8'", - "version": "==1.35.91" + "version": "==1.36.23" }, "cachetools": { "hashes": [ - "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292", - "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a" + "sha256:70f238fbba50383ef62e55c6aff6d9673175fe59f7c6782c7a0b9e38f4a9df95", + "sha256:b76651fdc3b24ead3c648bbdeeb940c1b04d365b38b4af66788f9ec4a81d42bb" ], "index": "pypi", "markers": "python_version >= '3.7'", - "version": "==5.5.0" + "version": "==5.5.1" }, "certifi": { "hashes": [ - "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", - "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db" + "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", + "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe" ], "markers": "python_version >= '3.6'", - "version": "==2024.12.14" + "version": "==2025.1.31" }, "cfenv": { "hashes": [ @@ -245,36 +245,40 @@ }, "cryptography": { "hashes": [ - "sha256:1923cb251c04be85eec9fda837661c67c1049063305d6be5721643c22dd4e2b7", - "sha256:37d76e6863da3774cd9db5b409a9ecfd2c71c981c38788d3fcfaf177f447b731", - "sha256:3c672a53c0fb4725a29c303be906d3c1fa99c32f58abe008a82705f9ee96f40b", - "sha256:404fdc66ee5f83a1388be54300ae978b2efd538018de18556dde92575e05defc", - "sha256:4ac4c9f37eba52cb6fbeaf5b59c152ea976726b865bd4cf87883a7e7006cc543", - "sha256:62901fb618f74d7d81bf408c8719e9ec14d863086efe4185afd07c352aee1d2c", - "sha256:660cb7312a08bc38be15b696462fa7cc7cd85c3ed9c576e81f4dc4d8b2b31591", - "sha256:708ee5f1bafe76d041b53a4f95eb28cdeb8d18da17e597d46d7833ee59b97ede", - "sha256:761817a3377ef15ac23cd7834715081791d4ec77f9297ee694ca1ee9c2c7e5eb", - "sha256:831c3c4d0774e488fdc83a1923b49b9957d33287de923d58ebd3cec47a0ae43f", - "sha256:84111ad4ff3f6253820e6d3e58be2cc2a00adb29335d4cacb5ab4d4d34f2a123", - "sha256:8b3e6eae66cf54701ee7d9c83c30ac0a1e3fa17be486033000f2a73a12ab507c", - "sha256:9e6fc8a08e116fb7c7dd1f040074c9d7b51d74a8ea40d4df2fc7aa08b76b9e6c", - "sha256:a01956ddfa0a6790d594f5b34fc1bfa6098aca434696a03cfdbe469b8ed79285", - "sha256:abc998e0c0eee3c8a1904221d3f67dcfa76422b23620173e28c11d3e626c21bd", - "sha256:b15492a11f9e1b62ba9d73c210e2416724633167de94607ec6069ef724fad092", - "sha256:be4ce505894d15d5c5037167ffb7f0ae90b7be6f2a98f9a5c3442395501c32fa", - "sha256:c5eb858beed7835e5ad1faba59e865109f3e52b3783b9ac21e7e47dc5554e289", - "sha256:cd4e834f340b4293430701e772ec543b0fbe6c2dea510a5286fe0acabe153a02", - "sha256:d2436114e46b36d00f8b72ff57e598978b37399d2786fd39793c36c6d5cb1c64", - "sha256:eb33480f1bad5b78233b0ad3e1b0be21e8ef1da745d8d2aecbb20671658b9053", - "sha256:eca27345e1214d1b9f9490d200f9db5a874479be914199194e746c893788d417", - "sha256:ed3534eb1090483c96178fcb0f8893719d96d5274dfde98aa6add34614e97c8e", - "sha256:f3f6fdfa89ee2d9d496e2c087cebef9d4fcbb0ad63c40e821b39f74bf48d9c5e", - "sha256:f53c2c87e0fb4b0c00fa9571082a057e37690a8f12233306161c8f4b819960b7", - "sha256:f5e7cb1e5e56ca0933b4873c0220a78b773b24d40d186b6738080b73d3d0a756", - "sha256:f677e1268c4e23420c3acade68fac427fffcb8d19d7df95ed7ad17cdef8404f4" + "sha256:00918d859aa4e57db8299607086f793fa7813ae2ff5a4637e318a25ef82730f7", + "sha256:1e8d181e90a777b63f3f0caa836844a1182f1f265687fac2115fcf245f5fbec3", + "sha256:1f9a92144fa0c877117e9748c74501bea842f93d21ee00b0cf922846d9d0b183", + "sha256:21377472ca4ada2906bc313168c9dc7b1d7ca417b63c1c3011d0c74b7de9ae69", + "sha256:24979e9f2040c953a94bf3c6782e67795a4c260734e5264dceea65c8f4bae64a", + "sha256:2a46a89ad3e6176223b632056f321bc7de36b9f9b93b2cc1cccf935a3849dc62", + "sha256:322eb03ecc62784536bc173f1483e76747aafeb69c8728df48537eb431cd1911", + "sha256:436df4f203482f41aad60ed1813811ac4ab102765ecae7a2bbb1dbb66dcff5a7", + "sha256:4f422e8c6a28cf8b7f883eb790695d6d45b0c385a2583073f3cec434cc705e1a", + "sha256:53f23339864b617a3dfc2b0ac8d5c432625c80014c25caac9082314e9de56f41", + "sha256:5fed5cd6102bb4eb843e3315d2bf25fede494509bddadb81e03a859c1bc17b83", + "sha256:610a83540765a8d8ce0f351ce42e26e53e1f774a6efb71eb1b41eb01d01c3d12", + "sha256:6c8acf6f3d1f47acb2248ec3ea261171a671f3d9428e34ad0357148d492c7864", + "sha256:6f76fdd6fd048576a04c5210d53aa04ca34d2ed63336d4abd306d0cbe298fddf", + "sha256:72198e2b5925155497a5a3e8c216c7fb3e64c16ccee11f0e7da272fa93b35c4c", + "sha256:887143b9ff6bad2b7570da75a7fe8bbf5f65276365ac259a5d2d5147a73775f2", + "sha256:888fcc3fce0c888785a4876ca55f9f43787f4c5c1cc1e2e0da71ad481ff82c5b", + "sha256:8e6a85a93d0642bd774460a86513c5d9d80b5c002ca9693e63f6e540f1815ed0", + "sha256:94f99f2b943b354a5b6307d7e8d19f5c423a794462bde2bf310c770ba052b1c4", + "sha256:9b336599e2cb77b1008cb2ac264b290803ec5e8e89d618a5e978ff5eb6f715d9", + "sha256:a2d8a7045e1ab9b9f803f0d9531ead85f90c5f2859e653b61497228b18452008", + "sha256:b8272f257cf1cbd3f2e120f14c68bff2b6bdfcc157fafdee84a1b795efd72862", + "sha256:bf688f615c29bfe9dfc44312ca470989279f0e94bb9f631f85e3459af8efc009", + "sha256:d9c5b9f698a83c8bd71e0f4d3f9f839ef244798e5ffe96febfa9714717db7af7", + "sha256:dd7c7e2d71d908dc0f8d2027e1604102140d84b155e658c20e8ad1304317691f", + "sha256:df978682c1504fc93b3209de21aeabf2375cb1571d4e61907b3e7a2540e83026", + "sha256:e403f7f766ded778ecdb790da786b418a9f2394f36e8cc8b796cc056ab05f44f", + "sha256:eb3889330f2a4a148abead555399ec9a32b13b7c8ba969b72d8e500eb7ef84cd", + "sha256:f4daefc971c2d1f82f03097dc6f216744a6cd2ac0f04c68fb935ea2ba2a0d420", + "sha256:f51f5705ab27898afda1aaa430f34ad90dc117421057782022edf0600bec5f14", + "sha256:fd0ee90072861e276b0ff08bd627abec29e32a53b2be44e41dbcdf87cbee2b00" ], "markers": "python_version >= '3.7' and python_full_version not in '3.9.0, 3.9.1'", - "version": "==44.0.0" + "version": "==44.0.1" }, "defusedxml": { "hashes": [ @@ -349,12 +353,12 @@ }, "django-cors-headers": { "hashes": [ - "sha256:14d76b4b4c8d39375baeddd89e4f08899051eeaf177cb02a29bd6eae8cf63aa8", - "sha256:8edbc0497e611c24d5150e0055d3b178c6534b8ed826fb6f53b21c63f5d48ba3" + "sha256:6fdf31bf9c6d6448ba09ef57157db2268d515d94fc5c89a0a1028e1fc03ee52b", + "sha256:f1c125dcd58479fe7a67fe2499c16ee38b81b397463cf025f0e2c42937421070" ], "index": "pypi", "markers": "python_version >= '3.9'", - "version": "==4.6.0" + "version": "==4.7.0" }, "django-csp": { "hashes": [ @@ -364,6 +368,14 @@ "index": "pypi", "version": "==3.8" }, + "django-filter": { + "hashes": [ + "sha256:1ec9eef48fa8da1c0ac9b411744b16c3f4c31176c867886e4c48da369c407153", + "sha256:4fa48677cf5857b9b1347fed23e355ea792464e0fe07244d1fdfb8a806215b80" + ], + "markers": "python_version >= '3.9'", + "version": "==25.1" + }, "django-fsm": { "hashes": [ "sha256:e2c02cbf273fb9691aa9a907c29990afdd21a4adea09c5640344c93fbe03f8d9", @@ -374,12 +386,12 @@ }, "django-import-export": { "hashes": [ - "sha256:91b47c9a2701a5b039667df5c46ee682a41bb224ac215a0e66b177a459e35983", - "sha256:b261f44aedf572a69f975655afba15bff1e354eddd91d9c1bbd32d3cee44168d" + "sha256:143611d9d3c8b000c54e33db122c8d283eef38167acd7a854d15b9ad77c328e7", + "sha256:61e078cea307a6199a7e905840640e55db3521d7c81be224fd8b6576ce9bd0fc" ], "index": "pypi", "markers": "python_version >= '3.9'", - "version": "==4.3.3" + "version": "==4.3.5" }, "django-login-required-middleware": { "hashes": [ @@ -399,6 +411,14 @@ "markers": "python_version >= '3.8'", "version": "==8.0.0" }, + "django-viewflow": { + "hashes": [ + "sha256:4a43cbc3f0aac694cefbb44a2ad6c2415c3e6779114b1975f4f28e0c8db494ff" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==2.2.9" + }, "django-waffle": { "hashes": [ "sha256:774f45b929627c9d303620c85419ce1da54066f2082d741af014f5bbd747e372", @@ -422,20 +442,20 @@ "django" ], "hashes": [ - "sha256:9d2080cf25807a26fc0d4301e2d7b62c64fbf547540f21e3a30cc02bc5fbe948", - "sha256:e068ae3174cef52ba4b95ead22e639056a02465f616e62323e04ae08e86a75a4" + "sha256:03db7ee2d50ec697b68814cd175a3a05a7c7954804e4e419ca8b570dc5a835cf", + "sha256:45bc56f1d53bbc59d8dd69bba97377dd88ec28b8229d81cedbd455b21789445b" ], - "markers": "python_version >= '3.8'", - "version": "==11.2.1" + "markers": "python_version >= '3.9'", + "version": "==14.1.1" }, "faker": { "hashes": [ - "sha256:1c925fc0e86a51fc46648b504078c88d0cd48da1da2595c4e712841cab43a1e4", - "sha256:d30c5f0e2796b8970de68978365247657486eb0311c5abe88d0b895b68dff05d" + "sha256:7cb2bbd4c8f040e4a340ae4019e9a48b6cf1db6a71bda4e5a61d8d13b7bef28d", + "sha256:ad1f1be7fd692ec0256517404a9d7f007ab36ac5d4674082fa72404049725eaa" ], "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==33.1.0" + "markers": "python_version >= '3.9'", + "version": "==36.1.1" }, "fred-epplib": { "git": "https://github.com/cisagov/epplib.git", @@ -608,155 +628,155 @@ }, "lxml": { "hashes": [ - "sha256:01220dca0d066d1349bd6a1726856a78f7929f3878f7e2ee83c296c69495309e", - "sha256:02ced472497b8362c8e902ade23e3300479f4f43e45f4105c85ef43b8db85229", - "sha256:052d99051e77a4f3e8482c65014cf6372e61b0a6f4fe9edb98503bb5364cfee3", - "sha256:07da23d7ee08577760f0a71d67a861019103e4812c87e2fab26b039054594cc5", - "sha256:094cb601ba9f55296774c2d57ad68730daa0b13dc260e1f941b4d13678239e70", - "sha256:0a7056921edbdd7560746f4221dca89bb7a3fe457d3d74267995253f46343f15", - "sha256:0c120f43553ec759f8de1fee2f4794452b0946773299d44c36bfe18e83caf002", - "sha256:0d7b36afa46c97875303a94e8f3ad932bf78bace9e18e603f2085b652422edcd", - "sha256:0fdf3a3059611f7585a78ee10399a15566356116a4288380921a4b598d807a22", - "sha256:109fa6fede314cc50eed29e6e56c540075e63d922455346f11e4d7a036d2b8cf", - "sha256:146173654d79eb1fc97498b4280c1d3e1e5d58c398fa530905c9ea50ea849b22", - "sha256:1473427aff3d66a3fa2199004c3e601e6c4500ab86696edffdbc84954c72d832", - "sha256:1483fd3358963cc5c1c9b122c80606a3a79ee0875bcac0204149fa09d6ff2727", - "sha256:168f2dfcfdedf611eb285efac1516c8454c8c99caf271dccda8943576b67552e", - "sha256:17e8d968d04a37c50ad9c456a286b525d78c4a1c15dd53aa46c1d8e06bf6fa30", - "sha256:18feb4b93302091b1541221196a2155aa296c363fd233814fa11e181adebc52f", - "sha256:1afe0a8c353746e610bd9031a630a95bcfb1a720684c3f2b36c4710a0a96528f", - "sha256:1d04f064bebdfef9240478f7a779e8c5dc32b8b7b0b2fc6a62e39b928d428e51", - "sha256:1fdc9fae8dd4c763e8a31e7630afef517eab9f5d5d31a278df087f307bf601f4", - "sha256:1ffc23010330c2ab67fac02781df60998ca8fe759e8efde6f8b756a20599c5de", - "sha256:20094fc3f21ea0a8669dc4c61ed7fa8263bd37d97d93b90f28fc613371e7a875", - "sha256:213261f168c5e1d9b7535a67e68b1f59f92398dd17a56d934550837143f79c42", - "sha256:218c1b2e17a710e363855594230f44060e2025b05c80d1f0661258142b2add2e", - "sha256:23e0553b8055600b3bf4a00b255ec5c92e1e4aebf8c2c09334f8368e8bd174d6", - "sha256:25f1b69d41656b05885aa185f5fdf822cb01a586d1b32739633679699f220391", - "sha256:2b3778cb38212f52fac9fe913017deea2fdf4eb1a4f8e4cfc6b009a13a6d3fcc", - "sha256:2bc9fd5ca4729af796f9f59cd8ff160fe06a474da40aca03fcc79655ddee1a8b", - "sha256:2c226a06ecb8cdef28845ae976da407917542c5e6e75dcac7cc33eb04aaeb237", - "sha256:2c3406b63232fc7e9b8783ab0b765d7c59e7c59ff96759d8ef9632fca27c7ee4", - "sha256:2c86bf781b12ba417f64f3422cfc302523ac9cd1d8ae8c0f92a1c66e56ef2e86", - "sha256:2d9b8d9177afaef80c53c0a9e30fa252ff3036fb1c6494d427c066a4ce6a282f", - "sha256:2dec2d1130a9cda5b904696cec33b2cfb451304ba9081eeda7f90f724097300a", - "sha256:2dfab5fa6a28a0b60a20638dc48e6343c02ea9933e3279ccb132f555a62323d8", - "sha256:2ecdd78ab768f844c7a1d4a03595038c166b609f6395e25af9b0f3f26ae1230f", - "sha256:315f9542011b2c4e1d280e4a20ddcca1761993dda3afc7a73b01235f8641e903", - "sha256:36aef61a1678cb778097b4a6eeae96a69875d51d1e8f4d4b491ab3cfb54b5a03", - "sha256:384aacddf2e5813a36495233b64cb96b1949da72bef933918ba5c84e06af8f0e", - "sha256:3879cc6ce938ff4eb4900d901ed63555c778731a96365e53fadb36437a131a99", - "sha256:3c174dc350d3ec52deb77f2faf05c439331d6ed5e702fc247ccb4e6b62d884b7", - "sha256:3eb44520c4724c2e1a57c0af33a379eee41792595023f367ba3952a2d96c2aab", - "sha256:406246b96d552e0503e17a1006fd27edac678b3fcc9f1be71a2f94b4ff61528d", - "sha256:41ce1f1e2c7755abfc7e759dc34d7d05fd221723ff822947132dc934d122fe22", - "sha256:423b121f7e6fa514ba0c7918e56955a1d4470ed35faa03e3d9f0e3baa4c7e492", - "sha256:44264ecae91b30e5633013fb66f6ddd05c006d3e0e884f75ce0b4755b3e3847b", - "sha256:482c2f67761868f0108b1743098640fbb2a28a8e15bf3f47ada9fa59d9fe08c3", - "sha256:4b0c7a688944891086ba192e21c5229dea54382f4836a209ff8d0a660fac06be", - "sha256:4c1fefd7e3d00921c44dc9ca80a775af49698bbfd92ea84498e56acffd4c5469", - "sha256:4e109ca30d1edec1ac60cdbe341905dc3b8f55b16855e03a54aaf59e51ec8c6f", - "sha256:501d0d7e26b4d261fca8132854d845e4988097611ba2531408ec91cf3fd9d20a", - "sha256:516f491c834eb320d6c843156440fe7fc0d50b33e44387fcec5b02f0bc118a4c", - "sha256:51806cfe0279e06ed8500ce19479d757db42a30fd509940b1701be9c86a5ff9a", - "sha256:562e7494778a69086f0312ec9689f6b6ac1c6b65670ed7d0267e49f57ffa08c4", - "sha256:56b9861a71575f5795bde89256e7467ece3d339c9b43141dbdd54544566b3b94", - "sha256:5b8f5db71b28b8c404956ddf79575ea77aa8b1538e8b2ef9ec877945b3f46442", - "sha256:5c2fb570d7823c2bbaf8b419ba6e5662137f8166e364a8b2b91051a1fb40ab8b", - "sha256:5c54afdcbb0182d06836cc3d1be921e540be3ebdf8b8a51ee3ef987537455f84", - "sha256:5d6a6972b93c426ace71e0be9a6f4b2cfae9b1baed2eed2006076a746692288c", - "sha256:609251a0ca4770e5a8768ff902aa02bf636339c5a93f9349b48eb1f606f7f3e9", - "sha256:62d172f358f33a26d6b41b28c170c63886742f5b6772a42b59b4f0fa10526cb1", - "sha256:62f7fdb0d1ed2065451f086519865b4c90aa19aed51081979ecd05a21eb4d1be", - "sha256:658f2aa69d31e09699705949b5fc4719cbecbd4a97f9656a232e7d6c7be1a367", - "sha256:65ab5685d56914b9a2a34d67dd5488b83213d680b0c5d10b47f81da5a16b0b0e", - "sha256:68934b242c51eb02907c5b81d138cb977b2129a0a75a8f8b60b01cb8586c7b21", - "sha256:68b87753c784d6acb8a25b05cb526c3406913c9d988d51f80adecc2b0775d6aa", - "sha256:69959bd3167b993e6e710b99051265654133a98f20cec1d9b493b931942e9c16", - "sha256:6a7095eeec6f89111d03dabfe5883a1fd54da319c94e0fb104ee8f23616b572d", - "sha256:6b038cc86b285e4f9fea2ba5ee76e89f21ed1ea898e287dc277a25884f3a7dfe", - "sha256:6ba0d3dcac281aad8a0e5b14c7ed6f9fa89c8612b47939fc94f80b16e2e9bc83", - "sha256:6e91cf736959057f7aac7adfc83481e03615a8e8dd5758aa1d95ea69e8931dba", - "sha256:6ee8c39582d2652dcd516d1b879451500f8db3fe3607ce45d7c5957ab2596040", - "sha256:6f651ebd0b21ec65dfca93aa629610a0dbc13dbc13554f19b0113da2e61a4763", - "sha256:71a8dd38fbd2f2319136d4ae855a7078c69c9a38ae06e0c17c73fd70fc6caad8", - "sha256:74068c601baff6ff021c70f0935b0c7bc528baa8ea210c202e03757c68c5a4ff", - "sha256:7437237c6a66b7ca341e868cda48be24b8701862757426852c9b3186de1da8a2", - "sha256:747a3d3e98e24597981ca0be0fd922aebd471fa99d0043a3842d00cdcad7ad6a", - "sha256:74bcb423462233bc5d6066e4e98b0264e7c1bed7541fff2f4e34fe6b21563c8b", - "sha256:78d9b952e07aed35fe2e1a7ad26e929595412db48535921c5013edc8aa4a35ce", - "sha256:7b1cd427cb0d5f7393c31b7496419da594fe600e6fdc4b105a54f82405e6626c", - "sha256:7d3d1ca42870cdb6d0d29939630dbe48fa511c203724820fc0fd507b2fb46577", - "sha256:7e2f58095acc211eb9d8b5771bf04df9ff37d6b87618d1cbf85f92399c98dae8", - "sha256:7f41026c1d64043a36fda21d64c5026762d53a77043e73e94b71f0521939cc71", - "sha256:81b4e48da4c69313192d8c8d4311e5d818b8be1afe68ee20f6385d0e96fc9512", - "sha256:86a6b24b19eaebc448dc56b87c4865527855145d851f9fc3891673ff97950540", - "sha256:874a216bf6afaf97c263b56371434e47e2c652d215788396f60477540298218f", - "sha256:89e043f1d9d341c52bf2af6d02e6adde62e0a46e6755d5eb60dc6e4f0b8aeca2", - "sha256:8c72e9563347c7395910de6a3100a4840a75a6f60e05af5e58566868d5eb2d6a", - "sha256:8dc2c0395bea8254d8daebc76dcf8eb3a95ec2a46fa6fae5eaccee366bfe02ce", - "sha256:8f0de2d390af441fe8b2c12626d103540b5d850d585b18fcada58d972b74a74e", - "sha256:92e67a0be1639c251d21e35fe74df6bcc40cba445c2cda7c4a967656733249e2", - "sha256:94d6c3782907b5e40e21cadf94b13b0842ac421192f26b84c45f13f3c9d5dc27", - "sha256:97acf1e1fd66ab53dacd2c35b319d7e548380c2e9e8c54525c6e76d21b1ae3b1", - "sha256:9ada35dd21dc6c039259596b358caab6b13f4db4d4a7f8665764d616daf9cc1d", - "sha256:9c52100e2c2dbb0649b90467935c4b0de5528833c76a35ea1a2691ec9f1ee7a1", - "sha256:9e41506fec7a7f9405b14aa2d5c8abbb4dbbd09d88f9496958b6d00cb4d45330", - "sha256:9e4b47ac0f5e749cfc618efdf4726269441014ae1d5583e047b452a32e221920", - "sha256:9fb81d2824dff4f2e297a276297e9031f46d2682cafc484f49de182aa5e5df99", - "sha256:a0eabd0a81625049c5df745209dc7fcef6e2aea7793e5f003ba363610aa0a3ff", - "sha256:a3d819eb6f9b8677f57f9664265d0a10dd6551d227afb4af2b9cd7bdc2ccbf18", - "sha256:a87de7dd873bf9a792bf1e58b1c3887b9264036629a5bf2d2e6579fe8e73edff", - "sha256:aa617107a410245b8660028a7483b68e7914304a6d4882b5ff3d2d3eb5948d8c", - "sha256:aac0bbd3e8dd2d9c45ceb82249e8bdd3ac99131a32b4d35c8af3cc9db1657179", - "sha256:ab6dd83b970dc97c2d10bc71aa925b84788c7c05de30241b9e96f9b6d9ea3080", - "sha256:ace2c2326a319a0bb8a8b0e5b570c764962e95818de9f259ce814ee666603f19", - "sha256:ae5fe5c4b525aa82b8076c1a59d642c17b6e8739ecf852522c6321852178119d", - "sha256:b11a5d918a6216e521c715b02749240fb07ae5a1fefd4b7bf12f833bc8b4fe70", - "sha256:b1c8c20847b9f34e98080da785bb2336ea982e7f913eed5809e5a3c872900f32", - "sha256:b369d3db3c22ed14c75ccd5af429086f166a19627e84a8fdade3f8f31426e52a", - "sha256:b710bc2b8292966b23a6a0121f7a6c51d45d2347edcc75f016ac123b8054d3f2", - "sha256:bd96517ef76c8654446fc3db9242d019a1bb5fe8b751ba414765d59f99210b79", - "sha256:c00f323cc00576df6165cc9d21a4c21285fa6b9989c5c39830c3903dc4303ef3", - "sha256:c162b216070f280fa7da844531169be0baf9ccb17263cf5a8bf876fcd3117fa5", - "sha256:c1a69e58a6bb2de65902051d57fde951febad631a20a64572677a1052690482f", - "sha256:c1f794c02903c2824fccce5b20c339a1a14b114e83b306ff11b597c5f71a1c8d", - "sha256:c24037349665434f375645fa9d1f5304800cec574d0310f618490c871fd902b3", - "sha256:c300306673aa0f3ed5ed9372b21867690a17dba38c68c44b287437c362ce486b", - "sha256:c56a1d43b2f9ee4786e4658c7903f05da35b923fb53c11025712562d5cc02753", - "sha256:c6379f35350b655fd817cd0d6cbeef7f265f3ae5fedb1caae2eb442bbeae9ab9", - "sha256:c802e1c2ed9f0c06a65bc4ed0189d000ada8049312cfeab6ca635e39c9608957", - "sha256:cb83f8a875b3d9b458cada4f880fa498646874ba4011dc974e071a0a84a1b033", - "sha256:cf120cce539453ae086eacc0130a324e7026113510efa83ab42ef3fcfccac7fb", - "sha256:dd36439be765e2dde7660212b5275641edbc813e7b24668831a5c8ac91180656", - "sha256:dd5350b55f9fecddc51385463a4f67a5da829bc741e38cf689f38ec9023f54ab", - "sha256:df5c7333167b9674aa8ae1d4008fa4bc17a313cc490b2cca27838bbdcc6bb15b", - "sha256:e63601ad5cd8f860aa99d109889b5ac34de571c7ee902d6812d5d9ddcc77fa7d", - "sha256:e92ce66cd919d18d14b3856906a61d3f6b6a8500e0794142338da644260595cd", - "sha256:e99f5507401436fdcc85036a2e7dc2e28d962550afe1cbfc07c40e454256a859", - "sha256:ea2e2f6f801696ad7de8aec061044d6c8c0dd4037608c7cab38a9a4d316bfb11", - "sha256:eafa2c8658f4e560b098fe9fc54539f86528651f61849b22111a9b107d18910c", - "sha256:ecd4ad8453ac17bc7ba3868371bffb46f628161ad0eefbd0a855d2c8c32dd81a", - "sha256:ee70d08fd60c9565ba8190f41a46a54096afa0eeb8f76bd66f2c25d3b1b83005", - "sha256:eec1bb8cdbba2925bedc887bc0609a80e599c75b12d87ae42ac23fd199445654", - "sha256:ef0c1fe22171dd7c7c27147f2e9c3e86f8bdf473fed75f16b0c2e84a5030ce80", - "sha256:f2901429da1e645ce548bf9171784c0f74f0718c3f6150ce166be39e4dd66c3e", - "sha256:f422a209d2455c56849442ae42f25dbaaba1c6c3f501d58761c619c7836642ec", - "sha256:f65e5120863c2b266dbcc927b306c5b78e502c71edf3295dfcb9501ec96e5fc7", - "sha256:f7d4a670107d75dfe5ad080bed6c341d18c4442f9378c9f58e5851e86eb79965", - "sha256:f914c03e6a31deb632e2daa881fe198461f4d06e57ac3d0e05bbcab8eae01945", - "sha256:fb66442c2546446944437df74379e9cf9e9db353e61301d1a0e26482f43f0dd8" + "sha256:016b96c58e9a4528219bb563acf1aaaa8bc5452e7651004894a973f03b84ba81", + "sha256:05123fad495a429f123307ac6d8fd6f977b71e9a0b6d9aeeb8f80c017cb17131", + "sha256:057e30d0012439bc54ca427a83d458752ccda725c1c161cc283db07bcad43cf9", + "sha256:06a20d607a86fccab2fc15a77aa445f2bdef7b49ec0520a842c5c5afd8381576", + "sha256:094b28ed8a8a072b9e9e2113a81fda668d2053f2ca9f2d202c2c8c7c2d6516b1", + "sha256:0bcfadea3cdc68e678d2b20cb16a16716887dd00a881e16f7d806c2138b8ff0c", + "sha256:0d6b2fa86becfa81f0a0271ccb9eb127ad45fb597733a77b92e8a35e53414914", + "sha256:0f2cfae0688fd01f7056a17367e3b84f37c545fb447d7282cf2c242b16262607", + "sha256:106b7b5d2977b339f1e97efe2778e2ab20e99994cbb0ec5e55771ed0795920c8", + "sha256:133f3493253a00db2c870d3740bc458ebb7d937bd0a6a4f9328373e0db305709", + "sha256:136bf638d92848a939fd8f0e06fcf92d9f2e4b57969d94faae27c55f3d85c05b", + "sha256:155e1a5693cf4b55af652f5c0f78ef36596c7f680ff3ec6eb4d7d85367259b2c", + "sha256:1637fa31ec682cd5760092adfabe86d9b718a75d43e65e211d5931809bc111e7", + "sha256:172d65f7c72a35a6879217bcdb4bb11bc88d55fb4879e7569f55616062d387c2", + "sha256:17b5d7f8acf809465086d498d62a981fa6a56d2718135bb0e4aa48c502055f5c", + "sha256:198bb4b4dd888e8390afa4f170d4fa28467a7eaf857f1952589f16cfbb67af27", + "sha256:1b6f92e35e2658a5ed51c6634ceb5ddae32053182851d8cad2a5bc102a359b33", + "sha256:1b92fe86e04f680b848fff594a908edfa72b31bfc3499ef7433790c11d4c8cd8", + "sha256:1bcc211542f7af6f2dfb705f5f8b74e865592778e6cafdfd19c792c244ccce19", + "sha256:1c93ed3c998ea8472be98fb55aed65b5198740bfceaec07b2eba551e55b7b9ae", + "sha256:203b1d3eaebd34277be06a3eb880050f18a4e4d60861efba4fb946e31071a295", + "sha256:22ec2b3c191f43ed21f9545e9df94c37c6b49a5af0a874008ddc9132d49a2d9c", + "sha256:231cf4d140b22a923b1d0a0a4e0b4f972e5893efcdec188934cc65888fd0227b", + "sha256:236610b77589faf462337b3305a1be91756c8abc5a45ff7ca8f245a71c5dab70", + "sha256:29bfc8d3d88e56ea0a27e7c4897b642706840247f59f4377d81be8f32aa0cfbf", + "sha256:2b8969dbc8d09d9cd2ae06362c3bad27d03f433252601ef658a49bd9f2b22d79", + "sha256:2dd0b80ac2d8f13ffc906123a6f20b459cb50a99222d0da492360512f3e50f84", + "sha256:2df7ed5edeb6bd5590914cd61df76eb6cce9d590ed04ec7c183cf5509f73530d", + "sha256:2e4a570f6a99e96c457f7bec5ad459c9c420ee80b99eb04cbfcfe3fc18ec6423", + "sha256:2f1be45d4c15f237209bbf123a0e05b5d630c8717c42f59f31ea9eae2ad89394", + "sha256:2f23cf50eccb3255b6e913188291af0150d89dab44137a69e14e4dcb7be981f1", + "sha256:3031e4c16b59424e8d78522c69b062d301d951dc55ad8685736c3335a97fc270", + "sha256:33e06717c00c788ab4e79bc4726ecc50c54b9bfb55355eae21473c145d83c2d2", + "sha256:364de8f57d6eda0c16dcfb999af902da31396949efa0e583e12675d09709881b", + "sha256:3715cdf0dd31b836433af9ee9197af10e3df41d273c19bb249230043667a5dfd", + "sha256:3bb8149840daf2c3f97cebf00e4ed4a65a0baff888bf2605a8d0135ff5cf764e", + "sha256:3c3c8b55c7fc7b7e8877b9366568cc73d68b82da7fe33d8b98527b73857a225f", + "sha256:3d68eeef7b4d08a25e51897dac29bcb62aba830e9ac6c4e3297ee7c6a0cf6439", + "sha256:3dddf0fb832486cc1ea71d189cb92eb887826e8deebe128884e15020bb6e3f61", + "sha256:3edbb9c9130bac05d8c3fe150c51c337a471cc7fdb6d2a0a7d3a88e88a829314", + "sha256:3effe081b3135237da6e4c4530ff2a868d3f80be0bda027e118a5971285d42d0", + "sha256:422c179022ecdedbe58b0e242607198580804253da220e9454ffe848daa1cfd2", + "sha256:42978a68d3825eaac55399eb37a4d52012a205c0c6262199b8b44fcc6fd686e8", + "sha256:4399b4226c4785575fb20998dc571bc48125dc92c367ce2602d0d70e0c455eb0", + "sha256:45fbb70ccbc8683f2fb58bea89498a7274af1d9ec7995e9f4af5604e028233fc", + "sha256:4867361c049761a56bd21de507cab2c2a608c55102311d142ade7dab67b34f32", + "sha256:48fd46bf7155def2e15287c6f2b133a2f78e2d22cdf55647269977b873c65499", + "sha256:4b0d5cdba1b655d5b18042ac9c9ff50bda33568eb80feaaca4fc237b9c4fbfde", + "sha256:4df0ec814b50275ad6a99bc82a38b59f90e10e47714ac9871e1b223895825468", + "sha256:4e52e1b148867b01c05e21837586ee307a01e793b94072d7c7b91d2c2da02ffe", + "sha256:514fe78fc4b87e7a7601c92492210b20a1b0c6ab20e71e81307d9c2e377c64de", + "sha256:524ccfded8989a6595dbdda80d779fb977dbc9a7bc458864fc9a0c2fc15dc877", + "sha256:528f3a0498a8edc69af0559bdcf8a9f5a8bf7c00051a6ef3141fdcf27017bbf5", + "sha256:52d82b0d436edd6a1d22d94a344b9a58abd6c68c357ed44f22d4ba8179b37629", + "sha256:5412500e0dc5481b1ee9cf6b38bb3b473f6e411eb62b83dc9b62699c3b7b79f7", + "sha256:585c4dc429deebc4307187d2b71ebe914843185ae16a4d582ee030e6cfbb4d8a", + "sha256:5865b270b420eda7b68928d70bb517ccbe045e53b1a428129bb44372bf3d7dd5", + "sha256:5881aaa4bf3a2d086c5f20371d3a5856199a0d8ac72dd8d0dbd7a2ecfc26ab73", + "sha256:5885bc586f1edb48e5d68e7a4b4757b5feb2a496b64f462b4d65950f5af3364f", + "sha256:5a11b16a33656ffc43c92a5343a28dc71eefe460bcc2a4923a96f292692709f6", + "sha256:5a997b784a639e05b9d4053ef3b20c7e447ea80814a762f25b8ed5a89d261eac", + "sha256:5be8f5e4044146a69c96077c7e08f0709c13a314aa5315981185c1f00235fe65", + "sha256:63d57fc94eb0bbb4735e45517afc21ef262991d8758a8f2f05dd6e4174944519", + "sha256:673b9d8e780f455091200bba8534d5f4f465944cbdd61f31dc832d70e29064a5", + "sha256:67d2f8ad9dcc3a9e826bdc7802ed541a44e124c29b7d95a679eeb58c1c14ade8", + "sha256:67f5e80adf0aafc7b5454f2c1cb0cde920c9b1f2cbd0485f07cc1d0497c35c5d", + "sha256:68018c4c67d7e89951a91fbd371e2e34cd8cfc71f0bb43b5332db38497025d51", + "sha256:6c4dd3bfd0c82400060896717dd261137398edb7e524527438c54a8c34f736bf", + "sha256:71f31eda4e370f46af42fc9f264fafa1b09f46ba07bdbee98f25689a04b81c20", + "sha256:7512b4d0fc5339d5abbb14d1843f70499cab90d0b864f790e73f780f041615d7", + "sha256:75fa3d6946d317ffc7016a6fcc44f42db6d514b7fdb8b4b28cbe058303cb6e53", + "sha256:779e851fd0e19795ccc8a9bb4d705d6baa0ef475329fe44a13cf1e962f18ff1e", + "sha256:796520afa499732191e39fc95b56a3b07f95256f2d22b1c26e217fb69a9db5b5", + "sha256:7aae7a3d63b935babfdc6864b31196afd5145878ddd22f5200729006366bc4d5", + "sha256:7b82e67c5feb682dbb559c3e6b78355f234943053af61606af126df2183b9ef9", + "sha256:7c0536bd9178f754b277a3e53f90f9c9454a3bd108b1531ffff720e082d824f2", + "sha256:7eda194dd46e40ec745bf76795a7cccb02a6a41f445ad49d3cf66518b0bd9cff", + "sha256:82a4bb10b0beef1434fb23a09f001ab5ca87895596b4581fd53f1e5145a8934a", + "sha256:85c4f11be9cf08917ac2a5a8b6e1ef63b2f8e3799cec194417e76826e5f1de9c", + "sha256:88b72eb7222d918c967202024812c2bfb4048deeb69ca328363fb8e15254c549", + "sha256:89934f9f791566e54c1d92cdc8f8fd0009447a5ecdb1ec6b810d5f8c4955f6be", + "sha256:8b1942b3e4ed9ed551ed3083a2e6e0772de1e5e3aca872d955e2e86385fb7ff9", + "sha256:8ffb141361108e864ab5f1813f66e4e1164181227f9b1f105b042729b6c15125", + "sha256:8fffc08de02071c37865a155e5ea5fce0282e1546fd5bde7f6149fcaa32558ac", + "sha256:91fb6a43d72b4f8863d21f347a9163eecbf36e76e2f51068d59cd004c506f332", + "sha256:928e75a7200a4c09e6efc7482a1337919cc61fe1ba289f297827a5b76d8969c2", + "sha256:96eef5b9f336f623ffc555ab47a775495e7e8846dde88de5f941e2906453a1ce", + "sha256:a0611da6b07dd3720f492db1b463a4d1175b096b49438761cc9f35f0d9eaaef5", + "sha256:a091026c3bf7519ab1e64655a3f52a59ad4a4e019a6f830c24d6430695b1cf6a", + "sha256:a22f66270bd6d0804b02cd49dae2b33d4341015545d17f8426f2c4e22f557a23", + "sha256:a243132767150a44e6a93cd1dde41010036e1cbc63cc3e9fe1712b277d926ce3", + "sha256:a31fa7536ec1fb7155a0cd3a4e3d956c835ad0a43e3610ca32384d01f079ea1c", + "sha256:a364e8e944d92dcbf33b6b494d4e0fb3499dcc3bd9485beb701aa4b4201fa414", + "sha256:a4058f16cee694577f7e4dd410263cd0ef75644b43802a689c2b3c2a7e69453b", + "sha256:a4b382e0e636ed54cd278791d93fe2c4f370772743f02bcbe431a160089025c9", + "sha256:a83d3adea1e0ee36dac34627f78ddd7f093bb9cfc0a8e97f1572a949b695cb98", + "sha256:a8ade0363f776f87f982572c2860cc43c65ace208db49c76df0a21dde4ddd16e", + "sha256:aa59974880ab5ad8ef3afaa26f9bda148c5f39e06b11a8ada4660ecc9fb2feb3", + "sha256:aa826340a609d0c954ba52fd831f0fba2a4165659ab0ee1a15e4aac21f302406", + "sha256:aaca5a812f050ab55426c32177091130b1e49329b3f002a32934cd0245571307", + "sha256:ae82fce1d964f065c32c9517309f0c7be588772352d2f40b1574a214bd6e6098", + "sha256:aed57b541b589fa05ac248f4cb1c46cbb432ab82cbd467d1c4f6a2bdc18aecf9", + "sha256:afa578b6524ff85fb365f454cf61683771d0170470c48ad9d170c48075f86725", + "sha256:b0884e3f22d87c30694e625b1e62e6f30d39782c806287450d9dc2fdf07692fd", + "sha256:b2aca14c235c7a08558fe0a4786a1a05873a01e86b474dfa8f6df49101853a4e", + "sha256:b450d7cabcd49aa7ab46a3c6aa3ac7e1593600a1a0605ba536ec0f1b99a04322", + "sha256:b725e70d15906d24615201e650d5b0388b08a5187a55f119f25874d0103f90dd", + "sha256:bfbbab9316330cf81656fed435311386610f78b6c93cc5db4bebbce8dd146675", + "sha256:c093c7088b40d8266f57ed71d93112bd64c6724d31f0794c1e52cc4857c28e0e", + "sha256:c2e49dc23a10a1296b04ca9db200c44d3eb32c8d8ec532e8c1fd24792276522a", + "sha256:c4393600915c308e546dc7003d74371744234e8444a28622d76fe19b98fa59d1", + "sha256:c5ae125276f254b01daa73e2c103363d3e99e3e10505686ac7d9d2442dd4627a", + "sha256:c6aacf00d05b38a5069826e50ae72751cb5bc27bdc4d5746203988e429b385bb", + "sha256:c76722b5ed4a31ba103e0dc77ab869222ec36efe1a614e42e9bcea88a36186fe", + "sha256:c809eef167bf4a57af4b03007004896f5c60bd38dc3852fcd97a26eae3d4c9e6", + "sha256:c92ea6d9dd84a750b2bae72ff5e8cf5fdd13e58dda79c33e057862c29a8d5b50", + "sha256:cb659702a45136c743bc130760c6f137870d4df3a9e14386478b8a0511abcfca", + "sha256:ce0930a963ff593e8bb6fda49a503911accc67dee7e5445eec972668e672a0f0", + "sha256:d0751528b97d2b19a388b302be2a0ee05817097bab46ff0ed76feeec24951f78", + "sha256:d184f85ad2bb1f261eac55cddfcf62a70dee89982c978e92b9a74a1bfef2e367", + "sha256:d2a3e412ce1849be34b45922bfef03df32d1410a06d1cdeb793a343c2f1fd666", + "sha256:d61ec60945d694df806a9aec88e8f29a27293c6e424f8ff91c80416e3c617645", + "sha256:db0c742aad702fd5d0c6611a73f9602f20aec2007c102630c06d7633d9c8f09a", + "sha256:db4743e30d6f5f92b6d2b7c86b3ad250e0bad8dee4b7ad8a0c44bfb276af89a3", + "sha256:dbf7bebc2275016cddf3c997bf8a0f7044160714c64a9b83975670a04e6d2252", + "sha256:de1fc314c3ad6bc2f6bd5b5a5b9357b8c6896333d27fdbb7049aea8bd5af2d79", + "sha256:df7e5edac4778127f2bf452e0721a58a1cfa4d1d9eac63bdd650535eb8543615", + "sha256:e220f7b3e8656ab063d2eb0cd536fafef396829cafe04cb314e734f87649058f", + "sha256:e3c623923967f3e5961d272718655946e5322b8d058e094764180cdee7bab1af", + "sha256:e69add9b6b7b08c60d7ff0152c7c9a6c45b4a71a919be5abde6f98f1ea16421c", + "sha256:e8e0d177b1fe251c3b1b914ab64135475c5273c8cfd2857964b2e3bb0fe196a7", + "sha256:ef45f31aec9be01379fc6c10f1d9c677f032f2bac9383c827d44f620e8a88407", + "sha256:f1208c1c67ec9e151d78aa3435aa9b08a488b53d9cfac9b699f15255a3461ef2", + "sha256:f12582b8d3b4c6be1d298c49cb7ae64a3a73efaf4c2ab4e37db182e3545815ac", + "sha256:f1de541a9893cf8a1b1db9bf0bf670a2decab42e3e82233d36a74eda7822b4c9", + "sha256:f4eac0584cdc3285ef2e74eee1513a6001681fd9753b259e8159421ed28a72e5", + "sha256:f7b64fcd670bca8800bc10ced36620c6bbb321e7bc1214b9c0c0df269c1dddc2", + "sha256:fb7c61d4be18e930f75948705e9718618862e6fc2ed0d7159b2262be73f167a2" ], "markers": "python_version >= '3.6'", - "version": "==5.3.0" + "version": "==5.3.1" }, "mako": { "hashes": [ - "sha256:42f48953c7eb91332040ff567eb7eea69b22e7a4affbc5ba8e845e8f730f6627", - "sha256:577b97e414580d3e088d47c2dbbe9594aa7a5146ed2875d4dfa9075af2dd3cc8" + "sha256:95920acccb578427a9aa38e37a186b1e43156c87260d7ba18ca63aa4c7cbd3a1", + "sha256:b5d65ff3462870feec922dbccf38f6efb44e5714d7b593a656be86663d8600ac" ], "markers": "python_version >= '3.8'", - "version": "==1.3.8" + "version": "==1.3.9" }, "markupsafe": { "hashes": [ @@ -827,11 +847,11 @@ }, "marshmallow": { "hashes": [ - "sha256:bcaf2d6fd74fb1459f8450e85d994997ad3e70036452cbfa4ab685acb19479b3", - "sha256:c448ac6455ca4d794773f00bae22c2f351d62d739929f761dce5eacb5c468d7f" + "sha256:3350409f20a70a7e4e11a27661187b77cdcaeb20abca41c1454fe33636bea09c", + "sha256:e6d8affb6cb61d39d26402096dc0aee12d5a26d490a121f118d2e81dc0719dc6" ], "markers": "python_version >= '3.9'", - "version": "==3.23.2" + "version": "==3.26.1" }, "oic": { "hashes": [ @@ -859,10 +879,10 @@ }, "phonenumberslite": { "hashes": [ - "sha256:02da5e78c67b213bae95afd6289f40486c93e302e518769911dfa5e7287ddeee", - "sha256:dfa44a4bae2e46d737ae5301cb96b14cdcbf45063236c74c6ddb08f5fd471b0d" + "sha256:3daa107c7d89576effa8ecc0ed17f0e8845055836590a96fa5f1fdac5dd475e0", + "sha256:b961bb36d32688bcf28ec308f8f11f502353c9aa5f9fb18261b215c6b0e6b898" ], - "version": "==8.13.52" + "version": "==8.13.55" }, "psycopg2-binary": { "hashes": [ @@ -878,6 +898,7 @@ "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7", "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d", "sha256:270934a475a0e4b6925b5f804e3809dd5f90f8613621d062848dd82f9cd62007", + "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142", "sha256:2ad26b467a405c798aaa1458ba09d7e2b6e5f96b1ce0ac15d82fd9f95dc38a92", "sha256:2b3d2491d4d78b6b14f76881905c7a8a8abcf974aad4a8a0b065273a0ed7a2cb", "sha256:2ce3e21dc3437b1d960521eca599d57408a695a0d3c26797ea0f72e834c7ffe5", @@ -987,11 +1008,11 @@ }, "pydantic": { "hashes": [ - "sha256:597e135ea68be3a37552fb524bc7d0d66dcf93d395acd93a00682f1efcb8ee3d", - "sha256:82f12e9723da6de4fe2ba888b5971157b3be7ad914267dea8f05f82b28254f06" + "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", + "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236" ], "markers": "python_version >= '3.8'", - "version": "==2.10.4" + "version": "==2.10.6" }, "pydantic-core": { "hashes": [ @@ -1150,19 +1171,19 @@ }, "s3transfer": { "hashes": [ - "sha256:244a76a24355363a68164241438de1b72f8781664920260c48465896b712a41e", - "sha256:29edc09801743c21eb5ecbc617a152df41d3c287f67b615f73e5f750583666a7" + "sha256:3b39185cb72f5acc77db1a58b6e25b977f28d20496b6e58d6813d75f464d632f", + "sha256:be6ecb39fadd986ef1701097771f87e4d2f821f27f6071c872143884d2950fbc" ], "markers": "python_version >= '3.8'", - "version": "==0.10.4" + "version": "==0.11.2" }, "setuptools": { "hashes": [ - "sha256:8199222558df7c86216af4f84c30e9b34a61d8ba19366cc914424cdbd28252f6", - "sha256:ce74b49e8f7110f9bf04883b730f4765b774ef3ef28f722cce7c273d253aaf7d" + "sha256:c5afc8f407c626b8313a86e10311dd3f661c6cd9c09d4bf8c15c0e11f9f2b0e6", + "sha256:e3982f444617239225d675215d51f6ba05f845d4eec313da4418fdbb56fb27e3" ], "markers": "python_version >= '3.9'", - "version": "==75.6.0" + "version": "==75.8.0" }, "six": { "hashes": [ @@ -1182,11 +1203,11 @@ }, "tablib": { "hashes": [ - "sha256:9a6930037cfe0f782377963ca3f2b1dae3fd4cdbf0883848f22f1447e7bb718b", - "sha256:f9db84ed398df5109bd69c11d46613d16cc572fb9ad3213f10d95e2b5f12c18e" + "sha256:35bdb9d4ec7052232f8803908f9c7a9c3c65807188b70618fa7a7d8ccd560b4d", + "sha256:94d8bcdc65a715a0024a6d5b701a5f31e45bd159269e62c73731de79f048db2b" ], "markers": "python_version >= '3.9'", - "version": "==3.7.0" + "version": "==3.8.0" }, "tblib": { "hashes": [ @@ -1206,6 +1227,14 @@ "markers": "python_version >= '3.8'", "version": "==4.12.2" }, + "tzdata": { + "hashes": [ + "sha256:24894909e88cdb28bd1636c6887801df64cb485bd593f2fd83ef29075a81d694", + "sha256:7e127113816800496f027041c570f50bcd464a020098a3b6b199517772303639" + ], + "markers": "python_version >= '2'", + "version": "==2025.1" + }, "urllib3": { "hashes": [ "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", @@ -1216,12 +1245,12 @@ }, "whitenoise": { "hashes": [ - "sha256:486bd7267a375fa9650b136daaec156ac572971acc8bf99add90817a530dd1d4", - "sha256:df12dce147a043d1956d81d288c6f0044147c6d2ab9726e5772ac50fb45d2280" + "sha256:8c4a7c9d384694990c26f3047e118c691557481d624f069b7f7752a2f735d609", + "sha256:c8a489049b7ee9889617bb4c274a153f3d979e8f51d2efd0f5b403caf41c57df" ], "index": "pypi", "markers": "python_version >= '3.9'", - "version": "==6.8.2" + "version": "==6.9.0" }, "zope.event": { "hashes": [ @@ -1286,49 +1315,49 @@ }, "bandit": { "hashes": [ - "sha256:b1a61d829c0968aed625381e426aa378904b996529d048f8d908fa28f6b13e38", - "sha256:b5bfe55a095abd9fe20099178a7c6c060f844bfd4fe4c76d28e35e4c52b9d31e" + "sha256:28f04dc0d258e1dd0f99dee8eefa13d1cb5e3fde1a5ab0c523971f97b289bcd8", + "sha256:f5847beb654d309422985c36644649924e0ea4425c76dec2e89110b87506193a" ], "index": "pypi", "markers": "python_version >= '3.9'", - "version": "==1.8.0" + "version": "==1.8.3" }, "beautifulsoup4": { "hashes": [ - "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051", - "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed" + "sha256:1bd32405dacc920b42b83ba01644747ed77456a65760e285fbc47633ceddaf8b", + "sha256:99045d7d3f08f91f0d656bc9b7efbae189426cd913d830294a15eefa0ea4df16" ], - "markers": "python_full_version >= '3.6.0'", - "version": "==4.12.3" + "markers": "python_full_version >= '3.7.0'", + "version": "==4.13.3" }, "black": { "hashes": [ - "sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f", - "sha256:17374989640fbca88b6a448129cd1745c5eb8d9547b464f281b251dd00155ccd", - "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea", - "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981", - "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b", - "sha256:2cd9c95431d94adc56600710f8813ee27eea544dd118d45896bb734e9d7a0dc7", - "sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8", - "sha256:394d4ddc64782e51153eadcaaca95144ac4c35e27ef9b0a42e121ae7e57a9175", - "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d", - "sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392", - "sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad", - "sha256:63f626344343083322233f175aaf372d326de8436f5928c042639a4afbbf1d3f", - "sha256:649fff99a20bd06c6f727d2a27f401331dc0cc861fb69cde910fe95b01b5928f", - "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b", - "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875", - "sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3", - "sha256:ccfa1d0cb6200857f1923b602f978386a3a2758a65b52e0950299ea014be6800", - "sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65", - "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2", - "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812", - "sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50", - "sha256:fe4d6476887de70546212c99ac9bd803d90b42fc4767f058a0baa895013fbb3e" + "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", + "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7", + "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da", + "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2", + "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc", + "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", + "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f", + "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b", + "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32", + "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f", + "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", + "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299", + "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0", + "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", + "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0", + "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", + "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355", + "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096", + "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e", + "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9", + "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba", + "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f" ], "index": "pypi", "markers": "python_version >= '3.9'", - "version": "==24.10.0" + "version": "==25.1.0" }, "blinker": { "hashes": [ @@ -1340,12 +1369,12 @@ }, "boto3": { "hashes": [ - "sha256:ba391982f6cada136c5bba99e85d7fe1bc4e157c53a22a78e4aca35d1b39152e", - "sha256:eecef248f8743ab30036cd9c916808a0892fc9036e1a35434d8222060c08bbd2" + "sha256:006800604c34382873521b20890b758eea7109d699696ece932131259d0a4658", + "sha256:d59642672b1f35f55f47b317693241ce53333816f47c9e72fcc8fd0e9adc6a87" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.35.91" + "version": "==1.36.23" }, "boto3-mocking": { "hashes": [ @@ -1358,28 +1387,28 @@ }, "boto3-stubs": { "hashes": [ - "sha256:780f71406147b78f9860d78907b5c015874537d821364588ec837c4cd1eecf91", - "sha256:e4301b9d05b31fbfea382d0d1d950c2178f7fca03058b31373fac9a4cdf89438" + "sha256:e09016f06daf7794d11815891b962f588c513c5f5ee6c0ab2c045c375f798c9a", + "sha256:ee2738fd742742dbe9db2745c66ac1f853ed497b4f2f9c1625d81b0a27b6d1b2" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.35.91" + "version": "==1.36.23" }, "botocore": { "hashes": [ - "sha256:7b0b9c5954701fff4d2c516918f45641b04ff4ca92bbd9f5b37c0b80f8c14220", - "sha256:93de9d0f52f7e36a2c190d55520d3b2654f32c5a628fdd484bffa00bc7865e1d" + "sha256:886730e79495a2e153842725ebdf85185c8277cdf255b3b5879cd097ddc7fcc3", + "sha256:9feaa2d876f487e718a5fd80a35fa401042b518c0c75117d3e1ea39a567439e7" ], "markers": "python_version >= '3.8'", - "version": "==1.35.91" + "version": "==1.36.23" }, "botocore-stubs": { "hashes": [ - "sha256:c6b294cae436eaaf87dcb717e4348c250ea1fc170336579da114b693663d8e42", - "sha256:f7fd78d84f49d28692662b9bdeb4c92f1bf8a5707d0c28c8544399005b02823b" + "sha256:2283cf6e926a323d2a2ba0322228960db7f40061dfee197858118359d8402816", + "sha256:b150699f4f98e39e3e90e0d6787f5e8f919876f9ddbd24e3663b8311222588ec" ], "markers": "python_version >= '3.8'", - "version": "==1.35.90" + "version": "==1.36.23" }, "click": { "hashes": [ @@ -1400,12 +1429,12 @@ }, "django-debug-toolbar": { "hashes": [ - "sha256:36e421cb908c2f0675e07f9f41e3d1d8618dc386392ec82d23bcfcd5d29c7044", - "sha256:3beb671c9ec44ffb817fad2780667f172bd1c067dbcabad6268ce39a81335f45" + "sha256:296f6f18a80710e84fbb8361538ae5ec522a75ebe9ab67db34bcf1026cbeb420", + "sha256:7456cc2e951db37dab335686db7803c4a0ecb6736d120705f6668db9548bf49f" ], "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==4.4.6" + "markers": "python_version >= '3.9'", + "version": "==5.0.1" }, "django-model2puml": { "hashes": [ @@ -1416,20 +1445,20 @@ }, "django-stubs": { "hashes": [ - "sha256:126d354bbdff4906c4e93e6361197f6fbfb6231c3df6def85a291dae6f9f577b", - "sha256:c4dc64260bd72e6d32b9e536e8dd0d9247922f0271f82d1d5132a18f24b388ac" + "sha256:716758ced158b439213062e52de6df3cff7c586f9f9ad7ab59210efbea5dfe78", + "sha256:8c230bc5bebee6da282ba8a27ad1503c84a0c4cd2f46e63d149e76d2a63e639a" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==5.1.1" + "version": "==5.1.3" }, "django-stubs-ext": { "hashes": [ - "sha256:3907f99e178c93323e2ce908aef8352adb8c047605161f8d9e5e7b4efb5a6a9c", - "sha256:db7364e4f50ae7e5360993dbd58a3a57ea4b2e7e5bab0fbd525ccdb3e7975d1c" + "sha256:3e60f82337f0d40a362f349bf15539144b96e4ceb4dbd0239be1cd71f6a74ad0", + "sha256:64561fbc53e963cc1eed2c8eb27e18b8e48dcb90771205180fe29fc8a59e55fd" ], "markers": "python_version >= '3.8'", - "version": "==5.1.1" + "version": "==5.1.3" }, "django-webtest": { "hashes": [ @@ -1441,12 +1470,12 @@ }, "flake8": { "hashes": [ - "sha256:049d058491e228e03e67b390f311bbf88fce2dbaa8fa673e7aea87b7198b8d38", - "sha256:597477df7860daa5aa0fdd84bf5208a043ab96b8e96ab708770ae0364dd03213" + "sha256:1cbc62e65536f65e6d754dfe6f1bada7f5cf392d6f5db3c2b85892466c3e7c1a", + "sha256:c586ffd0b41540951ae41af572e6790dbd49fc12b3aa2541685d253d9bd504bd" ], "index": "pypi", "markers": "python_full_version >= '3.8.1'", - "version": "==7.1.1" + "version": "==7.1.2" }, "jmespath": { "hashes": [ @@ -1482,48 +1511,42 @@ }, "mypy": { "hashes": [ - "sha256:07ba89fdcc9451f2ebb02853deb6aaaa3d2239a236669a63ab3801bbf923ef5c", - "sha256:0c911fde686394753fff899c409fd4e16e9b294c24bfd5e1ea4675deae1ac6fd", - "sha256:183cf0a45457d28ff9d758730cd0210419ac27d4d3f285beda038c9083363b1f", - "sha256:1fb545ca340537d4b45d3eecdb3def05e913299ca72c290326be19b3804b39c0", - "sha256:27fc248022907e72abfd8e22ab1f10e903915ff69961174784a3900a8cba9ad9", - "sha256:2ae753f5c9fef278bcf12e1a564351764f2a6da579d4a81347e1d5a15819997b", - "sha256:30ff5ef8519bbc2e18b3b54521ec319513a26f1bba19a7582e7b1f58a6e69f14", - "sha256:3888a1816d69f7ab92092f785a462944b3ca16d7c470d564165fe703b0970c35", - "sha256:44bf464499f0e3a2d14d58b54674dee25c031703b2ffc35064bd0df2e0fac319", - "sha256:46c756a444117c43ee984bd055db99e498bc613a70bbbc120272bd13ca579fbc", - "sha256:499d6a72fb7e5de92218db961f1a66d5f11783f9ae549d214617edab5d4dbdbb", - "sha256:52686e37cf13d559f668aa398dd7ddf1f92c5d613e4f8cb262be2fb4fedb0fcb", - "sha256:553c293b1fbdebb6c3c4030589dab9fafb6dfa768995a453d8a5d3b23784af2e", - "sha256:57961db9795eb566dc1d1b4e9139ebc4c6b0cb6e7254ecde69d1552bf7613f60", - "sha256:7084fb8f1128c76cd9cf68fe5971b37072598e7c31b2f9f95586b65c741a9d31", - "sha256:7d54bd85b925e501c555a3227f3ec0cfc54ee8b6930bd6141ec872d1c572f81f", - "sha256:7ec88144fe9b510e8475ec2f5f251992690fcf89ccb4500b214b4226abcd32d6", - "sha256:8b21525cb51671219f5307be85f7e646a153e5acc656e5cebf64bfa076c50107", - "sha256:8b4e3413e0bddea671012b063e27591b953d653209e7a4fa5e48759cda77ca11", - "sha256:8c6d94b16d62eb3e947281aa7347d78236688e21081f11de976376cf010eb31a", - "sha256:8edc07eeade7ebc771ff9cf6b211b9a7d93687ff892150cb5692e4f4272b0837", - "sha256:8f845a00b4f420f693f870eaee5f3e2692fa84cc8514496114649cfa8fd5e2c6", - "sha256:8fa2220e54d2946e94ab6dbb3ba0a992795bd68b16dc852db33028df2b00191b", - "sha256:90716d8b2d1f4cd503309788e51366f07c56635a3309b0f6a32547eaaa36a64d", - "sha256:92c3ed5afb06c3a8e188cb5da4984cab9ec9a77ba956ee419c68a388b4595255", - "sha256:ad3301ebebec9e8ee7135d8e3109ca76c23752bac1e717bc84cd3836b4bf3eae", - "sha256:b66a60cc4073aeb8ae00057f9c1f64d49e90f918fbcef9a977eb121da8b8f1d1", - "sha256:ba24549de7b89b6381b91fbc068d798192b1b5201987070319889e93038967a8", - "sha256:bce23c7377b43602baa0bd22ea3265c49b9ff0b76eb315d6c34721af4cdf1d9b", - "sha256:c99f27732c0b7dc847adb21c9d47ce57eb48fa33a17bc6d7d5c5e9f9e7ae5bac", - "sha256:cb9f255c18052343c70234907e2e532bc7e55a62565d64536dbc7706a20b78b9", - "sha256:d4b19b03fdf54f3c5b2fa474c56b4c13c9dbfb9a2db4370ede7ec11a2c5927d9", - "sha256:d64169ec3b8461311f8ce2fd2eb5d33e2d0f2c7b49116259c51d0d96edee48d1", - "sha256:dbec574648b3e25f43d23577309b16534431db4ddc09fda50841f1e34e64ed34", - "sha256:e0fe0f5feaafcb04505bcf439e991c6d8f1bf8b15f12b05feeed96e9e7bf1427", - "sha256:f2a0ecc86378f45347f586e4163d1769dd81c5a223d577fe351f26b179e148b1", - "sha256:f995e511de847791c3b11ed90084a7a0aafdc074ab88c5a9711622fe4751138c", - "sha256:fad79bfe3b65fe6a1efaed97b445c3d37f7be9fdc348bdb2d7cac75579607c89" + "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e", + "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22", + "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f", + "sha256:1fbb8da62dc352133d7d7ca90ed2fb0e9d42bb1a32724c287d3c76c58cbaa9c2", + "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f", + "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b", + "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5", + "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f", + "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43", + "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e", + "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c", + "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828", + "sha256:712e962a6357634fef20412699a3655c610110e01cdaa6180acec7fc9f8513ba", + "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee", + "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d", + "sha256:8f8722560a14cde92fdb1e31597760dc35f9f5524cce17836c0d22841830fd5b", + "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445", + "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e", + "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13", + "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5", + "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd", + "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf", + "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357", + "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b", + "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036", + "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559", + "sha256:c7be1e46525adfa0d97681432ee9fcd61a3964c2446795714699a998d193f1a3", + "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f", + "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464", + "sha256:d10d994b41fb3497719bbf866f227b3489048ea4bbbb5015357db306249f7980", + "sha256:e601a7fa172c2131bff456bb3ee08a88360760d0d2f8cbd7a75a65497e2df078", + "sha256:f95579473af29ab73a10bada2f9722856792a36ec5af5399b653aa28360290a5" ], "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==1.14.1" + "markers": "python_version >= '3.9'", + "version": "==1.15.0" }, "mypy-extensions": { "hashes": [ @@ -1559,11 +1582,11 @@ }, "pbr": { "hashes": [ - "sha256:788183e382e3d1d7707db08978239965e8b9e4e5ed42669bf4758186734d5f24", - "sha256:a776ae228892d8013649c0aeccbb3d5f99ee15e005a4cbb7e61d55a067b28a2a" + "sha256:38d4daea5d9fa63b3f626131b9d34947fd0c8be9b05a29276870580050a25a76", + "sha256:93ea72ce6989eb2eed99d0f75721474f69ad88128afdef5ac377eb797c4bf76b" ], "markers": "python_version >= '2.6'", - "version": "==6.1.0" + "version": "==6.1.1" }, "platformdirs": { "hashes": [ @@ -1591,11 +1614,11 @@ }, "pygments": { "hashes": [ - "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", - "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a" + "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", + "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c" ], "markers": "python_version >= '3.8'", - "version": "==2.18.0" + "version": "==2.19.1" }, "python-dateutil": { "hashes": [ @@ -1674,11 +1697,19 @@ }, "s3transfer": { "hashes": [ - "sha256:244a76a24355363a68164241438de1b72f8781664920260c48465896b712a41e", - "sha256:29edc09801743c21eb5ecbc617a152df41d3c287f67b615f73e5f750583666a7" + "sha256:3b39185cb72f5acc77db1a58b6e25b977f28d20496b6e58d6813d75f464d632f", + "sha256:be6ecb39fadd986ef1701097771f87e4d2f821f27f6071c872143884d2950fbc" ], "markers": "python_version >= '3.8'", - "version": "==0.10.4" + "version": "==0.11.2" + }, + "setuptools": { + "hashes": [ + "sha256:c5afc8f407c626b8313a86e10311dd3f661c6cd9c09d4bf8c15c0e11f9f2b0e6", + "sha256:e3982f444617239225d675215d51f6ba05f845d4eec313da4418fdbb56fb27e3" + ], + "markers": "python_version >= '3.9'", + "version": "==75.8.0" }, "six": { "hashes": [ @@ -1752,11 +1783,11 @@ }, "types-awscrt": { "hashes": [ - "sha256:405bce8c281f9e7c6c92a229225cc0bf10d30729a6a601123213389bd524b8b1", - "sha256:fbf9c221af5607b24bf17f8431217ce8b9a27917139edbc984891eb63fd5a593" + "sha256:7391bf502f6093221e68da8fb6a2af7ec67a98d376c58d5b76cc3938f449d121", + "sha256:965659260599b421564204b895467684104a2c0311bbacfd3c2423b8b0d3f3e9" ], "markers": "python_version >= '3.8'", - "version": "==0.23.6" + "version": "==0.23.10" }, "types-cachetools": { "hashes": [ @@ -1786,11 +1817,11 @@ }, "types-s3transfer": { "hashes": [ - "sha256:03123477e3064c81efe712bf9d372c7c72f2790711431f9baa59cf96ea607267", - "sha256:22ac1aabc98f9d7f2928eb3fb4d5c02bf7435687f0913345a97dd3b84d0c217d" + "sha256:09c31cff8c79a433fcf703b840b66d1f694a6c70c410ef52015dd4fe07ee0ae2", + "sha256:3ccb8b90b14434af2fb0d6c08500596d93f3a83fb804a2bb843d9bf4f7c2ca60" ], "markers": "python_version >= '3.8'", - "version": "==0.10.4" + "version": "==0.11.2" }, "typing-extensions": { "hashes": [ @@ -1827,11 +1858,11 @@ }, "webtest": { "hashes": [ - "sha256:0b2de681c16f57b31da5cce6e94ff03cdc77bd86c37a57ba0ee27fed8e065ceb", - "sha256:799846e169d15e0c1233ab4ab00ee4de59a5d964407d6f2945d89249328dbbdb" + "sha256:5b3d8c69ac9057f17750ed5b45320a411423c2b4196bec6450961be98b03d8c1", + "sha256:94778d19a37e5abd7388dad4d93874410ecced53a1739a8e5ff2dbcba1cfc0c4" ], - "markers": "python_version >= '3.7'", - "version": "==3.0.2" + "markers": "python_version >= '3.9'", + "version": "==3.0.4" } } } diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 09d0eaa812..2dc885b5be 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -11,11 +11,11 @@ Value, When, ) - +from registrar.models.flows import PortfolioInvitationFlow, DomainInvitationFlow, DomainRequestFlow from django.db.models.functions import Concat, Coalesce from django.http import HttpResponseRedirect + from registrar.models.federal_agency import FederalAgency -from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.utility.admin_helpers import ( AutocompleteSelectWithPlaceholder, get_action_needed_reason_default_email, @@ -28,6 +28,7 @@ from django.shortcuts import redirect, get_object_or_404 from django_fsm import get_available_FIELD_transitions, FSMField from registrar.models import DomainInformation, Portfolio, UserPortfolioPermission, DomainInvitation + from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.utility.email_invitations import ( send_domain_invitation_email, @@ -1754,7 +1755,8 @@ def save_model(self, request, obj, form, change): ) # if user exists for email, immediately retrieve portfolio invitation upon creation if requested_user is not None: - portfolio_invitation.retrieve() + portfolio_flow = PortfolioInvitationFlow(portfolio_invitation) + portfolio_flow.retrieve() portfolio_invitation.save() messages.success(request, f"{requested_email} has been invited to the organization: {domain_org}") @@ -1768,7 +1770,8 @@ def save_model(self, request, obj, form, change): messages.warning(request, "Could not send email confirmation to existing domain managers.") if requested_user is not None: # Domain Invitation creation for an existing User - obj.retrieve() + flow = DomainInvitationFlow(obj) + flow.retrieve() # Call the parent save method to save the object super().save_model(request, obj, form, change) messages.success(request, f"{requested_email} has been invited to the domain: {domain}") @@ -1861,7 +1864,8 @@ def save_model(self, request, obj, form, change): ) # if user exists for email, immediately retrieve portfolio invitation upon creation if requested_user is not None: - obj.retrieve() + flow = PortfolioInvitationFlow(obj) + flow.retrieve() messages.success(request, f"{requested_email} has been invited.") else: messages.warning(request, "User is already a member of this portfolio.") @@ -2958,15 +2962,16 @@ def _handle_status_change(self, request, obj, original_obj): def get_status_method_mapping(self, domain_request): """Returns what method should be ran given an domain request object""" # Define a per-object mapping + flow = DomainRequestFlow(domain_request) status_method_mapping = { models.DomainRequest.DomainRequestStatus.STARTED: None, - models.DomainRequest.DomainRequestStatus.SUBMITTED: domain_request.submit, - models.DomainRequest.DomainRequestStatus.IN_REVIEW: domain_request.in_review, - models.DomainRequest.DomainRequestStatus.ACTION_NEEDED: domain_request.action_needed, - models.DomainRequest.DomainRequestStatus.APPROVED: domain_request.approve, - models.DomainRequest.DomainRequestStatus.WITHDRAWN: domain_request.withdraw, - models.DomainRequest.DomainRequestStatus.REJECTED: domain_request.reject, - models.DomainRequest.DomainRequestStatus.INELIGIBLE: (domain_request.reject_with_prejudice), + models.DomainRequest.DomainRequestStatus.SUBMITTED: flow.submit, + models.DomainRequest.DomainRequestStatus.IN_REVIEW: flow.in_review, + models.DomainRequest.DomainRequestStatus.ACTION_NEEDED: flow.action_needed, + models.DomainRequest.DomainRequestStatus.APPROVED: flow.approve, + models.DomainRequest.DomainRequestStatus.WITHDRAWN: flow.withdraw, + models.DomainRequest.DomainRequestStatus.REJECTED: flow.reject, + models.DomainRequest.DomainRequestStatus.INELIGIBLE: (flow.reject_with_prejudice), } # Grab the method diff --git a/src/registrar/fixtures/fixtures_domains.py b/src/registrar/fixtures/fixtures_domains.py index 8855194f83..d1e737bc5a 100644 --- a/src/registrar/fixtures/fixtures_domains.py +++ b/src/registrar/fixtures/fixtures_domains.py @@ -8,6 +8,7 @@ from registrar.fixtures.fixtures_users import UserFixture from registrar.models import User, DomainRequest from registrar.models.domain import Domain +from registrar.models.flows import DomainRequestFlow fake = Faker() logger = logging.getLogger(__name__) @@ -64,7 +65,8 @@ def _approve_request(cls, domain_request, users): domain_request.investigator = random.choice(users) # nosec # Approve the domain request - domain_request.approve(send_email=False) + flow = DomainRequestFlow(domain_request) + flow.approve(send_email=False) return domain_request diff --git a/src/registrar/management/commands/create_federal_portfolio.py b/src/registrar/management/commands/create_federal_portfolio.py index d753d0ce86..d6b548cde7 100644 --- a/src/registrar/management/commands/create_federal_portfolio.py +++ b/src/registrar/management/commands/create_federal_portfolio.py @@ -4,12 +4,19 @@ import logging from django.core.management import BaseCommand, CommandError from registrar.management.commands.utility.terminal_helper import TerminalColors, TerminalHelper -from registrar.models import DomainInformation, DomainRequest, FederalAgency, Suborganization, Portfolio, User -from registrar.models.domain import Domain -from registrar.models.domain_invitation import DomainInvitation -from registrar.models.portfolio_invitation import PortfolioInvitation -from registrar.models.user_domain_role import UserDomainRole -from registrar.models.user_portfolio_permission import UserPortfolioPermission +from registrar.models import ( + Domain, + DomainInformation, + DomainInvitation, + DomainRequest, + FederalAgency, + Suborganization, + Portfolio, + PortfolioInvitation, + User, + UserDomainRole, + UserPortfolioPermission, +) from registrar.models.utility.generic_helper import normalize_string from django.db.models import F, Q diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index d3c0ed3470..e7139516ad 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -6,16 +6,15 @@ from datetime import date, timedelta from typing import Optional from django.db import transaction -from django_fsm import FSMField, transition, TransitionNotAllowed # type: ignore from django.db import models, IntegrityError from django.utils import timezone from typing import Any from registrar.models.domain_invitation import DomainInvitation from registrar.models.host import Host from registrar.models.host_ip import HostIP +from registrar.models.utility.state_controlled_model import StateControlledModel from registrar.utility.enums import DefaultEmail from registrar.utility import errors - from registrar.utility.errors import ( ActionNotAllowed, NameserverError, @@ -37,7 +36,6 @@ from django.db.models import DateField, TextField from .utility.domain_field import DomainField from .utility.domain_helper import DomainHelper -from .utility.time_stamped_model import TimeStampedModel from .public_contact import PublicContact @@ -46,7 +44,7 @@ logger = logging.getLogger(__name__) -class Domain(TimeStampedModel, DomainHelper): +class Domain(StateControlledModel, DomainHelper): """ Manage the lifecycle of domain names. @@ -314,6 +312,14 @@ def registry_expiration_date(self, ex_date: date): To update the expiration date, use renew_domain method.""" raise NotImplementedError() + def get_flow(self): + """returns the DomainFlow object""" + # this is a Lazy import to avoid a circular import error + # the only other solution is adding the Flow definition to this already massive file + from registrar.models.flows import DomainFlow + + return DomainFlow(self) + def renew_domain(self, length: int = 1, unit: epp.Unit = epp.Unit.YEAR): """ Renew the domain to a length and unit of time relative to the current @@ -755,13 +761,13 @@ def nameservers(self, hosts: list[tuple[str, list]]): # noqa if successTotalNameservers < 2: try: - self.dns_needed() + self.get_flow().dns_needed() self.save() except Exception as err: logger.info("nameserver setter checked for dns_needed state and it did not succeed. Warning: %s" % err) elif successTotalNameservers >= 2 and successTotalNameservers <= 13: try: - self.ready() + self.get_flow().ready() self.save() except Exception as err: logger.info("nameserver setter checked for create state and it did not succeed. Warning: %s" % err) @@ -1175,12 +1181,10 @@ def __str__(self) -> str: verbose_name="domain", ) - state = FSMField( + state = models.CharField( max_length=21, choices=State.choices, default=State.UNKNOWN, - # cannot change state directly, particularly in Django admin - protected=True, # This must be defined for custom state help messages, # as otherwise the view will purge the help field as it does not exist. help_text=" ", @@ -1505,28 +1509,28 @@ def _get_or_create_domain(self): logger.info("_get_or_create_domain() -> Switching to dns_needed from unknown") # avoid infinite loop already_tried_to_create = True - self.dns_needed_from_unknown() + self.get_flow().dns_needed_from_unknown() + # self.dns_needed_from_unknown() self.save() else: logger.error(e) logger.error(e.code) raise e - def addRegistrant(self): - """Adds a default registrant contact""" - registrant = PublicContact.get_default_registrant() - registrant.domain = self - registrant.save() # calls the registrant_contact.setter - return registrant.registry_id - - @transition(field="state", source=State.UNKNOWN, target=State.DNS_NEEDED) - def dns_needed_from_unknown(self): - logger.info("Changing to dns_needed") - - registrantID = self.addRegistrant() + def _create_domain_in_registry(self, registrantID): + """ + Creates the domain in the registry. + This should only be called from withinside a transition function + Args: + registrantID (str) - Public Contact id + Returns: + None + Raises: + RegistryError + """ req = commands.CreateDomain( - name=self.name, + name=self.domain.name, registrant=registrantID, auth_info=epp.DomainAuthInfo(pw="2fooBAR123fooBaz"), # not a password ) @@ -1538,7 +1542,12 @@ def dns_needed_from_unknown(self): if err.code != ErrorCode.OBJECT_EXISTS: raise err - self.addAllDefaults() + def addRegistrant(self): + """Adds a default registrant contact""" + registrant = PublicContact.get_default_registrant() + registrant.domain = self + registrant.save() # calls the registrant_contact.setter + return registrant.registry_id def addAllDefaults(self): """Adds default security, technical, and administrative contacts""" @@ -1552,111 +1561,6 @@ def addAllDefaults(self): administrative_contact = self.get_default_administrative_contact() administrative_contact.save() - @transition(field="state", source=[State.READY, State.ON_HOLD], target=State.ON_HOLD) - def place_client_hold(self, ignoreEPP=False): - """place a clienthold on a domain (no longer should resolve) - ignoreEPP (boolean) - set to true to by-pass EPP (used for transition domains) - """ - - # (check prohibited statuses) - logger.info("clientHold()-> inside clientHold") - - # In order to allow transition domains to by-pass EPP calls, - # include this ignoreEPP flag - if not ignoreEPP: - self._place_client_hold() - - @transition(field="state", source=[State.READY, State.ON_HOLD], target=State.READY) - def revert_client_hold(self, ignoreEPP=False): - """undo a clienthold placed on a domain - ignoreEPP (boolean) - set to true to by-pass EPP (used for transition domains) - """ - - logger.info("clientHold()-> inside clientHold") - if not ignoreEPP: - self._remove_client_hold() - # TODO -on the client hold ticket any additional error handling here - - @transition(field="state", source=[State.ON_HOLD, State.DNS_NEEDED], target=State.DELETED) - def deletedInEpp(self): - """Domain is deleted in epp but is saved in our database. - Subdomains will be deleted first if not in use by another domain. - Contacts for this domain will also be deleted. - Error handling should be provided by the caller.""" - # While we want to log errors, we want to preserve - # that information when this function is called. - # Human-readable errors are introduced at the admin.py level, - # as doing everything here would reduce reliablity. - try: - logger.info("deletedInEpp()-> inside _delete_domain") - self._delete_domain() - self.deleted = timezone.now() - self.expiration_date = None - except RegistryError as err: - logger.error(f"Could not delete domain. Registry returned error: {err}. {err.note}") - raise err - except TransitionNotAllowed as err: - logger.error("Could not delete domain. FSM failure: {err}") - raise err - except Exception as err: - logger.error(f"Could not delete domain. An unspecified error occured: {err}") - raise err - else: - self._invalidate_cache() - - # def is_dns_needed(self): - # """Commented out and kept in the codebase - # as this call should be made, but adds - # a lot of processing time - # when EPP calling is made more efficient - # this should be added back in - - # The goal is to double check that - # the nameservers we set are in fact - # on the registry - # """ - # self._invalidate_cache() - # nameserverList = self.nameservers - # return len(nameserverList) < 2 - - # def dns_not_needed(self): - # return not self.is_dns_needed() - - @transition( - field="state", - source=[State.DNS_NEEDED, State.READY], - target=State.READY, - # conditions=[dns_not_needed] - ) - def ready(self): - """Transition to the ready state - domain should have nameservers and all contacts - and now should be considered live on a domain - """ - logger.info("Changing to ready state") - logger.info("able to transition to ready state") - # if self.first_ready is not None, this means that this - # domain was READY, then not READY, then is READY again. - # We do not want to overwrite first_ready. - if self.first_ready is None: - self.first_ready = timezone.now() - - @transition( - field="state", - source=[State.READY], - target=State.DNS_NEEDED, - # conditions=[is_dns_needed] - ) - def dns_needed(self): - """Transition to the DNS_NEEDED state - domain should NOT have nameservers but - SHOULD have all contacts - Going to check nameservers and will - result in an EPP call - """ - logger.info("Changing to DNS_NEEDED state") - logger.info("able to transition to DNS_NEEDED state") - def get_state_help_text(self, request=None) -> str: """Returns a str containing additional information about a given state. Returns custom content for when the domain itself is expired.""" @@ -1926,8 +1830,9 @@ def _fix_unknown_state(self, cleaned): and (and should be into DNS_NEEDED), we double check the current state and # of nameservers and update the state from there """ + flow = self.get_flow() try: - self._add_missing_contacts_if_unknown(cleaned) + flow._add_missing_contacts_if_unknown(cleaned) except Exception as e: logger.error( @@ -1935,48 +1840,9 @@ def _fix_unknown_state(self, cleaned): "Domain will still be in UNKNOWN state." % (self.name, e) ) if len(self.nameservers) >= 2 and (self.state != self.State.READY): - self.ready() + flow.ready() self.save() - @transition(field="state", source=State.UNKNOWN, target=State.DNS_NEEDED) - def _add_missing_contacts_if_unknown(self, cleaned): - """ - _add_missing_contacts_if_unknown: Add contacts (SECURITY, TECHNICAL, and/or ADMINISTRATIVE) - if they are missing, AND switch the state to DNS_NEEDED from UNKNOWN (if it - is in an UNKNOWN state, that is an error state) - Note: The transition state change happens at the end of the function - """ - - missingAdmin = True - missingSecurity = True - missingTech = True - - contacts = cleaned.get("_contacts", []) - if len(contacts) < 3: - for contact in contacts: - if contact.type == PublicContact.ContactTypeChoices.ADMINISTRATIVE: - missingAdmin = False - if contact.type == PublicContact.ContactTypeChoices.SECURITY: - missingSecurity = False - if contact.type == PublicContact.ContactTypeChoices.TECHNICAL: - missingTech = False - - # We are only creating if it doesn't exist so we don't overwrite - if missingAdmin: - administrative_contact = self.get_default_administrative_contact() - administrative_contact.save() - if missingSecurity: - security_contact = self.get_default_security_contact() - security_contact.save() - if missingTech: - technical_contact = self.get_default_technical_contact() - technical_contact.save() - - logger.info( - "_add_missing_contacts_if_unknown => Adding contacts. Values are " - f"missingAdmin: {missingAdmin}, missingSecurity: {missingSecurity}, missingTech: {missingTech}" - ) - def _fetch_cache(self, fetch_hosts=False, fetch_contacts=False): """Contact registry for info about a domain.""" try: diff --git a/src/registrar/models/domain_invitation.py b/src/registrar/models/domain_invitation.py index 28089dcb54..0eaaf0c2fb 100644 --- a/src/registrar/models/domain_invitation.py +++ b/src/registrar/models/domain_invitation.py @@ -1,20 +1,13 @@ """People are invited by email to administer domains.""" import logging - -from django.contrib.auth import get_user_model from django.db import models - -from django_fsm import FSMField, transition # type: ignore - -from .utility.time_stamped_model import TimeStampedModel -from .user_domain_role import UserDomainRole - +from .utility.state_controlled_model import StateControlledModel logger = logging.getLogger(__name__) -class DomainInvitation(TimeStampedModel): +class DomainInvitation(StateControlledModel): class Meta: """Contains meta information about this class""" @@ -40,47 +33,10 @@ class DomainInvitationStatus(models.TextChoices): related_name="invitations", ) - status = FSMField( + status = models.CharField( choices=DomainInvitationStatus.choices, default=DomainInvitationStatus.INVITED, - protected=True, # can't alter state except through transition methods! ) def __str__(self): return f"Invitation for {self.email} on {self.domain} is {self.status}" - - @transition(field="status", source=DomainInvitationStatus.INVITED, target=DomainInvitationStatus.RETRIEVED) - def retrieve(self): - """When an invitation is retrieved, create the corresponding permission. - - Raises: - RuntimeError if no matching user can be found. - """ - - # get a user with this email address - User = get_user_model() - try: - user = User.objects.get(email=self.email) - except User.DoesNotExist: - # should not happen because a matching user should exist before - # we retrieve this invitation - raise RuntimeError("Cannot find the user to retrieve this domain invitation.") - - # and create a role for that user on this domain - _, created = UserDomainRole.objects.get_or_create( - user=user, domain=self.domain, role=UserDomainRole.Roles.MANAGER - ) - if not created: - # something strange happened and this role already existed when - # the invitation was retrieved. Log that this occurred. - logger.warn("Invitation %s was retrieved for a role that already exists.", self) - - @transition(field="status", source=DomainInvitationStatus.INVITED, target=DomainInvitationStatus.CANCELED) - def cancel_invitation(self): - """When an invitation is canceled, change the status to canceled""" - pass - - @transition(field="status", source=DomainInvitationStatus.CANCELED, target=DomainInvitationStatus.INVITED) - def update_cancellation_status(self): - """When an invitation is canceled but reinvited, update the status to invited""" - pass diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index 5ae51ebc67..df6b37b02a 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -4,26 +4,27 @@ from django.apps import apps from django.conf import settings from django.db import models -from django_fsm import FSMField, transition # type: ignore from django.utils import timezone +from viewflow import fsm from registrar.models.domain import Domain from registrar.models.federal_agency import FederalAgency from registrar.models.utility.generic_helper import CreateOrUpdateOrganizationTypeHelper from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices +from registrar.utility.db_helpers import object_is_being_created from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes from registrar.utility.constants import BranchChoices from auditlog.models import LogEntry from django.core.exceptions import ValidationError from registrar.utility.waffle import flag_is_active_for_user, flag_is_active_anywhere -from .utility.time_stamped_model import TimeStampedModel +from registrar.models.utility.state_controlled_model import StateControlledModel from ..utility.email import send_templated_email, EmailSendingError from itertools import chain logger = logging.getLogger(__name__) -class DomainRequest(TimeStampedModel): +class DomainRequest(StateControlledModel): """A registrant's domain request for a new domain.""" class Meta: @@ -296,10 +297,9 @@ def get_action_needed_reason_label(cls, action_needed_reason: str): return cls(action_needed_reason).label if action_needed_reason else None # #### Internal fields about the domain request ##### - status = FSMField( + status = models.CharField( choices=DomainRequestStatus.choices, # possible states as an array of constants default=DomainRequestStatus.STARTED, # sensible default - protected=False, # can change state directly, particularly in Django admin ) rejection_reason = models.TextField( @@ -654,6 +654,13 @@ def get_action_needed_reason_label(cls, action_needed_reason: str): blank=True, ) + # def __setattr__(self, name, value): + # """ Overrides the setter ('=' operator) with custom logic """ + + # if name == "status" and not object_is_being_created(self) : + # raise ValidationError("Direct changes to 'status' are not allowed.") + # super().__setattr__(name, value) + def is_awaiting_review(self) -> bool: """Checks if the current status is in submitted or in_review""" return self.status in [self.DomainRequestStatus.SUBMITTED, self.DomainRequestStatus.IN_REVIEW] @@ -1029,193 +1036,6 @@ def investigator_exists_and_is_staff(self): is_valid = False return is_valid - @transition( - field="status", - source=[ - DomainRequestStatus.STARTED, - DomainRequestStatus.IN_REVIEW, - DomainRequestStatus.ACTION_NEEDED, - DomainRequestStatus.WITHDRAWN, - ], - target=DomainRequestStatus.SUBMITTED, - ) - def submit(self): - """Submit an domain request that is started. - - As a side effect, an email notification is sent.""" - - # check our conditions here inside the `submit` method so that we - # can raise more informative exceptions - - # requested_domain could be None here - if not hasattr(self, "requested_domain") or self.requested_domain is None: - raise ValueError("Requested domain is missing.") - - DraftDomain = apps.get_model("registrar.DraftDomain") - if not DraftDomain.string_could_be_domain(self.requested_domain.name): - raise ValueError("Requested domain is not a valid domain name.") - # if the domain has not been submitted before this must be the first time - if not self.first_submitted_date: - self.first_submitted_date = timezone.now().date() - - # Update last_submitted_date to today - self.last_submitted_date = timezone.now().date() - self.save() - - # Limit email notifications to transitions from Started and Withdrawn - limited_statuses = [self.DomainRequestStatus.STARTED, self.DomainRequestStatus.WITHDRAWN] - - bcc_address = "" - if settings.IS_PRODUCTION: - bcc_address = settings.DEFAULT_FROM_EMAIL - - if self.status in limited_statuses: - self._send_status_update_email( - "submission confirmation", - "emails/submission_confirmation.txt", - "emails/submission_confirmation_subject.txt", - send_email=True, - bcc_address=bcc_address, - ) - - @transition( - field="status", - source=[ - DomainRequestStatus.SUBMITTED, - DomainRequestStatus.ACTION_NEEDED, - DomainRequestStatus.APPROVED, - DomainRequestStatus.REJECTED, - DomainRequestStatus.INELIGIBLE, - ], - target=DomainRequestStatus.IN_REVIEW, - conditions=[domain_is_not_active, investigator_exists_and_is_staff], - ) - def in_review(self): - """Investigate an domain request that has been submitted. - - This action is logged. - - This action cleans up the rejection status if moving away from rejected. - - As side effects this will delete the domain and domain_information - (will cascade) when they exist.""" - - if self.status == self.DomainRequestStatus.APPROVED: - self.delete_and_clean_up_domain("in_review") - elif self.status == self.DomainRequestStatus.REJECTED: - self.rejection_reason = None - elif self.status == self.DomainRequestStatus.ACTION_NEEDED: - self.action_needed_reason = None - - literal = DomainRequest.DomainRequestStatus.IN_REVIEW - # Check if the tuple exists, then grab its value - in_review = literal if literal is not None else "In Review" - logger.info(f"A status change occurred. {self} was changed to '{in_review}'") - - @transition( - field="status", - source=[ - DomainRequestStatus.IN_REVIEW, - DomainRequestStatus.APPROVED, - DomainRequestStatus.REJECTED, - DomainRequestStatus.INELIGIBLE, - ], - target=DomainRequestStatus.ACTION_NEEDED, - conditions=[domain_is_not_active, investigator_exists_and_is_staff], - ) - def action_needed(self): - """Send back an domain request that is under investigation or rejected. - - This action is logged. - - This action cleans up the rejection status if moving away from rejected. - - As side effects this will delete the domain and domain_information - (will cascade) when they exist. - - Afterwards, we send out an email for action_needed in def save(). - See the function send_custom_status_update_email. - """ - - if self.status == self.DomainRequestStatus.APPROVED: - self.delete_and_clean_up_domain("action_needed") - - elif self.status == self.DomainRequestStatus.REJECTED: - self.rejection_reason = None - - # Check if the tuple is setup correctly, then grab its value. - - literal = DomainRequest.DomainRequestStatus.ACTION_NEEDED - action_needed = literal if literal is not None else "Action Needed" - logger.info(f"A status change occurred. {self} was changed to '{action_needed}'") - - @transition( - field="status", - source=[ - DomainRequestStatus.SUBMITTED, - DomainRequestStatus.IN_REVIEW, - DomainRequestStatus.ACTION_NEEDED, - DomainRequestStatus.REJECTED, - ], - target=DomainRequestStatus.APPROVED, - conditions=[investigator_exists_and_is_staff], - ) - def approve(self, send_email=True): - """Approve an domain request that has been submitted. - - This action cleans up the rejection status if moving away from rejected. - - This has substantial side-effects because it creates another database - object for the approved Domain and makes the user who created the - domain request into an admin on that domain. It also triggers an email - notification.""" - - should_save = False - if self.federal_agency is None: - self.federal_agency = FederalAgency.objects.filter(agency="Non-Federal Agency").first() - should_save = True - - if self.is_requesting_new_suborganization(): - self.sub_organization = self.create_requested_suborganization() - should_save = True - - if should_save: - self.save() - - # create the domain - Domain = apps.get_model("registrar.Domain") - - # == Check that the domain_request is valid == # - if Domain.objects.filter(name=self.requested_domain.name).exists(): - raise FSMDomainRequestError(code=FSMErrorCodes.APPROVE_DOMAIN_IN_USE) - - # == Create the domain and related components == # - created_domain = Domain.objects.create(name=self.requested_domain.name) - self.approved_domain = created_domain - - # copy the information from DomainRequest into domaininformation - DomainInformation = apps.get_model("registrar.DomainInformation") - DomainInformation.create_from_da(domain_request=self, domain=created_domain) - - # create the permission for the user - UserDomainRole = apps.get_model("registrar.UserDomainRole") - UserDomainRole.objects.get_or_create( - user=self.creator, domain=created_domain, role=UserDomainRole.Roles.MANAGER - ) - - if self.status == self.DomainRequestStatus.REJECTED: - self.rejection_reason = None - elif self.status == self.DomainRequestStatus.ACTION_NEEDED: - self.action_needed_reason = None - - # == Send out an email == # - self._send_status_update_email( - "domain request approved", - "emails/status_change_approved.txt", - "emails/status_change_approved_subject.txt", - send_email=send_email, - ) - def is_withdrawable(self): """Helper function that determines if the request can be withdrawn in its current status""" # This list is equivalent to the source field on withdraw. We need a better way to @@ -1227,68 +1047,6 @@ def is_withdrawable(self): self.DomainRequestStatus.ACTION_NEEDED, ] - @transition( - field="status", - source=[DomainRequestStatus.SUBMITTED, DomainRequestStatus.IN_REVIEW, DomainRequestStatus.ACTION_NEEDED], - target=DomainRequestStatus.WITHDRAWN, - ) - def withdraw(self): - """Withdraw an domain request that has been submitted.""" - - self._send_status_update_email( - "withdraw", - "emails/domain_request_withdrawn.txt", - "emails/domain_request_withdrawn_subject.txt", - ) - - @transition( - field="status", - source=[DomainRequestStatus.IN_REVIEW, DomainRequestStatus.ACTION_NEEDED, DomainRequestStatus.APPROVED], - target=DomainRequestStatus.REJECTED, - conditions=[domain_is_not_active, investigator_exists_and_is_staff], - ) - def reject(self): - """Reject an domain request that has been submitted. - - This action is logged. - - This action cleans up the action needed status if moving away from action needed. - - As side effects this will delete the domain and domain_information - (will cascade) when they exist. - - Afterwards, we send out an email for reject in def save(). - See the function send_custom_status_update_email. - """ - - if self.status == self.DomainRequestStatus.APPROVED: - self.delete_and_clean_up_domain("reject") - - @transition( - field="status", - source=[ - DomainRequestStatus.IN_REVIEW, - DomainRequestStatus.ACTION_NEEDED, - DomainRequestStatus.APPROVED, - DomainRequestStatus.REJECTED, - ], - target=DomainRequestStatus.INELIGIBLE, - conditions=[domain_is_not_active, investigator_exists_and_is_staff], - ) - def reject_with_prejudice(self): - """The applicant is a bad actor, reject with prejudice. - - No email As a side effect, but we block the applicant from editing - any existing domains/domain requests and from submitting new aplications. - We do this by setting an ineligible status on the user, which the - permissions classes test against. This will also delete the domain - and domain_information (will cascade) when they exist.""" - - if self.status == self.DomainRequestStatus.APPROVED: - self.delete_and_clean_up_domain("reject_with_prejudice") - - self.creator.restrict_user() - def requesting_entity_is_portfolio(self) -> bool: """Determines if this record is requesting that a portfolio be their organization. Used for the RequestingEntity page. @@ -1380,7 +1138,7 @@ def unlock_other_contacts(self) -> bool: # ## Form policies ## # # # These methods control what questions need to be answered by applicants - # during the domain request flow. They are policies about the domain request so + # during the domain request dom. They are policies about the domain request so # they appear here. def show_organization_federal(self) -> bool: diff --git a/src/registrar/models/flows/__init__.py b/src/registrar/models/flows/__init__.py new file mode 100644 index 0000000000..ffb5ee2d07 --- /dev/null +++ b/src/registrar/models/flows/__init__.py @@ -0,0 +1,6 @@ +from .domain_flow import DomainFlow +from .domain_invitation_flow import DomainInvitationFlow +from .domain_request_flow import DomainRequestFlow +from .portfolio_invitation import PortfolioInvitationFlow + +__all__ = ["DomainFlow", "DomainInvitationFlow", "DomainRequestFlow", "PortfolioInvitationFlow"] diff --git a/src/registrar/models/flows/domain_flow.py b/src/registrar/models/flows/domain_flow.py new file mode 100644 index 0000000000..adec0c8d5c --- /dev/null +++ b/src/registrar/models/flows/domain_flow.py @@ -0,0 +1,164 @@ +import logging + +from django_fsm import TransitionNotAllowed + +from registrar.models.public_contact import PublicContact +from viewflow import fsm +from django.utils import timezone +from epplibwrapper import RegistryError +from registrar.models.domain import Domain + +logger = logging.getLogger(__name__) + + +class DomainFlow(object): + """ + Controls the "flow" between states of the Domain object + Only pass Domain to this class + """ + + state = fsm.State(Domain.State, default=Domain.State.UNKNOWN) + + def __init__(self, domain): + self.domain = domain + + @state.setter() + def _set_domain_state(self, value): + self.domain.__dict__["state"] = value + + @state.getter() + def _get_domain_state(self): + return self.domain.state + + @state.transition(source=Domain.State.UNKNOWN, target=Domain.State.DNS_NEEDED) + def dns_needed_from_unknown(self): + logger.info("Changing to dns_needed") + + # Registrant must be created before the domain + registrantID = self.domain.addRegistrant() + + # create the domain in the registry and add Public contacts + self.domain._create_domain_in_registry(registrantID) + self.domain.addAllDefaults() + + @state.transition(source=[Domain.State.READY, Domain.State.ON_HOLD], target=Domain.State.ON_HOLD) + def place_client_hold(self, ignoreEPP=False): + """place a clienthold on a domain (no longer should resolve) + ignoreEPP (boolean) - set to true to by-pass EPP (used for transition domains) + """ + + # (check prohibited statuses) + logger.info("clientHold()-> inside clientHold") + + # In order to allow transition domains to by-pass EPP calls, + # include this ignoreEPP flag + if not ignoreEPP: + self.domain._place_client_hold() + + @state.transition(source=[Domain.State.READY, Domain.State.ON_HOLD], target=Domain.State.READY) + def revert_client_hold(self, ignoreEPP=False): + """undo a clienthold placed on a domain + ignoreEPP (boolean) - set to true to by-pass EPP (used for transition domains) + """ + + logger.info("clientHold()-> inside clientHold") + if not ignoreEPP: + self.domain._remove_client_hold() + + @state.transition(source=[Domain.State.ON_HOLD, Domain.State.DNS_NEEDED], target=Domain.State.DELETED) + def deletedInEpp(self): + """Domain is deleted in epp but is saved in our database. + Subdomains will be deleted first if not in use by another domain. + Contacts for this domain will also be deleted. + Error handling should be provided by the caller.""" + # While we want to log errors, we want to preserve + # that information when this function is called. + # Human-readable errors are introduced at the admin.py level, + # as doing everything here would reduce reliablity. + try: + logger.info("deletedInEpp()-> inside _delete_domain") + self.domain._delete_domain() + self.domain.deleted = timezone.now() + self.domain.expiration_date = None + except RegistryError as err: + logger.error(f"Could not delete domain. Registry returned error: {err}. {err.note}") + raise err + except TransitionNotAllowed as err: + logger.error("Could not delete domain. FSM failure: {err}") + raise err + except Exception as err: + logger.error(f"Could not delete domain. An unspecified error occured: {err}") + raise err + else: + self.domain._invalidate_cache() + + @state.transition( + source=[Domain.State.DNS_NEEDED, Domain.State.READY], + target=Domain.State.READY, + # conditions=[dns_not_needed] + ) + def ready(self): + """Transition to the ready state + domain should have nameservers and all contacts + and now should be considered live on a domain + """ + logger.info("Changing to ready state") + logger.info("able to transition to ready state") + # if self.first_ready is not None, this means that this + # domain was READY, then not READY, then is READY again. + # We do not want to overwrite first_ready. + if self.domain.first_ready is None: + self.domain.first_ready = timezone.now() + + @state.transition( + source=[Domain.State.READY], + target=Domain.State.DNS_NEEDED, + ) + def dns_needed(self): + """Transition to the DNS_NEEDED state + domain should NOT have nameservers but + SHOULD have all contacts + Going to check nameservers and will + result in an EPP call + """ + logger.info("Changing to DNS_NEEDED state") + logger.info("able to transition to DNS_NEEDED state") + + @state.transition(source=Domain.State.UNKNOWN, target=Domain.State.DNS_NEEDED) + def _add_missing_contacts_if_unknown(self, cleaned): + """ + _add_missing_contacts_if_unknown: Add contacts (SECURITY, TECHNICAL, and/or ADMINISTRATIVE) + if they are missing, AND switch the state to DNS_NEEDED from UNKNOWN (if it + is in an UNKNOWN state, that is an error state) + Note: The transition state change happens at the end of the function + """ + + missingAdmin = True + missingSecurity = True + missingTech = True + + contacts = cleaned.get("_contacts", []) + if len(contacts) < 3: + for contact in contacts: + if contact.type == PublicContact.ContactTypeChoices.ADMINISTRATIVE: + missingAdmin = False + if contact.type == PublicContact.ContactTypeChoices.SECURITY: + missingSecurity = False + if contact.type == PublicContact.ContactTypeChoices.TECHNICAL: + missingTech = False + + # We are only creating if it doesn't exist so we don't overwrite + if missingAdmin: + administrative_contact = self.domain.get_default_administrative_contact() + administrative_contact.save() + if missingSecurity: + security_contact = self.domain.get_default_security_contact() + security_contact.save() + if missingTech: + technical_contact = self.domain.get_default_technical_contact() + technical_contact.save() + + logger.info( + "_add_missing_contacts_if_unknown => Adding contacts. Values are " + f"missingAdmin: {missingAdmin}, missingSecurity: {missingSecurity}, missingTech: {missingTech}" + ) diff --git a/src/registrar/models/flows/domain_invitation_flow.py b/src/registrar/models/flows/domain_invitation_flow.py new file mode 100644 index 0000000000..ad3f7cdd21 --- /dev/null +++ b/src/registrar/models/flows/domain_invitation_flow.py @@ -0,0 +1,69 @@ +from registrar.models import DomainInvitation, UserDomainRole +from viewflow import fsm +import logging +from django.contrib.auth import get_user_model + + +logger = logging.getLogger(__name__) + + +class DomainInvitationFlow(object): + """ + Controls the "flow" between states of the Domain Invitation object + Only pass DomainInvitation to this class + """ + + status = fsm.State(DomainInvitation.DomainInvitationStatus, default=DomainInvitation.DomainInvitationStatus.INVITED) + + def __init__(self, domain_invitation): + self.domain_invitation = domain_invitation + + @status.setter() + def _set_domain_invitation_status(self, value): + self.domain_invitation.__dict__["status"] = value + + @status.getter() + def _get_domain_invitation_status(self): + return self.domain_invitation.status + + @status.transition( + source=DomainInvitation.DomainInvitationStatus.INVITED, target=DomainInvitation.DomainInvitationStatus.RETRIEVED + ) + def retrieve(self): + """When an invitation is retrieved, create the corresponding permission. + + Raises: + RuntimeError if no matching user can be found. + """ + + # get a user with this email address + User = get_user_model() + try: + user = User.objects.get(email=self.domain_invitation.email) + except User.DoesNotExist: + # should not happen because a matching user should exist before + # we retrieve this invitation + raise RuntimeError("Cannot find the user to retrieve this domain invitation.") + + # and create a role for that user on this domain + _, created = UserDomainRole.objects.get_or_create( + user=user, domain=self.domain_invitation.domain, role=UserDomainRole.Roles.MANAGER + ) + if not created: + # something strange happened and this role already existed when + # the invitation was retrieved. Log that this occurred. + logger.warning("Invitation %s was retrieved for a role that already exists.", self.domain_invitation) + + @status.transition( + source=DomainInvitation.DomainInvitationStatus.INVITED, target=DomainInvitation.DomainInvitationStatus.CANCELED + ) + def cancel_invitation(self): + """When an invitation is canceled, change the status to canceled""" + pass + + @status.transition( + source=DomainInvitation.DomainInvitationStatus.CANCELED, target=DomainInvitation.DomainInvitationStatus.INVITED + ) + def update_cancellation_status(self): + """When an invitation is canceled but reinvited, update the status to invited""" + pass diff --git a/src/registrar/models/flows/domain_request_flow.py b/src/registrar/models/flows/domain_request_flow.py new file mode 100644 index 0000000000..89576519ec --- /dev/null +++ b/src/registrar/models/flows/domain_request_flow.py @@ -0,0 +1,288 @@ +import logging + +# from django import apps +from django.conf import settings +from registrar.models.domain_request import DomainRequest +from viewflow import fsm +from django.utils import timezone +from django.apps import apps +from registrar.models.federal_agency import FederalAgency +from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes + +logger = logging.getLogger(__name__) + + +class DomainRequestFlow(object): + """ + Controls the "flow" between states of the Domain Request object + Only pass DomainRequest to this class + """ + + status = fsm.State(DomainRequest.DomainRequestStatus, default=DomainRequest.DomainRequestStatus.STARTED) + + def __init__(self, domain_request): + self.domain_request = domain_request + + @status.setter() + def _set_domain_request_status(self, value): + self.domain_request.__dict__["status"] = value + + @status.getter() + def _get_domain_request_status(self): + return self.domain_request.status + + @status.transition( + source=[ + DomainRequest.DomainRequestStatus.STARTED, + DomainRequest.DomainRequestStatus.IN_REVIEW, + DomainRequest.DomainRequestStatus.ACTION_NEEDED, + DomainRequest.DomainRequestStatus.WITHDRAWN, + ], + target=DomainRequest.DomainRequestStatus.SUBMITTED, + ) + def submit(self): + """Submit an domain request that is started. + + As a side effect, an email notification is sent.""" + + # check our conditions here inside the `submit` method so that we + # can raise more informative exceptions + + # requested_domain could be None here + if not hasattr(self.domain_request, "requested_domain") or self.domain_request.requested_domain is None: + raise ValueError("Requested domain is missing.") + + DraftDomain = apps.get_model("registrar.DraftDomain") + if not DraftDomain.string_could_be_domain(self.domain_request.requested_domain.name): + raise ValueError("Requested domain is not a valid domain name.") + # if the domain has not been submitted before this must be the first time + if not self.domain_request.first_submitted_date: + self.domain_request.first_submitted_date = timezone.now().date() + + # Update last_submitted_date to today + self.domain_request.last_submitted_date = timezone.now().date() + self.domain_request.save() + + # Limit email notifications to transitions from Started and Withdrawn + limited_statuses = [DomainRequest.DomainRequestStatus.STARTED, DomainRequest.DomainRequestStatus.WITHDRAWN] + + bcc_address = "" + if settings.IS_PRODUCTION: + bcc_address = settings.DEFAULT_FROM_EMAIL + + if self.domain_request.status in limited_statuses: + self.domain_request._send_status_update_email( + "submission confirmation", + "emails/submission_confirmation.txt", + "emails/submission_confirmation_subject.txt", + send_email=True, + bcc_address=bcc_address, + ) + + def domain_is_not_active(self): + return self.domain_request.domain_is_not_active() + + def investigator_exists_and_is_staff(self): + return self.domain_request.investigator_exists_and_is_staff() + + @status.transition( + source=[ + DomainRequest.DomainRequestStatus.SUBMITTED, + DomainRequest.DomainRequestStatus.ACTION_NEEDED, + DomainRequest.DomainRequestStatus.APPROVED, + DomainRequest.DomainRequestStatus.REJECTED, + DomainRequest.DomainRequestStatus.INELIGIBLE, + ], + target=DomainRequest.DomainRequestStatus.IN_REVIEW, + conditions=[domain_is_not_active, investigator_exists_and_is_staff], + ) + def in_review(self): + """Investigate an domain request that has been submitted. + + This action is logged. + + This action cleans up the rejection status if moving away from rejected. + + As side effects this will delete the domain and domain_information + (will cascade) when they exist.""" + + if self.domain_request.status == DomainRequest.DomainRequestStatus.APPROVED: + self.domain_request.delete_and_clean_up_domain("in_review") + elif self.domain_request.status == DomainRequest.DomainRequestStatus.REJECTED: + self.domain_request.rejection_reason = None + elif self.domain_request.status == DomainRequest.DomainRequestStatus.ACTION_NEEDED: + self.domain_request.action_needed_reason = None + + literal = DomainRequest.DomainRequestStatus.IN_REVIEW + # Check if the tuple exists, then grab its value + in_review = literal if literal is not None else "In Review" + logger.info(f"A status change occurred. {self.domain_request} was changed to '{in_review}'") + + @status.transition( + source=[ + DomainRequest.DomainRequestStatus.IN_REVIEW, + DomainRequest.DomainRequestStatus.APPROVED, + DomainRequest.DomainRequestStatus.REJECTED, + DomainRequest.DomainRequestStatus.INELIGIBLE, + ], + target=DomainRequest.DomainRequestStatus.ACTION_NEEDED, + conditions=[domain_is_not_active, investigator_exists_and_is_staff], + ) + def action_needed(self): + """Send back an domain request that is under investigation or rejected. + + This action is logged. + + This action cleans up the rejection status if moving away from rejected. + + As side effects this will delete the domain and domain_information + (will cascade) when they exist. + + Afterwards, we send out an email for action_needed in def save(). + See the function send_custom_status_update_email. + """ + + if self.domain_request.status == DomainRequest.DomainRequestStatus.APPROVED: + self.domain_request.delete_and_clean_up_domain("action_needed") + + elif self.domain_request.status == DomainRequest.DomainRequestStatus.REJECTED: + self.rejection_reason = None + + # Check if the tuple is setup correctly, then grab its value. + + literal = DomainRequest.DomainRequestStatus.ACTION_NEEDED + action_needed = literal if literal is not None else "Action Needed" + logger.info(f"A status change occurred. {self.domain_request} was changed to '{action_needed}'") + + @status.transition( + source=[ + DomainRequest.DomainRequestStatus.SUBMITTED, + DomainRequest.DomainRequestStatus.IN_REVIEW, + DomainRequest.DomainRequestStatus.ACTION_NEEDED, + DomainRequest.DomainRequestStatus.REJECTED, + ], + target=DomainRequest.DomainRequestStatus.APPROVED, + conditions=[investigator_exists_and_is_staff], + ) + def approve(self, send_email=True): + """Approve an domain request that has been submitted. + + This action cleans up the rejection status if moving away from rejected. + + This has substantial side-effects because it creates another database + object for the approved Domain and makes the user who created the + domain request into an admin on that domain. It also triggers an email + notification.""" + + should_save = False + if self.domain_request.federal_agency is None: + self.domain_request.federal_agency = FederalAgency.objects.filter(agency="Non-Federal Agency").first() + should_save = True + + if self.domain_request.is_requesting_new_suborganization(): + self.domain_request.sub_organization = self.domain_request.create_requested_suborganization() + should_save = True + + if should_save: + self.domain_request.save() + + # create the domain + Domain = apps.get_model("registrar.Domain") + + # == Check that the domain_request is valid == # + if Domain.objects.filter(name=self.domain_request.requested_domain.name).exists(): + raise FSMDomainRequestError(code=FSMErrorCodes.APPROVE_DOMAIN_IN_USE) + + # == Create the domain and related components == # + created_domain = Domain.objects.create(name=self.domain_request.requested_domain.name) + self.domain_request.approved_domain = created_domain + + # copy the information from DomainRequest into domaininformation + DomainInformation = apps.get_model("registrar.DomainInformation") + DomainInformation.create_from_da(domain_request=self.domain_request, domain=created_domain) + + # create the permission for the user + UserDomainRole = apps.get_model("registrar.UserDomainRole") + UserDomainRole.objects.get_or_create( + user=self.domain_request.creator, domain=created_domain, role=UserDomainRole.Roles.MANAGER + ) + + if self.domain_request.status == DomainRequest.DomainRequestStatus.REJECTED: + self.domain_request.rejection_reason = None + elif self.domain_request.status == DomainRequest.DomainRequestStatus.ACTION_NEEDED: + self.domain_request.action_needed_reason = None + + # == Send out an email == # + self.domain_request._send_status_update_email( + "domain request approved", + "emails/status_change_approved.txt", + "emails/status_change_approved_subject.txt", + send_email=send_email, + ) + + @status.transition( + source=[ + DomainRequest.DomainRequestStatus.SUBMITTED, + DomainRequest.DomainRequestStatus.IN_REVIEW, + DomainRequest.DomainRequestStatus.ACTION_NEEDED, + ], + target=DomainRequest.DomainRequestStatus.WITHDRAWN, + ) + def withdraw(self): + """Withdraw an domain request that has been submitted.""" + + self.domain_request._send_status_update_email( + "withdraw", + "emails/domain_request_withdrawn.txt", + "emails/domain_request_withdrawn_subject.txt", + ) + + @status.transition( + source=[ + DomainRequest.DomainRequestStatus.IN_REVIEW, + DomainRequest.DomainRequestStatus.ACTION_NEEDED, + DomainRequest.DomainRequestStatus.APPROVED, + ], + target=DomainRequest.DomainRequestStatus.REJECTED, + conditions=[domain_is_not_active, investigator_exists_and_is_staff], + ) + def reject(self): + """Reject an domain request that has been submitted. + + This action is logged. + + This action cleans up the action needed status if moving away from action needed. + + As side effects this will delete the domain and domain_information + (will cascade) when they exist. + + Afterwards, we send out an email for reject in def save(). + See the function send_custom_status_update_email. + """ + + if self.domain_request.status == DomainRequest.DomainRequestStatus.APPROVED: + self.domain_request.delete_and_clean_up_domain("reject") + + @status.transition( + source=[ + DomainRequest.DomainRequestStatus.IN_REVIEW, + DomainRequest.DomainRequestStatus.ACTION_NEEDED, + DomainRequest.DomainRequestStatus.APPROVED, + DomainRequest.DomainRequestStatus.REJECTED, + ], + target=DomainRequest.DomainRequestStatus.INELIGIBLE, + conditions=[domain_is_not_active, investigator_exists_and_is_staff], + ) + def reject_with_prejudice(self): + """The applicant is a bad actor, reject with prejudice. + + No email As a side effect, but we block the applicant from editing + any existing domains/domain requests and from submitting new aplications. + We do this by setting an ineligible status on the user, which the + permissions classes test against. This will also delete the domain + and domain_information (will cascade) when they exist.""" + + if self.domain_request.status == DomainRequest.DomainRequestStatus.APPROVED: + self.domain_request.delete_and_clean_up_domain("reject_with_prejudice") + + self.domain_request.creator.restrict_user() diff --git a/src/registrar/models/flows/portfolio_invitation.py b/src/registrar/models/flows/portfolio_invitation.py new file mode 100644 index 0000000000..0ddf26851a --- /dev/null +++ b/src/registrar/models/flows/portfolio_invitation.py @@ -0,0 +1,65 @@ +import logging +from registrar.models.portfolio_invitation import PortfolioInvitation +from django.contrib.auth import get_user_model +from viewflow import fsm + +from registrar.models.portfolio_invitation import PortfolioInvitation +from registrar.models.user_portfolio_permission import UserPortfolioPermission + + +class PortfolioInvitationFlow(object): + """ + Controls the "flow" between states of the Portfolio Invitation object + Only pass PortfolioInvitation to this class + """ + + status = fsm.State( + PortfolioInvitation.PortfolioInvitationStatus, default=PortfolioInvitation.PortfolioInvitationStatus.INVITED + ) + + def __init__(self, portfolio_invitation): + self.portfolio_invitation = portfolio_invitation + + @status.setter() + def _set_portfolio_invitation_status(self, value): + self.portfolio_invitation.__dict__["status"] = value + + @status.getter() + def _get_portfolio_invitation_status(self): + return self.portfolio_invitation.status + + @status.transition( + source=PortfolioInvitation.PortfolioInvitationStatus.INVITED, + target=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED, + ) + def retrieve(self): + """When an invitation is retrieved, create the corresponding permission. + + Raises: + RuntimeError if no matching user can be found. + """ + + # get a user with this email address + User = get_user_model() + try: + user = User.objects.get(email=self.portfolio_invitation.email) + except User.DoesNotExist: + # should not happen because a matching user should exist before + # we retrieve this invitation + raise RuntimeError("Cannot find the user to retrieve this portfolio invitation.") + + # and create a role for that user on this portfolio + user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( + portfolio=self.portfolio_invitation.portfolio, user=user + ) + + if self.portfolio_invitation.roles and len(self.portfolio_invitation.roles) > 0: + user_portfolio_permission.roles = self.portfolio_invitation.roles + + if ( + self.portfolio_invitation.additional_permissions + and len(self.portfolio_invitation.additional_permissions) > 0 + ): + user_portfolio_permission.additional_permissions = self.portfolio_invitation.additional_permissions + + user_portfolio_permission.save() diff --git a/src/registrar/models/portfolio_invitation.py b/src/registrar/models/portfolio_invitation.py index 1da0fcdd13..04e97ad129 100644 --- a/src/registrar/models/portfolio_invitation.py +++ b/src/registrar/models/portfolio_invitation.py @@ -2,7 +2,6 @@ import logging from django.db import models -from django_fsm import FSMField, transition from django.contrib.auth import get_user_model from registrar.models import DomainInvitation, UserPortfolioPermission from .utility.portfolio_helper import ( @@ -19,13 +18,13 @@ get_role_display, validate_portfolio_invitation, ) # type: ignore -from .utility.time_stamped_model import TimeStampedModel +from .utility.state_controlled_model import StateControlledModel from django.contrib.postgres.fields import ArrayField logger = logging.getLogger(__name__) -class PortfolioInvitation(TimeStampedModel): +class PortfolioInvitation(StateControlledModel): class Meta: """Contains meta information about this class""" @@ -70,10 +69,9 @@ class PortfolioInvitationStatus(models.TextChoices): help_text="Select one or more additional permissions.", ) - status = FSMField( + status = models.CharField( choices=PortfolioInvitationStatus.choices, default=PortfolioInvitationStatus.INVITED, - protected=True, # can't alter state except through transition methods! ) def __str__(self): @@ -181,33 +179,6 @@ def members_description_display(self): """ return get_members_description_display(self.roles, self.additional_permissions) - @transition(field="status", source=PortfolioInvitationStatus.INVITED, target=PortfolioInvitationStatus.RETRIEVED) - def retrieve(self): - """When an invitation is retrieved, create the corresponding permission. - - Raises: - RuntimeError if no matching user can be found. - """ - - # get a user with this email address - User = get_user_model() - try: - user = User.objects.get(email=self.email) - except User.DoesNotExist: - # should not happen because a matching user should exist before - # we retrieve this invitation - raise RuntimeError("Cannot find the user to retrieve this portfolio invitation.") - - # and create a role for that user on this portfolio - user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( - portfolio=self.portfolio, user=user - ) - if self.roles and len(self.roles) > 0: - user_portfolio_permission.roles = self.roles - if self.additional_permissions and len(self.additional_permissions) > 0: - user_portfolio_permission.additional_permissions = self.additional_permissions - user_portfolio_permission.save() - def clean(self): """Extends clean method to perform additional validation, which can raise errors in django admin.""" super().clean() diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index d5476ab9ab..18c1b85828 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -16,6 +16,7 @@ from waffle.decorators import flag_is_active from django.utils import timezone from datetime import timedelta +from registrar.models.flows import DomainInvitationFlow, PortfolioInvitationFlow from phonenumber_field.modelfields import PhoneNumberField # type: ignore @@ -360,7 +361,8 @@ def check_domain_invitations_on_login(self): email__iexact=self.email, status=DomainInvitation.DomainInvitationStatus.INVITED ): try: - invitation.retrieve() + flow = DomainInvitationFlow(invitation) + flow.retrieve() invitation.save() except RuntimeError: # retrieving should not fail because of a missing user, but @@ -398,7 +400,8 @@ def check_portfolio_invitations_on_login(self): ) if only_single_portfolio or flag_is_active(None, "multiple_portfolios"): try: - invitation.retrieve() + flow = PortfolioInvitationFlow(invitation) + flow.retrieve() invitation.save() except RuntimeError: # retrieving should not fail because of a missing user, but diff --git a/src/registrar/models/utility/state_controlled_model.py b/src/registrar/models/utility/state_controlled_model.py new file mode 100644 index 0000000000..286fd00bdb --- /dev/null +++ b/src/registrar/models/utility/state_controlled_model.py @@ -0,0 +1,32 @@ +from registrar.models.utility.time_stamped_model import TimeStampedModel +from registrar.utility.db_helpers import object_is_being_created +from django.core.exceptions import ValidationError + + +class StateControlledModel(TimeStampedModel): + """ + An abstract base model that adds restrictions to Viewflow state controlled fields. + This will prevent fields named Status or State from being changed manually. + For example, object.state = SomeState would result in a immediate validation error + + Only use this when applying Viewflow FSM control to a model. + Caveate - this class would need to be changed if there is ever a need to have both a state field and a status field in the same model + """ + + class Meta: + abstract = True + # don't put anything else here, it will be ignored + + def __setattr__(self, name, value): + """Overrides the setter ('=' operator) with custom logic + This will block anyone from setting the state/status field using the = operator + The object can still be created for the first time with this field filled in. + """ + + if isinstance(self, StateControlledModel) and not object_is_being_created(self): + if name == "status": + raise ValidationError("Direct changes to 'status' are not allowed.") + elif name == "state": + raise ValidationError("Direct changes to 'state' are not allowed.") + + super().__setattr__(name, value) diff --git a/src/registrar/utility/db_helpers.py b/src/registrar/utility/db_helpers.py index 5b7e0392cf..17a4feba92 100644 --- a/src/registrar/utility/db_helpers.py +++ b/src/registrar/utility/db_helpers.py @@ -18,3 +18,16 @@ def ignore_unique_violation(): pass else: raise e + + +def object_is_being_created(object): + """returns true if the object is new and hasn't been saved in the db + To use this inside a class just pass 'self' as the parameter + """ + # _state and _state.adding are django specifc more information at: + # https://docs.djangoproject.com/en/4.2/ref/models/instances/#django.db.models.Model.from_db + # _state exists on object after initialization + # `adding` is set to True by django + # only when the object hasn't been saved to the db + # django automagically changes this to false after db-save + return getattr(object, "_state", None) and object._state.adding diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 3a083393e5..ec763aed49 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -18,6 +18,7 @@ IS_STAFF_MANAGING_DOMAIN, grant_access, ) +from registrar.models.flows import PortfolioInvitationFlow from registrar.forms.domain import DomainSuborganizationForm, DomainRenewalForm from registrar.models import ( Domain, @@ -1362,7 +1363,8 @@ def form_valid(self, form): ) # if user exists for email, immediately retrieve portfolio invitation upon creation if requested_user is not None: - portfolio_invitation.retrieve() + flow = PortfolioInvitationFlow(portfolio_invitation) + flow.retrieve() portfolio_invitation.save() messages.success(self.request, f"{requested_email} has been invited to the organization: {domain_org}") diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py index 3c395108b3..9b6c62665c 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -18,6 +18,7 @@ from registrar.forms import feb from registrar.forms.utility.wizard_form_helper import request_step_list from registrar.models import DomainRequest +from registrar.models.flows import DomainRequestFlow from registrar.models.contact import Contact from registrar.models.user import User from registrar.utility.waffle import flag_is_active_for_user @@ -260,7 +261,8 @@ def storage(self): def done(self): """Called when the user clicks the submit button, if all forms are valid.""" - self.domain_request.submit() # change the status to submitted + flow = DomainRequestFlow(self.domain_request) + flow.submit() # change the status to submitted self.domain_request.save() logger.debug("Domain Request object saved: %s", self.domain_request.id) return redirect(reverse(f"{self.URL_NAMESPACE}:finished")) @@ -987,7 +989,8 @@ def get(self, *args, **kwargs): to withdraw and send back to homepage. """ domain_request = DomainRequest.objects.get(id=self.kwargs["domain_request_pk"]) - domain_request.withdraw() + flow = DomainRequestFlow(domain_request) + flow.withdraw() domain_request.save() if self.request.user.is_org_user(self.request): return HttpResponseRedirect(reverse("domain-requests")) diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index c2ec44b9e7..47f1b81f0d 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -25,6 +25,7 @@ UserDomainRole, UserPortfolioPermission, ) +from registrar.models.flows import PortfolioInvitationFlow from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.utility.email import EmailSendingError from registrar.utility.email_invitations import ( @@ -984,7 +985,8 @@ def submit_new_member(self, form): portfolio_invitation = form.save() # if user exists for email, immediately retrieve portfolio invitation upon creation if requested_user is not None: - portfolio_invitation.retrieve() + flow = PortfolioInvitationFlow(portfolio_invitation) + flow.retrieve() portfolio_invitation.save() messages.success(self.request, f"{requested_email} has been invited.") else: diff --git a/src/requirements.txt b/src/requirements.txt index c3ed17604d..d4687f0f89 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1,14 +1,14 @@ -i https://pypi.python.org/simple annotated-types==0.7.0; python_version >= '3.8' asgiref==3.8.1; python_version >= '3.8' -boto3==1.35.91; python_version >= '3.8' -botocore==1.35.91; python_version >= '3.8' -cachetools==5.5.0; python_version >= '3.7' -certifi==2024.12.14; python_version >= '3.6' +boto3==1.36.23; python_version >= '3.8' +botocore==1.36.23; python_version >= '3.8' +cachetools==5.5.1; python_version >= '3.7' +certifi==2025.1.31; python_version >= '3.6' cfenv==0.5.3 cffi==1.17.1; python_version >= '3.8' charset-normalizer==3.4.1; python_version >= '3.7' -cryptography==44.0.0; python_version >= '3.7' and python_full_version not in '3.9.0, 3.9.1' +cryptography==44.0.1; python_version >= '3.7' and python_full_version not in '3.9.0, 3.9.1' defusedxml==0.7.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' diff-match-patch==20241021; python_version >= '3.7' dj-database-url==2.3.0 @@ -18,16 +18,18 @@ django-admin-multiple-choice-list-filter==0.1.1 django-allow-cidr==0.7.1 django-auditlog==3.0.0; python_version >= '3.8' django-cache-url==3.4.5 -django-cors-headers==4.6.0; python_version >= '3.9' +django-cors-headers==4.7.0; python_version >= '3.9' django-csp==3.8 +django-filter==25.1; python_version >= '3.9' django-fsm==2.8.1 -django-import-export==4.3.3; python_version >= '3.9' +django-import-export==4.3.5; python_version >= '3.9' django-login-required-middleware==0.9.0 django-phonenumber-field[phonenumberslite]==8.0.0; python_version >= '3.8' +django-viewflow==2.2.9; python_version >= '3.8' django-waffle==4.2.0; python_version >= '3.8' django-widget-tweaks==1.5.0; python_version >= '3.8' -environs[django]==11.2.1; python_version >= '3.8' -faker==33.1.0; python_version >= '3.8' +environs[django]==14.1.1; python_version >= '3.9' +faker==36.1.1; python_version >= '3.9' fred-epplib @ git+https://github.com/cisagov/epplib.git@d56d183f1664f34c40ca9716a3a9a345f0ef561c furl==2.1.3 future==1.0.0; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2' @@ -36,18 +38,18 @@ greenlet==3.1.1; python_version >= '3.7' gunicorn==23.0.0; python_version >= '3.7' idna==3.10; python_version >= '3.6' jmespath==1.0.1; python_version >= '3.7' -lxml==5.3.0; python_version >= '3.6' -mako==1.3.8; python_version >= '3.8' +lxml==5.3.1; python_version >= '3.6' +mako==1.3.9; python_version >= '3.8' markupsafe==3.0.2; python_version >= '3.9' -marshmallow==3.23.2; python_version >= '3.9' +marshmallow==3.26.1; python_version >= '3.9' oic==1.7.0; python_version ~= '3.8' orderedmultidict==1.0.1 packaging==24.2; python_version >= '3.8' -phonenumberslite==8.13.52 +phonenumberslite==8.13.55 psycopg2-binary==2.9.10; python_version >= '3.8' pycparser==2.22; python_version >= '3.8' pycryptodomex==3.21.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5' -pydantic==2.10.4; python_version >= '3.8' +pydantic==2.10.6; python_version >= '3.8' pydantic-core==2.27.2; python_version >= '3.8' pydantic-settings==2.7.1; python_version >= '3.8' pyjwkest==1.4.2 @@ -55,14 +57,15 @@ python-dateutil==2.9.0.post0; python_version >= '2.7' and python_version not in python-dotenv==1.0.1; python_version >= '3.8' pyzipper==0.3.6; python_version >= '3.4' requests==2.32.3; python_version >= '3.8' -s3transfer==0.10.4; python_version >= '3.8' -setuptools==75.6.0; python_version >= '3.9' +s3transfer==0.11.2; python_version >= '3.8' +setuptools==75.8.0; python_version >= '3.9' six==1.17.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2' sqlparse==0.5.3; python_version >= '3.8' -tablib==3.7.0; python_version >= '3.9' +tablib==3.8.0; python_version >= '3.9' tblib==3.0.0; python_version >= '3.8' typing-extensions==4.12.2; python_version >= '3.8' +tzdata==2025.1; python_version >= '2' urllib3==2.3.0; python_version >= '3.9' -whitenoise==6.8.2; python_version >= '3.9' +whitenoise==6.9.0; python_version >= '3.9' zope.event==5.0; python_version >= '3.7' zope.interface==7.2; python_version >= '3.8'