diff --git a/docs/dev-practices/code_review.md b/docs/dev-practices/code_review.md index 7b054cad5..c14e91d52 100644 --- a/docs/dev-practices/code_review.md +++ b/docs/dev-practices/code_review.md @@ -4,7 +4,6 @@ Pull requests should be titled in the format of `#issue_number: Descriptive name Pull requests including a migration should be suffixed with ` - MIGRATION` After creating a pull request, pull request submitters should: -- Add at least 2 developers as PR reviewers (only 1 will need to approve). - Message on Slack or in standup to notify the team that a PR is ready for review. - If any model was updated to modify/add/delete columns, run makemigrations and commit the associated migrations file. diff --git a/src/Pipfile.lock b/src/Pipfile.lock index 56daf5db5..76f2c914d 100644 --- a/src/Pipfile.lock +++ b/src/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "2799ab9e493352740c6946e604ccc075c5c16359c809753296091bbe2b9fd837" + "sha256": "07f7bc9bda4099f96b18f8f063b487b121b82ae01de06a7f2e9013d56098a421" }, "pipfile-spec": 6, "requires": {}, @@ -32,20 +32,20 @@ }, "boto3": { "hashes": [ - "sha256:2bf7e7f376aee52155fc4ae4487f29333a6bcdf3a05c3bc4fede10b972d951a6", - "sha256:e74bc6d69c04ca611b7f58afe08e2ded6cb6504a4a80557b656abeefee395f88" + "sha256:ba391982f6cada136c5bba99e85d7fe1bc4e157c53a22a78e4aca35d1b39152e", + "sha256:eecef248f8743ab30036cd9c916808a0892fc9036e1a35434d8222060c08bbd2" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.35.41" + "version": "==1.35.91" }, "botocore": { "hashes": [ - "sha256:8a09a32136df8768190a6c92f0240cd59c30deb99c89026563efadbbed41fa00", - "sha256:915c4d81e3a0be3b793c1e2efdf19af1d0a9cd4a2d8de08ee18216c14d67764b" + "sha256:7b0b9c5954701fff4d2c516918f45641b04ff4ca92bbd9f5b37c0b80f8c14220", + "sha256:93de9d0f52f7e36a2c190d55520d3b2654f32c5a628fdd484bffa00bc7865e1d" ], "markers": "python_version >= '3.8'", - "version": "==1.35.41" + "version": "==1.35.91" }, "cachetools": { "hashes": [ @@ -58,11 +58,11 @@ }, "certifi": { "hashes": [ - "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", - "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9" + "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", + "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db" ], "markers": "python_version >= '3.6'", - "version": "==2024.8.30" + "version": "==2024.12.14" }, "cfenv": { "hashes": [ @@ -142,152 +142,139 @@ "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b" ], - "markers": "platform_python_implementation != 'PyPy'", + "markers": "python_version >= '3.8'", "version": "==1.17.1" }, "charset-normalizer": { "hashes": [ - "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621", - "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6", - "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8", - "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912", - "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c", - "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b", - "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d", - "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d", - "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95", - "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e", - "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565", - "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", - "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab", - "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be", - "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", - "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907", - "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0", - "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2", - "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62", - "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62", - "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", - "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc", - "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284", - "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca", - "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455", - "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858", - "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b", - "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594", - "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", - "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db", - "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", - "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea", - "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6", - "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", - "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749", - "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7", - "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd", - "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99", - "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242", - "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee", - "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129", - "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2", - "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51", - "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee", - "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8", - "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", - "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613", - "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742", - "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe", - "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3", - "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5", - "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631", - "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7", - "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15", - "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c", - "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea", - "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417", - "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", - "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", - "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca", - "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa", - "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99", - "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149", - "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41", - "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574", - "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0", - "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f", - "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", - "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654", - "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3", - "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19", - "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", - "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578", - "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9", - "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1", - "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51", - "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719", - "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236", - "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a", - "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c", - "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade", - "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944", - "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc", - "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6", - "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6", - "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27", - "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6", - "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2", - "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12", - "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf", - "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", - "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7", - "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf", - "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", - "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b", - "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", - "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03", - "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4", - "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", - "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365", - "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a", - "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748", - "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b", - "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", - "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482" + "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537", + "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa", + "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a", + "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294", + "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b", + "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", + "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", + "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd", + "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4", + "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", + "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2", + "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", + "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd", + "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", + "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8", + "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1", + "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", + "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496", + "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d", + "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", + "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e", + "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a", + "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4", + "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca", + "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78", + "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408", + "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5", + "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", + "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", + "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a", + "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765", + "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6", + "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146", + "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6", + "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", + "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd", + "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c", + "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", + "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", + "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176", + "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770", + "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824", + "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f", + "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf", + "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487", + "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d", + "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd", + "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", + "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534", + "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f", + "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b", + "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", + "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd", + "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125", + "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9", + "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de", + "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", + "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d", + "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", + "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f", + "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", + "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7", + "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a", + "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", + "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8", + "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41", + "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", + "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f", + "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", + "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", + "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", + "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77", + "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76", + "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247", + "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", + "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb", + "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", + "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e", + "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6", + "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037", + "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", + "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e", + "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807", + "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", + "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c", + "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12", + "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3", + "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089", + "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", + "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e", + "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00", + "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616" ], - "markers": "python_full_version >= '3.7.0'", - "version": "==3.4.0" + "markers": "python_version >= '3.7'", + "version": "==3.4.1" }, "cryptography": { "hashes": [ - "sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494", - "sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806", - "sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d", - "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062", - "sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2", - "sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4", - "sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1", - "sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85", - "sha256:5b43d1ea6b378b54a1dc99dd8a2b5be47658fe9a7ce0a58ff0b55f4b43ef2b84", - "sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042", - "sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d", - "sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962", - "sha256:7c05650fe8023c5ed0d46793d4b7d7e6cd9c04e68eabe5b0aeea836e37bdcec2", - "sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa", - "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d", - "sha256:88cce104c36870d70c49c7c8fd22885875d950d9ee6ab54df2745f83ba0dc365", - "sha256:9d3cdb25fa98afdd3d0892d132b8d7139e2c087da1712041f6b762e4f807cc96", - "sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47", - "sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d", - "sha256:c1332724be35d23a854994ff0b66530119500b6053d0bd3363265f7e5e77288d", - "sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c", - "sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb", - "sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277", - "sha256:e710bf40870f4db63c3d7d929aa9e09e4e7ee219e703f949ec4073b4294f6172", - "sha256:ea25acb556320250756e53f9e20a4177515f012c9eaea17eb7587a8c4d8ae034", - "sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a", - "sha256:fba1007b3ef89946dbbb515aeeb41e30203b004f0b4b00e5e16078b518563289" + "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" ], - "markers": "python_version >= '3.7'", - "version": "==43.0.1" + "markers": "python_version >= '3.7' and python_full_version not in '3.9.0, 3.9.1'", + "version": "==44.0.0" }, "defusedxml": { "hashes": [ @@ -299,18 +286,18 @@ }, "diff-match-patch": { "hashes": [ - "sha256:953019cdb9c9d2c9e47b5b12bcff3cf4746fc4598eb406076fa1fc27e6a1f15c", - "sha256:dce43505fb7b1b317de7195579388df0746d90db07015ed47a85e5e44930ef93" + "sha256:93cea333fb8b2bc0d181b0de5e16df50dd344ce64828226bda07728818936782", + "sha256:beae57a99fa48084532935ee2968b8661db861862ec82c6f21f4acdd6d835073" ], "markers": "python_version >= '3.7'", - "version": "==20230430" + "version": "==20241021" }, "dj-database-url": { "hashes": [ - "sha256:3e792567b0aa9a4884860af05fe2aa4968071ad351e033b6db632f97ac6db9de", - "sha256:9f9b05058ddf888f1e6f840048b8d705ff9395e3b52a07165daa3d8b9360551b" + "sha256:ae52e8e634186b57e5a45e445da5dc407a819c2ceed8a53d1fac004cc5288787", + "sha256:bb0d414ba0ac5cd62773ec7f86f8cc378a9dbb00a80884c2fc08cc570452521e" ], - "version": "==2.2.0" + "version": "==2.3.0" }, "dj-email-url": { "hashes": [ @@ -321,12 +308,12 @@ }, "django": { "hashes": [ - "sha256:a2d4c4d4ea0b6f0895acde632071aff6400bfc331228fc978b05452a0ff3e9f1", - "sha256:b1260ed381b10a11753c73444408e19869f3241fc45c985cd55a30177c789d13" + "sha256:3a93350214ba25f178d4045c0786c61573e7dbfa3c509b3551374f1e11ba8de0", + "sha256:6b56d834cc94c8b21a8f4e775064896be3b4a4ca387f2612d4406a5927cd2fdc" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==4.2.10" + "version": "==4.2.17" }, "django-admin-multiple-choice-list-filter": { "hashes": [ @@ -362,12 +349,12 @@ }, "django-cors-headers": { "hashes": [ - "sha256:28c1ded847aa70208798de3e42422a782f427b8b720e8d7319d34b654b5978e6", - "sha256:6c01a85cf1ec779a7bde621db853aa3ce5c065a5ba8e27df7a9f9e8dac310f4f" + "sha256:14d76b4b4c8d39375baeddd89e4f08899051eeaf177cb02a29bd6eae8cf63aa8", + "sha256:8edbc0497e611c24d5150e0055d3b178c6534b8ed826fb6f53b21c63f5d48ba3" ], "index": "pypi", "markers": "python_version >= '3.9'", - "version": "==4.5.0" + "version": "==4.6.0" }, "django-csp": { "hashes": [ @@ -387,12 +374,12 @@ }, "django-import-export": { "hashes": [ - "sha256:16ecc5a9f0df46bde6eb278a3e65ebda0ee1db55656f36440e9fb83f40ab85a3", - "sha256:730ae2443a02b1ba27d8dba078a27ae9123adfcabb78161b4f130843607b3df9" + "sha256:91b47c9a2701a5b039667df5c46ee682a41bb224ac215a0e66b177a459e35983", + "sha256:b261f44aedf572a69f975655afba15bff1e354eddd91d9c1bbd32d3cee44168d" ], "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==4.1.1" + "markers": "python_version >= '3.9'", + "version": "==4.3.3" }, "django-login-required-middleware": { "hashes": [ @@ -414,12 +401,12 @@ }, "django-waffle": { "hashes": [ - "sha256:5979a2f3dd674ef7086480525b39651fc2045427f6d8e6a614192656d3402c5b", - "sha256:e49d7d461d89f3bd8e53f20efe39310acca8f275c9888495e68e195345bf18b1" + "sha256:774f45b929627c9d303620c85419ce1da54066f2082d741af014f5bbd747e372", + "sha256:97709550f4e75ce2a20b13e29f39777e1439a968569f2ee89398ca368afd586c" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==4.1.0" + "version": "==4.2.0" }, "django-widget-tweaks": { "hashes": [ @@ -435,20 +422,20 @@ "django" ], "hashes": [ - "sha256:069727a8f73d8ba8d033d3cd95c0da231d44f38f1da773bf076cef168d312ee8", - "sha256:e0bcfd41c718c07a7db422f9109e490746450da38793fe4ee197f397b9343435" + "sha256:9d2080cf25807a26fc0d4301e2d7b62c64fbf547540f21e3a30cc02bc5fbe948", + "sha256:e068ae3174cef52ba4b95ead22e639056a02465f616e62323e04ae08e86a75a4" ], "markers": "python_version >= '3.8'", - "version": "==11.0.0" + "version": "==11.2.1" }, "faker": { "hashes": [ - "sha256:8760fbb34564fbb2f394345eef24aec5b8f6506b6cfcefe8195ed66dd1032bdb", - "sha256:e8a15fd1b0f72992b008f5ea94c70d3baa0cb51b0d5a0e899c17b1d1b23d2771" + "sha256:1c925fc0e86a51fc46648b504078c88d0cd48da1da2595c4e712841cab43a1e4", + "sha256:d30c5f0e2796b8970de68978365247657486eb0311c5abe88d0b895b68dff05d" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==30.3.0" + "version": "==33.1.0" }, "fred-epplib": { "git": "https://github.com/cisagov/epplib.git", @@ -466,53 +453,53 @@ "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", "sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'", "version": "==1.0.0" }, "gevent": { "hashes": [ - "sha256:013150cc0f00f0a06dd898463ad9ebc43bd9c70c7fe35555c77d83fe6f758225", - "sha256:0814a5a7084e0bd357392e44e2a8bd72fc56fbdc3da0ff492ebb310c10fc95e6", - "sha256:103097b39764a0a02f1a051225ea6b4c64a53dd37603424ca8a1e09be63a460b", - "sha256:16bf432b274795b360d88b38cbffe0a6410450c94bfa172548bf1f512cf448c2", - "sha256:1a5012b7d047b16470063f0b8d003530e77362809f38cd7e601efb625c7ca71e", - "sha256:22bc6efb0f9fbb1c2e005ef1b94374568945c711bfb92f85916f66a819a5e6d0", - "sha256:377c02d0ddae3ebf843d6f453943602102bb186b09f1c78a2247e5dbf0e07b1c", - "sha256:421cfeacae2555b11318c6ee11f34bc0a9517657068d8911c916d55a85362ce2", - "sha256:44174aa4dae4db158e6f11a4ea696f1991d43ccc1634aa0c189daf03a9ced5d7", - "sha256:44dd79cfefea24f9bb630844a25047c3807e02722436e826ef2aed3d646190c1", - "sha256:4e3fbaf484ee68437f0ec589bdb1dd6f1dccc01fd6b72eac707e858b407521fa", - "sha256:4f0e6c49aac1c182be15a43d94e3b58c253d830c5b54dc93d6130e6987278611", - "sha256:539af6b66c6b9faca2cdd903f0a7564c85053f1faf95e9a37702df578ac37085", - "sha256:562b66d8b061b9cfae1bc704b0cd5d2b255628d86c3639ddc16e4ffa3ebf6e7a", - "sha256:5bb80c88f572a11156f258333c0e7b1f80d0746a03784600017901a2f1aa584a", - "sha256:5d1db7bc758455e6f6406d66e8b276b80dda5645877392a100d1ed7dda6aa7ad", - "sha256:618c4869e8140fd955b4620b10bc5a92ef1d62ae20aef38c1af7d892ee1bd996", - "sha256:6a93f249a40bda8c42cbeefff9582b22bb1dd769da56b4cbb824038366c4202c", - "sha256:6b9da562d7d7707d5561ecf4a27a361fd9f4856f39b8491a0753c89d8f39674c", - "sha256:73b65ee9a73a35fb68d96899895162beef19d86c1bcbe6f8f92eb0bd18c1d891", - "sha256:7b5f10ac866d3432a829a3a4446489be1fa3648f3140f9373fe99440a2e05682", - "sha256:81b4915081d148a31b64ad0314d2f609920b8ae6a24d9a7e4ddaab7c1fe998e7", - "sha256:90f9bc542f76efc56e5e76b420abaff42baf585db48a9fc0ac8edd6a16d9e60f", - "sha256:96e7bab9de56e0aca3858b8bc9c71f4eb0c0e12b7cf3cbfd170b62ce68cf71d7", - "sha256:975699ac5701d7ec1c633f2067deecea8711dc2a8683530aed260dd641274791", - "sha256:9f74faefea1acb398f057ed31ee9333e100bdae978b1e4c3b6a27d05df66e588", - "sha256:a11db551555c58606ed3dfe359a9a502e44350ed3ecbd59cbe7b0093bd020418", - "sha256:a6a04df4732bb7fdf9969ddee9a16a829e7971692fefdcb5baca760976d23e04", - "sha256:a72a7cb67764adafbac7ddeeffe539a738309068e2b2ac89cbd2f498383ce537", - "sha256:aabffb8b86fb95cb1ee5dffa315c9bd68fe20a7fe7260c0328679723b0257b7c", - "sha256:bc181db59d53e407650ebf44e63ff429c7bc25f9c346edddce1bdff1af436617", - "sha256:dd9c966e5fd8d7b0a54a130c5ad38ef581fd93ff4c44b6e73767519860da6ebe", - "sha256:ec800c25f09a7e031f2fbc3b17b4a4a0b54085c7532ac51b4c7ecef6d3ff8fc3", - "sha256:f0d6cfff74be4efcafecd374e094a8fed9e0d68efe90109d374ef5d8f18aa21a", - "sha256:f57b7a02e83d6e4a205cace6dd63e16b61a641a1da9366d9ec4f2b849430700f", - "sha256:fa190663f964583c8dbbab06bc863966e6f7eceaac8aa67c3ac0fae0a0a73b80", - "sha256:fa4cba4a8acbb71dd4215be8517879e4217c0746f7af2637330e7269694f53f2", - "sha256:fd9b670da1b7160e660cbba7f52e206892b97f61d8ff1872ce99dfaa9b475420" + "sha256:1c3443b0ed23dcb7c36a748d42587168672953d368f2956b17fad36d43b58836", + "sha256:1d4fadc319b13ef0a3c44d2792f7918cf1bca27cacd4d41431c22e6b46668026", + "sha256:1ea50009ecb7f1327347c37e9eb6561bdbc7de290769ee1404107b9a9cba7cf1", + "sha256:2142704c2adce9cd92f6600f371afb2860a446bfd0be5bd86cca5b3e12130766", + "sha256:351d1c0e4ef2b618ace74c91b9b28b3eaa0dd45141878a964e03c7873af09f62", + "sha256:356b73d52a227d3313f8f828025b665deada57a43d02b1cf54e5d39028dbcf8d", + "sha256:3d882faa24f347f761f934786dde6c73aa6c9187ee710189f12dcc3a63ed4a50", + "sha256:58851f23c4bdb70390f10fc020c973ffcf409eb1664086792c8b1e20f25eef43", + "sha256:68bee86b6e1c041a187347ef84cf03a792f0b6c7238378bf6ba4118af11feaae", + "sha256:7398c629d43b1b6fd785db8ebd46c0a353880a6fab03d1cf9b6788e7240ee32e", + "sha256:816b3883fa6842c1cf9d2786722014a0fd31b6312cca1f749890b9803000bad6", + "sha256:81d918e952954675f93fb39001da02113ec4d5f4921bf5a0cc29719af6824e5d", + "sha256:85329d556aaedced90a993226d7d1186a539c843100d393f2349b28c55131c85", + "sha256:8619d5c888cb7aebf9aec6703e410620ef5ad48cdc2d813dd606f8aa7ace675f", + "sha256:8bd1419114e9e4a3ed33a5bad766afff9a3cf765cb440a582a1b3a9bc80c1aca", + "sha256:92e0d7759de2450a501effd99374256b26359e801b2d8bf3eedd3751973e87f5", + "sha256:92fe5dfee4e671c74ffaa431fd7ffd0ebb4b339363d24d0d944de532409b935e", + "sha256:97e2f3999a5c0656f42065d02939d64fffaf55861f7d62b0107a08f52c984897", + "sha256:9d3b249e4e1f40c598ab8393fc01ae6a3b4d51fc1adae56d9ba5b315f6b2d758", + "sha256:a3d75fa387b69c751a3d7c5c3ce7092a171555126e136c1d21ecd8b50c7a6e46", + "sha256:a5f1701ce0f7832f333dd2faf624484cbac99e60656bfbb72504decd42970f0f", + "sha256:b24d800328c39456534e3bc3e1684a28747729082684634789c2f5a8febe7671", + "sha256:b5efe72e99b7243e222ba0c2c2ce9618d7d36644c166d63373af239da1036bab", + "sha256:b7bfcfe08d038e1fa6de458891bca65c1ada6d145474274285822896a858c870", + "sha256:beede1d1cff0c6fafae3ab58a0c470d7526196ef4cd6cc18e7769f207f2ea4eb", + "sha256:c6b775381f805ff5faf250e3a07c0819529571d19bb2a9d474bee8c3f90d66af", + "sha256:c9c935b83d40c748b6421625465b7308d87c7b3717275acd587eef2bd1c39546", + "sha256:ca845138965c8c56d1550499d6b923eb1a2331acfa9e13b817ad8305dde83d11", + "sha256:d618e118fdb7af1d6c1a96597a5cd6ac84a9f3732b5be8515c6a66e098d498b6", + "sha256:d6c0a065e31ef04658f799215dddae8752d636de2bed61365c358f9c91e7af61", + "sha256:d740206e69dfdfdcd34510c20adcb9777ce2cc18973b3441ab9767cd8948ca8a", + "sha256:d7886b63ebfb865178ab28784accd32f287d5349b3ed71094c86e4d3ca738af5", + "sha256:d9347690f4e53de2c4af74e62d6fabc940b6d4a6cad555b5a379f61e7d3f2a8e", + "sha256:d9ca80711e6553880974898d99357fb649e062f9058418a92120ca06c18c3c59", + "sha256:e24181d172f50097ac8fc272c8c5b030149b630df02d1c639ee9f878a470ba2b", + "sha256:ec68e270543ecd532c4c1d70fca020f90aa5486ad49c4f3b8b2e64a66f5c9274", + "sha256:f43f47e702d0c8e1b8b997c00f1601486f9f976f84ab704f8f11536e3fa144c9", + "sha256:ff96c5739834c9a594db0e12bf59cb3fa0e5102fc7b893972118a3166733d61c" ], "index": "pypi", "markers": "python_version >= '3.9'", - "version": "==24.10.2" + "version": "==24.11.1" }, "greenlet": { "hashes": [ @@ -765,86 +752,86 @@ }, "mako": { "hashes": [ - "sha256:260f1dbc3a519453a9c856dedfe4beb4e50bd5a26d96386cb6c80856556bb91a", - "sha256:48dbc20568c1d276a2698b36d968fa76161bf127194907ea6fc594fa81f943bc" + "sha256:42f48953c7eb91332040ff567eb7eea69b22e7a4affbc5ba8e845e8f730f6627", + "sha256:577b97e414580d3e088d47c2dbbe9594aa7a5146ed2875d4dfa9075af2dd3cc8" ], "markers": "python_version >= '3.8'", - "version": "==1.3.5" + "version": "==1.3.8" }, "markupsafe": { "hashes": [ - "sha256:0778de17cff1acaeccc3ff30cd99a3fd5c50fc58ad3d6c0e0c4c58092b859396", - "sha256:0f84af7e813784feb4d5e4ff7db633aba6c8ca64a833f61d8e4eade234ef0c38", - "sha256:17b2aea42a7280db02ac644db1d634ad47dcc96faf38ab304fe26ba2680d359a", - "sha256:242d6860f1fd9191aef5fae22b51c5c19767f93fb9ead4d21924e0bcb17619d8", - "sha256:244dbe463d5fb6d7ce161301a03a6fe744dac9072328ba9fc82289238582697b", - "sha256:26627785a54a947f6d7336ce5963569b5d75614619e75193bdb4e06e21d447ad", - "sha256:2a4b34a8d14649315c4bc26bbfa352663eb51d146e35eef231dd739d54a5430a", - "sha256:2ae99f31f47d849758a687102afdd05bd3d3ff7dbab0a8f1587981b58a76152a", - "sha256:312387403cd40699ab91d50735ea7a507b788091c416dd007eac54434aee51da", - "sha256:3341c043c37d78cc5ae6e3e305e988532b072329639007fd408a476642a89fd6", - "sha256:33d1c36b90e570ba7785dacd1faaf091203d9942bc036118fab8110a401eb1a8", - "sha256:3e683ee4f5d0fa2dde4db77ed8dd8a876686e3fc417655c2ece9a90576905344", - "sha256:3ffb4a8e7d46ed96ae48805746755fadd0909fea2306f93d5d8233ba23dda12a", - "sha256:40621d60d0e58aa573b68ac5e2d6b20d44392878e0bfc159012a5787c4e35bc8", - "sha256:40f1e10d51c92859765522cbd79c5c8989f40f0419614bcdc5015e7b6bf97fc5", - "sha256:45d42d132cff577c92bfba536aefcfea7e26efb975bd455db4e6602f5c9f45e7", - "sha256:48488d999ed50ba8d38c581d67e496f955821dc183883550a6fbc7f1aefdc170", - "sha256:4935dd7883f1d50e2ffecca0aa33dc1946a94c8f3fdafb8df5c330e48f71b132", - "sha256:4c2d64fdba74ad16138300815cfdc6ab2f4647e23ced81f59e940d7d4a1469d9", - "sha256:4c8817557d0de9349109acb38b9dd570b03cc5014e8aabf1cbddc6e81005becd", - "sha256:4ffaaac913c3f7345579db4f33b0020db693f302ca5137f106060316761beea9", - "sha256:5a4cb365cb49b750bdb60b846b0c0bc49ed62e59a76635095a179d440540c346", - "sha256:62fada2c942702ef8952754abfc1a9f7658a4d5460fabe95ac7ec2cbe0d02abc", - "sha256:67c519635a4f64e495c50e3107d9b4075aec33634272b5db1cde839e07367589", - "sha256:6a54c43d3ec4cf2a39f4387ad044221c66a376e58c0d0e971d47c475ba79c6b5", - "sha256:7044312a928a66a4c2a22644147bc61a199c1709712069a344a3fb5cfcf16915", - "sha256:730d86af59e0e43ce277bb83970530dd223bf7f2a838e086b50affa6ec5f9295", - "sha256:800100d45176652ded796134277ecb13640c1a537cad3b8b53da45aa96330453", - "sha256:80fcbf3add8790caddfab6764bde258b5d09aefbe9169c183f88a7410f0f6dea", - "sha256:82b5dba6eb1bcc29cc305a18a3c5365d2af06ee71b123216416f7e20d2a84e5b", - "sha256:852dc840f6d7c985603e60b5deaae1d89c56cb038b577f6b5b8c808c97580f1d", - "sha256:8ad4ad1429cd4f315f32ef263c1342166695fad76c100c5d979c45d5570ed58b", - "sha256:8ae369e84466aa70f3154ee23c1451fda10a8ee1b63923ce76667e3077f2b0c4", - "sha256:93e8248d650e7e9d49e8251f883eed60ecbc0e8ffd6349e18550925e31bd029b", - "sha256:973a371a55ce9ed333a3a0f8e0bcfae9e0d637711534bcb11e130af2ab9334e7", - "sha256:9ba25a71ebf05b9bb0e2ae99f8bc08a07ee8e98c612175087112656ca0f5c8bf", - "sha256:a10860e00ded1dd0a65b83e717af28845bb7bd16d8ace40fe5531491de76b79f", - "sha256:a4792d3b3a6dfafefdf8e937f14906a51bd27025a36f4b188728a73382231d91", - "sha256:a7420ceda262dbb4b8d839a4ec63d61c261e4e77677ed7c66c99f4e7cb5030dd", - "sha256:ad91738f14eb8da0ff82f2acd0098b6257621410dcbd4df20aaa5b4233d75a50", - "sha256:b6a387d61fe41cdf7ea95b38e9af11cfb1a63499af2759444b99185c4ab33f5b", - "sha256:b954093679d5750495725ea6f88409946d69cfb25ea7b4c846eef5044194f583", - "sha256:bbde71a705f8e9e4c3e9e33db69341d040c827c7afa6789b14c6e16776074f5a", - "sha256:beeebf760a9c1f4c07ef6a53465e8cfa776ea6a2021eda0d0417ec41043fe984", - "sha256:c91b394f7601438ff79a4b93d16be92f216adb57d813a78be4446fe0f6bc2d8c", - "sha256:c97ff7fedf56d86bae92fa0a646ce1a0ec7509a7578e1ed238731ba13aabcd1c", - "sha256:cb53e2a99df28eee3b5f4fea166020d3ef9116fdc5764bc5117486e6d1211b25", - "sha256:cbf445eb5628981a80f54087f9acdbf84f9b7d862756110d172993b9a5ae81aa", - "sha256:d06b24c686a34c86c8c1fba923181eae6b10565e4d80bdd7bc1c8e2f11247aa4", - "sha256:d98e66a24497637dd31ccab090b34392dddb1f2f811c4b4cd80c230205c074a3", - "sha256:db15ce28e1e127a0013dfb8ac243a8e392db8c61eae113337536edb28bdc1f97", - "sha256:db842712984e91707437461930e6011e60b39136c7331e971952bb30465bc1a1", - "sha256:e24bfe89c6ac4c31792793ad9f861b8f6dc4546ac6dc8f1c9083c7c4f2b335cd", - "sha256:e81c52638315ff4ac1b533d427f50bc0afc746deb949210bc85f05d4f15fd772", - "sha256:e9393357f19954248b00bed7c56f29a25c930593a77630c719653d51e7669c2a", - "sha256:ee3941769bd2522fe39222206f6dd97ae83c442a94c90f2b7a25d847d40f4729", - "sha256:f31ae06f1328595d762c9a2bf29dafd8621c7d3adc130cbb46278079758779ca", - "sha256:f94190df587738280d544971500b9cafc9b950d32efcb1fba9ac10d84e6aa4e6", - "sha256:fa7d686ed9883f3d664d39d5a8e74d3c5f63e603c2e3ff0abcba23eac6542635", - "sha256:fb532dd9900381d2e8f48172ddc5a59db4c445a11b9fab40b3b786da40d3b56b", - "sha256:fe32482b37b4b00c7a52a07211b479653b7fe4f22b2e481b9a9b099d8a430f2f" + "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", + "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", + "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0", + "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", + "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", + "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13", + "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", + "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", + "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", + "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", + "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0", + "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", + "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", + "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", + "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", + "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff", + "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", + "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", + "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", + "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", + "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", + "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", + "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", + "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", + "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a", + "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", + "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", + "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", + "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", + "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144", + "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f", + "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", + "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", + "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", + "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", + "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", + "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", + "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", + "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", + "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", + "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", + "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", + "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", + "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", + "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", + "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", + "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", + "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", + "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29", + "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", + "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", + "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", + "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", + "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", + "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", + "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a", + "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178", + "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", + "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", + "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", + "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50" ], "markers": "python_version >= '3.9'", - "version": "==3.0.1" + "version": "==3.0.2" }, "marshmallow": { "hashes": [ - "sha256:4972f529104a220bb8637d595aa4c9762afbe7f7a77d82dc58c1615d70c5823e", - "sha256:71a2dce49ef901c3f97ed296ae5051135fd3febd2bf43afe0ae9a82143a494d9" + "sha256:bcaf2d6fd74fb1459f8450e85d994997ad3e70036452cbfa4ab685acb19479b3", + "sha256:c448ac6455ca4d794773f00bae22c2f351d62d739929f761dce5eacb5c468d7f" ], - "markers": "python_version >= '3.8'", - "version": "==3.22.0" + "markers": "python_version >= '3.9'", + "version": "==3.23.2" }, "oic": { "hashes": [ @@ -864,97 +851,92 @@ }, "packaging": { "hashes": [ - "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", - "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124" + "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", + "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f" ], "markers": "python_version >= '3.8'", - "version": "==24.1" + "version": "==24.2" }, "phonenumberslite": { "hashes": [ - "sha256:9a4d040f4ef9ea5cbbd907f6fe9a52313d46191051e3a9994102c05082a9db67", - "sha256:baf770804c056a122c76f0d29d3a85bd3111c511c5350548e1c3355449b824e9" + "sha256:02da5e78c67b213bae95afd6289f40486c93e302e518769911dfa5e7287ddeee", + "sha256:dfa44a4bae2e46d737ae5301cb96b14cdcbf45063236c74c6ddb08f5fd471b0d" ], - "version": "==8.13.47" + "version": "==8.13.52" }, "psycopg2-binary": { "hashes": [ - "sha256:03ef7df18daf2c4c07e2695e8cfd5ee7f748a1d54d802330985a78d2a5a6dca9", - "sha256:0a602ea5aff39bb9fac6308e9c9d82b9a35c2bf288e184a816002c9fae930b77", - "sha256:0c009475ee389757e6e34611d75f6e4f05f0cf5ebb76c6037508318e1a1e0d7e", - "sha256:0ef4854e82c09e84cc63084a9e4ccd6d9b154f1dbdd283efb92ecd0b5e2b8c84", - "sha256:1236ed0952fbd919c100bc839eaa4a39ebc397ed1c08a97fc45fee2a595aa1b3", - "sha256:143072318f793f53819048fdfe30c321890af0c3ec7cb1dfc9cc87aa88241de2", - "sha256:15208be1c50b99203fe88d15695f22a5bed95ab3f84354c494bcb1d08557df67", - "sha256:1873aade94b74715be2246321c8650cabf5a0d098a95bab81145ffffa4c13876", - "sha256:18d0ef97766055fec15b5de2c06dd8e7654705ce3e5e5eed3b6651a1d2a9a152", - "sha256:1ea665f8ce695bcc37a90ee52de7a7980be5161375d42a0b6c6abedbf0d81f0f", - "sha256:2293b001e319ab0d869d660a704942c9e2cce19745262a8aba2115ef41a0a42a", - "sha256:246b123cc54bb5361588acc54218c8c9fb73068bf227a4a531d8ed56fa3ca7d6", - "sha256:275ff571376626195ab95a746e6a04c7df8ea34638b99fc11160de91f2fef503", - "sha256:281309265596e388ef483250db3640e5f414168c5a67e9c665cafce9492eda2f", - "sha256:2d423c8d8a3c82d08fe8af900ad5b613ce3632a1249fd6a223941d0735fce493", - "sha256:2e5afae772c00980525f6d6ecf7cbca55676296b580c0e6abb407f15f3706996", - "sha256:30dcc86377618a4c8f3b72418df92e77be4254d8f89f14b8e8f57d6d43603c0f", - "sha256:31a34c508c003a4347d389a9e6fcc2307cc2150eb516462a7a17512130de109e", - "sha256:323ba25b92454adb36fa425dc5cf6f8f19f78948cbad2e7bc6cdf7b0d7982e59", - "sha256:34eccd14566f8fe14b2b95bb13b11572f7c7d5c36da61caf414d23b91fcc5d94", - "sha256:3a58c98a7e9c021f357348867f537017057c2ed7f77337fd914d0bedb35dace7", - "sha256:3f78fd71c4f43a13d342be74ebbc0666fe1f555b8837eb113cb7416856c79682", - "sha256:4154ad09dac630a0f13f37b583eae260c6aa885d67dfbccb5b02c33f31a6d420", - "sha256:420f9bbf47a02616e8554e825208cb947969451978dceb77f95ad09c37791dae", - "sha256:4686818798f9194d03c9129a4d9a702d9e113a89cb03bffe08c6cf799e053291", - "sha256:57fede879f08d23c85140a360c6a77709113efd1c993923c59fde17aa27599fe", - "sha256:60989127da422b74a04345096c10d416c2b41bd7bf2a380eb541059e4e999980", - "sha256:64cf30263844fa208851ebb13b0732ce674d8ec6a0c86a4e160495d299ba3c93", - "sha256:68fc1f1ba168724771e38bee37d940d2865cb0f562380a1fb1ffb428b75cb692", - "sha256:6e6f98446430fdf41bd36d4faa6cb409f5140c1c2cf58ce0bbdaf16af7d3f119", - "sha256:729177eaf0aefca0994ce4cffe96ad3c75e377c7b6f4efa59ebf003b6d398716", - "sha256:72dffbd8b4194858d0941062a9766f8297e8868e1dd07a7b36212aaa90f49472", - "sha256:75723c3c0fbbf34350b46a3199eb50638ab22a0228f93fb472ef4d9becc2382b", - "sha256:77853062a2c45be16fd6b8d6de2a99278ee1d985a7bd8b103e97e41c034006d2", - "sha256:78151aa3ec21dccd5cdef6c74c3e73386dcdfaf19bced944169697d7ac7482fc", - "sha256:7f01846810177d829c7692f1f5ada8096762d9172af1b1a28d4ab5b77c923c1c", - "sha256:804d99b24ad523a1fe18cc707bf741670332f7c7412e9d49cb5eab67e886b9b5", - "sha256:81ff62668af011f9a48787564ab7eded4e9fb17a4a6a74af5ffa6a457400d2ab", - "sha256:8359bf4791968c5a78c56103702000105501adb557f3cf772b2c207284273984", - "sha256:83791a65b51ad6ee6cf0845634859d69a038ea9b03d7b26e703f94c7e93dbcf9", - "sha256:8532fd6e6e2dc57bcb3bc90b079c60de896d2128c5d9d6f24a63875a95a088cf", - "sha256:876801744b0dee379e4e3c38b76fc89f88834bb15bf92ee07d94acd06ec890a0", - "sha256:8dbf6d1bc73f1d04ec1734bae3b4fb0ee3cb2a493d35ede9badbeb901fb40f6f", - "sha256:8f8544b092a29a6ddd72f3556a9fcf249ec412e10ad28be6a0c0d948924f2212", - "sha256:911dda9c487075abd54e644ccdf5e5c16773470a6a5d3826fda76699410066fb", - "sha256:977646e05232579d2e7b9c59e21dbe5261f403a88417f6a6512e70d3f8a046be", - "sha256:9dba73be7305b399924709b91682299794887cbbd88e38226ed9f6712eabee90", - "sha256:a148c5d507bb9b4f2030a2025c545fccb0e1ef317393eaba42e7eabd28eb6041", - "sha256:a6cdcc3ede532f4a4b96000b6362099591ab4a3e913d70bcbac2b56c872446f7", - "sha256:ac05fb791acf5e1a3e39402641827780fe44d27e72567a000412c648a85ba860", - "sha256:b0605eaed3eb239e87df0d5e3c6489daae3f7388d455d0c0b4df899519c6a38d", - "sha256:b58b4710c7f4161b5e9dcbe73bb7c62d65670a87df7bcce9e1faaad43e715245", - "sha256:b6356793b84728d9d50ead16ab43c187673831e9d4019013f1402c41b1db9b27", - "sha256:b76bedd166805480ab069612119ea636f5ab8f8771e640ae103e05a4aae3e417", - "sha256:bc7bb56d04601d443f24094e9e31ae6deec9ccb23581f75343feebaf30423359", - "sha256:c2470da5418b76232f02a2fcd2229537bb2d5a7096674ce61859c3229f2eb202", - "sha256:c332c8d69fb64979ebf76613c66b985414927a40f8defa16cf1bc028b7b0a7b0", - "sha256:c6af2a6d4b7ee9615cbb162b0738f6e1fd1f5c3eda7e5da17861eacf4c717ea7", - "sha256:c77e3d1862452565875eb31bdb45ac62502feabbd53429fdc39a1cc341d681ba", - "sha256:ca08decd2697fdea0aea364b370b1249d47336aec935f87b8bbfd7da5b2ee9c1", - "sha256:ca49a8119c6cbd77375ae303b0cfd8c11f011abbbd64601167ecca18a87e7cdd", - "sha256:cb16c65dcb648d0a43a2521f2f0a2300f40639f6f8c1ecbc662141e4e3e1ee07", - "sha256:d2997c458c690ec2bc6b0b7ecbafd02b029b7b4283078d3b32a852a7ce3ddd98", - "sha256:d3f82c171b4ccd83bbaf35aa05e44e690113bd4f3b7b6cc54d2219b132f3ae55", - "sha256:dc4926288b2a3e9fd7b50dc6a1909a13bbdadfc67d93f3374d984e56f885579d", - "sha256:ead20f7913a9c1e894aebe47cccf9dc834e1618b7aa96155d2091a626e59c972", - "sha256:ebdc36bea43063116f0486869652cb2ed7032dbc59fbcb4445c4862b5c1ecf7f", - "sha256:ed1184ab8f113e8d660ce49a56390ca181f2981066acc27cf637d5c1e10ce46e", - "sha256:ee825e70b1a209475622f7f7b776785bd68f34af6e7a46e2e42f27b659b5bc26", - "sha256:f7ae5d65ccfbebdfa761585228eb4d0df3a8b15cfb53bd953e713e09fbb12957", - "sha256:f7fc5a5acafb7d6ccca13bfa8c90f8c51f13d8fb87d95656d3950f0158d3ce53", - "sha256:f9b5571d33660d5009a8b3c25dc1db560206e2d2f89d3df1cb32d72c0d117d52" + "sha256:04392983d0bb89a8717772a193cfaac58871321e3ec69514e1c4e0d4957b5aff", + "sha256:056470c3dc57904bbf63d6f534988bafc4e970ffd50f6271fc4ee7daad9498a5", + "sha256:0ea8e3d0ae83564f2fc554955d327fa081d065c8ca5cc6d2abb643e2c9c1200f", + "sha256:155e69561d54d02b3c3209545fb08938e27889ff5a10c19de8d23eb5a41be8a5", + "sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0", + "sha256:19721ac03892001ee8fdd11507e6a2e01f4e37014def96379411ca99d78aeb2c", + "sha256:1a6784f0ce3fec4edc64e985865c17778514325074adf5ad8f80636cd029ef7c", + "sha256:2286791ececda3a723d1910441c793be44625d86d1a4e79942751197f4d30341", + "sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f", + "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7", + "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d", + "sha256:270934a475a0e4b6925b5f804e3809dd5f90f8613621d062848dd82f9cd62007", + "sha256:2ad26b467a405c798aaa1458ba09d7e2b6e5f96b1ce0ac15d82fd9f95dc38a92", + "sha256:2b3d2491d4d78b6b14f76881905c7a8a8abcf974aad4a8a0b065273a0ed7a2cb", + "sha256:2ce3e21dc3437b1d960521eca599d57408a695a0d3c26797ea0f72e834c7ffe5", + "sha256:30e34c4e97964805f715206c7b789d54a78b70f3ff19fbe590104b71c45600e5", + "sha256:3216ccf953b3f267691c90c6fe742e45d890d8272326b4a8b20850a03d05b7b8", + "sha256:32581b3020c72d7a421009ee1c6bf4a131ef5f0a968fab2e2de0c9d2bb4577f1", + "sha256:35958ec9e46432d9076286dda67942ed6d968b9c3a6a2fd62b48939d1d78bf68", + "sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73", + "sha256:3c18f74eb4386bf35e92ab2354a12c17e5eb4d9798e4c0ad3a00783eae7cd9f1", + "sha256:3c4745a90b78e51d9ba06e2088a2fe0c693ae19cc8cb051ccda44e8df8a6eb53", + "sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d", + "sha256:3e9c76f0ac6f92ecfc79516a8034a544926430f7b080ec5a0537bca389ee0906", + "sha256:48b338f08d93e7be4ab2b5f1dbe69dc5e9ef07170fe1f86514422076d9c010d0", + "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2", + "sha256:512d29bb12608891e349af6a0cccedce51677725a921c07dba6342beaf576f9a", + "sha256:5a507320c58903967ef7384355a4da7ff3f28132d679aeb23572753cbf2ec10b", + "sha256:5c370b1e4975df846b0277b4deba86419ca77dbc25047f535b0bb03d1a544d44", + "sha256:6b269105e59ac96aba877c1707c600ae55711d9dcd3fc4b5012e4af68e30c648", + "sha256:6d4fa1079cab9018f4d0bd2db307beaa612b0d13ba73b5c6304b9fe2fb441ff7", + "sha256:6dc08420625b5a20b53551c50deae6e231e6371194fa0651dbe0fb206452ae1f", + "sha256:73aa0e31fa4bb82578f3a6c74a73c273367727de397a7a0f07bd83cbea696baa", + "sha256:7559bce4b505762d737172556a4e6ea8a9998ecac1e39b5233465093e8cee697", + "sha256:79625966e176dc97ddabc142351e0409e28acf4660b88d1cf6adb876d20c490d", + "sha256:7a813c8bdbaaaab1f078014b9b0b13f5de757e2b5d9be6403639b298a04d218b", + "sha256:7b2c956c028ea5de47ff3a8d6b3cc3330ab45cf0b7c3da35a2d6ff8420896526", + "sha256:7f4152f8f76d2023aac16285576a9ecd2b11a9895373a1f10fd9db54b3ff06b4", + "sha256:7f5d859928e635fa3ce3477704acee0f667b3a3d3e4bb109f2b18d4005f38287", + "sha256:851485a42dbb0bdc1edcdabdb8557c09c9655dfa2ca0460ff210522e073e319e", + "sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673", + "sha256:880845dfe1f85d9d5f7c412efea7a08946a46894537e4e5d091732eb1d34d9a0", + "sha256:8aabf1c1a04584c168984ac678a668094d831f152859d06e055288fa515e4d30", + "sha256:8aecc5e80c63f7459a1a2ab2c64df952051df196294d9f739933a9f6687e86b3", + "sha256:8cd9b4f2cfab88ed4a9106192de509464b75a906462fb846b936eabe45c2063e", + "sha256:8de718c0e1c4b982a54b41779667242bc630b2197948405b7bd8ce16bcecac92", + "sha256:9440fa522a79356aaa482aa4ba500b65f28e5d0e63b801abf6aa152a29bd842a", + "sha256:b5f86c56eeb91dc3135b3fd8a95dc7ae14c538a2f3ad77a19645cf55bab1799c", + "sha256:b73d6d7f0ccdad7bc43e6d34273f70d587ef62f824d7261c4ae9b8b1b6af90e8", + "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909", + "sha256:c3cc28a6fd5a4a26224007712e79b81dbaee2ffb90ff406256158ec4d7b52b47", + "sha256:ce5ab4bf46a211a8e924d307c1b1fcda82368586a19d0a24f8ae166f5c784864", + "sha256:d00924255d7fc916ef66e4bf22f354a940c67179ad3fd7067d7a0a9c84d2fbfc", + "sha256:d7cd730dfa7c36dbe8724426bf5612798734bff2d3c3857f36f2733f5bfc7c00", + "sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb", + "sha256:e3923c1d9870c49a2d44f795df0c889a22380d36ef92440ff618ec315757e539", + "sha256:e5720a5d25e3b99cd0dc5c8a440570469ff82659bb09431c1439b92caf184d3b", + "sha256:e8b58f0a96e7a1e341fc894f62c1177a7c83febebb5ff9123b579418fdc8a481", + "sha256:e984839e75e0b60cfe75e351db53d6db750b00de45644c5d1f7ee5d1f34a1ce5", + "sha256:eb09aa7f9cecb45027683bb55aebaaf45a0df8bf6de68801a6afdc7947bb09d4", + "sha256:ec8a77f521a17506a24a5f626cb2aee7850f9b69a0afe704586f63a464f3cd64", + "sha256:ecced182e935529727401b24d76634a357c71c9275b356efafd8a2a91ec07392", + "sha256:ee0e8c683a7ff25d23b55b11161c2663d4b099770f6085ff0a20d4505778d6b4", + "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1", + "sha256:f758ed67cab30b9a8d2833609513ce4d3bd027641673d4ebc9c067e4d208eec1", + "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567", + "sha256:ffe8ed017e4ed70f68b7b371d84b7d4a790368db9203dfc2d222febd3a9c8863" ], "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==2.9.9" + "markers": "python_version >= '3.8'", + "version": "==2.9.10" }, "pycparser": { "hashes": [ @@ -1005,114 +987,125 @@ }, "pydantic": { "hashes": [ - "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f", - "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12" + "sha256:597e135ea68be3a37552fb524bc7d0d66dcf93d395acd93a00682f1efcb8ee3d", + "sha256:82f12e9723da6de4fe2ba888b5971157b3be7ad914267dea8f05f82b28254f06" ], "markers": "python_version >= '3.8'", - "version": "==2.9.2" + "version": "==2.10.4" }, "pydantic-core": { "hashes": [ - "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36", - "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05", - "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071", - "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327", - "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c", - "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36", - "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29", - "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744", - "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d", - "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec", - "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e", - "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e", - "sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577", - "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232", - "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863", - "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6", - "sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368", - "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480", - "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2", - "sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2", - "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6", - "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769", - "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d", - "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2", - "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84", - "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166", - "sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271", - "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5", - "sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb", - "sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13", - "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323", - "sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556", - "sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665", - "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef", - "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb", - "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119", - "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126", - "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510", - "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b", - "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87", - "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f", - "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc", - "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8", - "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21", - "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f", - "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6", - "sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658", - "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b", - "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3", - "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb", - "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59", - "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24", - "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9", - "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3", - "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd", - "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753", - "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55", - "sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad", - "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a", - "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605", - "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e", - "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b", - "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433", - "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8", - "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07", - "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728", - "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0", - "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327", - "sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555", - "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64", - "sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6", - "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea", - "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b", - "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df", - "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e", - "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd", - "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068", - "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3", - "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040", - "sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12", - "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916", - "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f", - "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f", - "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801", - "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231", - "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5", - "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8", - "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee", - "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607" + "sha256:00bad2484fa6bda1e216e7345a798bd37c68fb2d97558edd584942aa41b7d278", + "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50", + "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9", + "sha256:044a50963a614ecfae59bb1eaf7ea7efc4bc62f49ed594e18fa1e5d953c40e9f", + "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", + "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc", + "sha256:097830ed52fd9e427942ff3b9bc17fab52913b2f50f2880dc4a5611446606a54", + "sha256:0d1e85068e818c73e048fe28cfc769040bb1f475524f4745a5dc621f75ac7630", + "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9", + "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236", + "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", + "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", + "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b", + "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048", + "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", + "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", + "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", + "sha256:251136cdad0cb722e93732cb45ca5299fb56e1344a833640bf93b2803f8d1bfd", + "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4", + "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7", + "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7", + "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", + "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e", + "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa", + "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6", + "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962", + "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", + "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f", + "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474", + "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5", + "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459", + "sha256:42c5f762659e47fdb7b16956c71598292f60a03aa92f8b6351504359dbdba6cf", + "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a", + "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c", + "sha256:4c9775e339e42e79ec99c441d9730fccf07414af63eac2f0e48e08fd38a64d76", + "sha256:4e0b4220ba5b40d727c7f879eac379b822eee5d8fff418e9d3381ee45b3b0362", + "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4", + "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", + "sha256:521eb9b7f036c9b6187f0b47318ab0d7ca14bd87f776240b90b21c1f4f149320", + "sha256:57762139821c31847cfb2df63c12f725788bd9f04bc2fb392790959b8f70f118", + "sha256:5e4f4bb20d75e9325cc9696c6802657b58bc1dbbe3022f32cc2b2b632c3fbb96", + "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306", + "sha256:669e193c1c576a58f132e3158f9dfa9662969edb1a250c54d8fa52590045f046", + "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3", + "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", + "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af", + "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", + "sha256:77d1bca19b0f7021b3a982e6f903dcd5b2b06076def36a652e3907f596e29f67", + "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a", + "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", + "sha256:7d0c8399fcc1848491f00e0314bd59fb34a9c008761bcb422a057670c3f65e35", + "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", + "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151", + "sha256:8083d4e875ebe0b864ffef72a4304827015cff328a1be6e22cc850753bfb122b", + "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", + "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133", + "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", + "sha256:85210c4d99a0114f5a9481b44560d7d1e35e32cc5634c656bc48e590b669b145", + "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15", + "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", + "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc", + "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", + "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", + "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", + "sha256:9fdbe7629b996647b99c01b37f11170a57ae675375b14b8c13b8518b8320ced5", + "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", + "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", + "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8", + "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", + "sha256:bca101c00bff0adb45a833f8451b9105d9df18accb8743b08107d7ada14bd7da", + "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", + "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc", + "sha256:c10eb4f1659290b523af58fa7cffb452a61ad6ae5613404519aee4bfbf1df993", + "sha256:c33939a82924da9ed65dab5a65d427205a73181d8098e79b6b426bdf8ad4e656", + "sha256:c61709a844acc6bf0b7dce7daae75195a10aac96a596ea1b776996414791ede4", + "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c", + "sha256:c817e2b40aba42bac6f457498dacabc568c3b7a986fc9ba7c8d9d260b71485fb", + "sha256:cabb9bcb7e0d97f74df8646f34fc76fbf793b7f6dc2438517d7a9e50eee4f14d", + "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", + "sha256:cca63613e90d001b9f2f9a9ceb276c308bfa2a43fafb75c8031c4f66039e8c6e", + "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", + "sha256:d2088237af596f0a524d3afc39ab3b036e8adb054ee57cbb1dcf8e09da5b29cc", + "sha256:d262606bf386a5ba0b0af3b97f37c83d7011439e3dc1a9298f21efb292e42f1a", + "sha256:d2d63f1215638d28221f664596b1ccb3944f6e25dd18cd3b86b0a4c408d5ebb9", + "sha256:d3e8d504bdd3f10835468f29008d72fc8359d95c9c415ce6e767203db6127506", + "sha256:d4041c0b966a84b4ae7a09832eb691a35aec90910cd2dbe7a208de59be77965b", + "sha256:d716e2e30c6f140d7560ef1538953a5cd1a87264c737643d481f2779fc247fe1", + "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d", + "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99", + "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", + "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31", + "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c", + "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", + "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", + "sha256:ef592d4bad47296fb11f96cd7dc898b92e795032b4894dfb4076cfccd43a9308", + "sha256:f141ee28a0ad2123b6611b6ceff018039df17f32ada8b534e6aa039545a3efb2", + "sha256:f66d89ba397d92f840f8654756196d93804278457b5fbede59598a1f9f90b228", + "sha256:f6f8e111843bbb0dee4cb6594cdc73e79b3329b526037ec242a3e49012495b3b", + "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", + "sha256:fd1aea04935a508f62e0d0ef1f5ae968774a32afc306fb8545e06f5ff5cdf3ad" ], "markers": "python_version >= '3.8'", - "version": "==2.23.4" + "version": "==2.27.2" }, "pydantic-settings": { "hashes": [ - "sha256:2c912e55fd5794a59bf8c832b9de832dcfdf4778d79ff79b708744eed499a907", - "sha256:f90b139682bee4d2065273d5185d71d37ea46cfe57e1b5ae184fc6a0b2484ca0" + "sha256:10c9caad35e64bfb3c2fbf70a078c0e25cc92499782e5200747f942a065dec93", + "sha256:590be9e6e24d06db33a4262829edef682500ef008565a969c73d39d5f8bfb3fd" ], "markers": "python_version >= '3.8'", - "version": "==2.5.2" + "version": "==2.7.1" }, "pyjwkest": { "hashes": [ @@ -1126,7 +1119,7 @@ "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", "version": "==2.9.0.post0" }, "python-dotenv": { @@ -1157,50 +1150,43 @@ }, "s3transfer": { "hashes": [ - "sha256:263ed587a5803c6c708d3ce44dc4dfedaab4c1a32e8329bab818933d79ddcf5d", - "sha256:4f50ed74ab84d474ce614475e0b8d5047ff080810aac5d01ea25231cfc944b0c" + "sha256:244a76a24355363a68164241438de1b72f8781664920260c48465896b712a41e", + "sha256:29edc09801743c21eb5ecbc617a152df41d3c287f67b615f73e5f750583666a7" ], "markers": "python_version >= '3.8'", - "version": "==0.10.3" + "version": "==0.10.4" }, "setuptools": { "hashes": [ - "sha256:35ab7fd3bcd95e6b7fd704e4a1539513edad446c097797f2985e0e4b960772f2", - "sha256:d59a21b17a275fb872a9c3dae73963160ae079f1049ed956880cd7c09b120538" + "sha256:8199222558df7c86216af4f84c30e9b34a61d8ba19366cc914424cdbd28252f6", + "sha256:ce74b49e8f7110f9bf04883b730f4765b774ef3ef28f722cce7c273d253aaf7d" ], - "markers": "python_version >= '3.8'", - "version": "==75.1.0" + "markers": "python_version >= '3.9'", + "version": "==75.6.0" }, "six": { "hashes": [ - "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", - "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" + "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", + "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.16.0" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "version": "==1.17.0" }, "sqlparse": { "hashes": [ - "sha256:773dcbf9a5ab44a090f3441e2180efe2560220203dc2f8c0b0fa141e18b505e4", - "sha256:bb6b4df465655ef332548e24f08e205afc81b9ab86cb1c45657a7ff173a3a00e" + "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", + "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca" ], "markers": "python_version >= '3.8'", - "version": "==0.5.1" + "version": "==0.5.3" }, "tablib": { - "extras": [ - "html", - "ods", - "xls", - "xlsx", - "yaml" - ], "hashes": [ - "sha256:9821caa9eca6062ff7299fa645e737aecff982e6b2b42046928a6413c8dabfd9", - "sha256:f6661dfc45e1d4f51fa8a6239f9c8349380859a5bfaa73280645f046d6c96e33" + "sha256:9a6930037cfe0f782377963ca3f2b1dae3fd4cdbf0883848f22f1447e7bb718b", + "sha256:f9db84ed398df5109bd69c11d46613d16cc572fb9ad3213f10d95e2b5f12c18e" ], - "markers": "python_version >= '3.8'", - "version": "==3.5.0" + "markers": "python_version >= '3.9'", + "version": "==3.7.0" }, "tblib": { "hashes": [ @@ -1222,20 +1208,20 @@ }, "urllib3": { "hashes": [ - "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", - "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9" + "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", + "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d" ], - "markers": "python_version >= '3.8'", - "version": "==2.2.3" + "markers": "python_version >= '3.9'", + "version": "==2.3.0" }, "whitenoise": { "hashes": [ - "sha256:58c7a6cd811e275a6c91af22e96e87da0b1109e9a53bb7464116ef4c963bf636", - "sha256:a1ae85e01fdc9815d12fa33f17765bc132ed2c54fa76daf9e39e879dd93566f6" + "sha256:486bd7267a375fa9650b136daaec156ac572971acc8bf99add90817a530dd1d4", + "sha256:df12dce147a043d1956d81d288c6f0044147c6d2ab9726e5772ac50fb45d2280" ], "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==6.7.0" + "markers": "python_version >= '3.9'", + "version": "==6.8.2" }, "zope.event": { "hashes": [ @@ -1247,46 +1233,46 @@ }, "zope.interface": { "hashes": [ - "sha256:07add15de0cc7e69917f7d286b64d54125c950aeb43efed7a5ea7172f000fbc1", - "sha256:0ac20581fc6cd7c754f6dff0ae06fedb060fa0e9ea6309d8be8b2701d9ea51c4", - "sha256:124149e2d42067b9c6597f4dafdc7a0983d0163868f897b7bb5dc850b14f9a87", - "sha256:27cfb5205d68b12682b6e55ab8424662d96e8ead19550aad0796b08dd2c9a45e", - "sha256:2a29ac607e970b5576547f0e3589ec156e04de17af42839eedcf478450687317", - "sha256:2b6a4924f5bad9fe21d99f66a07da60d75696a136162427951ec3cb223a5570d", - "sha256:2bd9e9f366a5df08ebbdc159f8224904c1c5ce63893984abb76954e6fbe4381a", - "sha256:3bcff5c09d0215f42ba64b49205a278e44413d9bf9fa688fd9e42bfe472b5f4f", - "sha256:3f005869a1a05e368965adb2075f97f8ee9a26c61898a9e52a9764d93774f237", - "sha256:4a00ead2e24c76436e1b457a5132d87f83858330f6c923640b7ef82d668525d1", - "sha256:4af4a12b459a273b0b34679a5c3dc5e34c1847c3dd14a628aa0668e19e638ea2", - "sha256:5501e772aff595e3c54266bc1bfc5858e8f38974ce413a8f1044aae0f32a83a3", - "sha256:5e28ea0bc4b084fc93a483877653a033062435317082cdc6388dec3438309faf", - "sha256:5e956b1fd7f3448dd5e00f273072e73e50dfafcb35e4227e6d5af208075593c9", - "sha256:5fcf379b875c610b5a41bc8a891841533f98de0520287d7f85e25386cd10d3e9", - "sha256:6159e767d224d8f18deff634a1d3722e68d27488c357f62ebeb5f3e2f5288b1f", - "sha256:661d5df403cd3c5b8699ac480fa7f58047a3253b029db690efa0c3cf209993ef", - "sha256:711eebc77f2092c6a8b304bad0b81a6ce3cf5490b25574e7309fbc07d881e3af", - "sha256:80a3c00b35f6170be5454b45abe2719ea65919a2f09e8a6e7b1362312a872cd3", - "sha256:848b6fa92d7c8143646e64124ed46818a0049a24ecc517958c520081fd147685", - "sha256:91b6c30689cfd87c8f264acb2fc16ad6b3c72caba2aec1bf189314cf1a84ca33", - "sha256:9733a9a0f94ef53d7aa64661811b20875b5bc6039034c6e42fb9732170130573", - "sha256:9940d5bc441f887c5f375ec62bcf7e7e495a2d5b1da97de1184a88fb567f06af", - "sha256:9e3e48f3dea21c147e1b10c132016cb79af1159facca9736d231694ef5a740a8", - "sha256:a14c9decf0eb61e0892631271d500c1e306c7b6901c998c7035e194d9150fdd1", - "sha256:a735f82d2e3ed47ca01a20dfc4c779b966b16352650a8036ab3955aad151ed8a", - "sha256:a99240b1d02dc469f6afbe7da1bf617645e60290c272968f4e53feec18d7dce8", - "sha256:b7b25db127db3e6b597c5f74af60309c4ad65acd826f89609662f0dc33a54728", - "sha256:b936d61dbe29572fd2cfe13e30b925e5383bed1aba867692670f5a2a2eb7b4e9", - "sha256:bec001798ab62c3fc5447162bf48496ae9fba02edc295a9e10a0b0c639a6452e", - "sha256:cc8a318162123eddbdf22fcc7b751288ce52e4ad096d3766ff1799244352449d", - "sha256:d0a45b5af9f72c805ee668d1479480ca85169312211bed6ed18c343e39307d5f", - "sha256:e53c291debef523b09e1fe3dffe5f35dde164f1c603d77f770b88a1da34b7ed6", - "sha256:ec1ef1fdb6f014d5886b97e52b16d0f852364f447d2ab0f0c6027765777b6667", - "sha256:ec59fe53db7d32abb96c6d4efeed84aab4a7c38c62d7a901a9b20c09dd936e7a", - "sha256:f245d039f72e6f802902375755846f5de1ee1e14c3e8736c078565599bcab621", - "sha256:ff115ef91c0eeac69cd92daeba36a9d8e14daee445b504eeea2b1c0b55821984" + "sha256:033b3923b63474800b04cba480b70f6e6243a62208071fc148354f3f89cc01b7", + "sha256:05b910a5afe03256b58ab2ba6288960a2892dfeef01336dc4be6f1b9ed02ab0a", + "sha256:086ee2f51eaef1e4a52bd7d3111a0404081dadae87f84c0ad4ce2649d4f708b7", + "sha256:0ef9e2f865721553c6f22a9ff97da0f0216c074bd02b25cf0d3af60ea4d6931d", + "sha256:1090c60116b3da3bfdd0c03406e2f14a1ff53e5771aebe33fec1edc0a350175d", + "sha256:144964649eba4c5e4410bb0ee290d338e78f179cdbfd15813de1a664e7649b3b", + "sha256:15398c000c094b8855d7d74f4fdc9e73aa02d4d0d5c775acdef98cdb1119768d", + "sha256:1909f52a00c8c3dcab6c4fad5d13de2285a4b3c7be063b239b8dc15ddfb73bd2", + "sha256:21328fcc9d5b80768bf051faa35ab98fb979080c18e6f84ab3f27ce703bce465", + "sha256:224b7b0314f919e751f2bca17d15aad00ddbb1eadf1cb0190fa8175edb7ede62", + "sha256:25e6a61dcb184453bb00eafa733169ab6d903e46f5c2ace4ad275386f9ab327a", + "sha256:27f926f0dcb058211a3bb3e0e501c69759613b17a553788b2caeb991bed3b61d", + "sha256:29caad142a2355ce7cfea48725aa8bcf0067e2b5cc63fcf5cd9f97ad12d6afb5", + "sha256:2ad9913fd858274db8dd867012ebe544ef18d218f6f7d1e3c3e6d98000f14b75", + "sha256:31d06db13a30303c08d61d5fb32154be51dfcbdb8438d2374ae27b4e069aac40", + "sha256:3e0350b51e88658d5ad126c6a57502b19d5f559f6cb0a628e3dc90442b53dd98", + "sha256:3f6771d1647b1fc543d37640b45c06b34832a943c80d1db214a37c31161a93f1", + "sha256:4893395d5dd2ba655c38ceb13014fd65667740f09fa5bb01caa1e6284e48c0cd", + "sha256:52e446f9955195440e787596dccd1411f543743c359eeb26e9b2c02b077b0519", + "sha256:550f1c6588ecc368c9ce13c44a49b8d6b6f3ca7588873c679bd8fd88a1b557b6", + "sha256:72cd1790b48c16db85d51fbbd12d20949d7339ad84fd971427cf00d990c1f137", + "sha256:7bd449c306ba006c65799ea7912adbbfed071089461a19091a228998b82b1fdb", + "sha256:7dc5016e0133c1a1ec212fc87a4f7e7e562054549a99c73c8896fa3a9e80cbc7", + "sha256:802176a9f99bd8cc276dcd3b8512808716492f6f557c11196d42e26c01a69a4c", + "sha256:80ecf2451596f19fd607bb09953f426588fc1e79e93f5968ecf3367550396b22", + "sha256:8b49f1a3d1ee4cdaf5b32d2e738362c7f5e40ac8b46dd7d1a65e82a4872728fe", + "sha256:8e7da17f53e25d1a3bde5da4601e026adc9e8071f9f6f936d0fe3fe84ace6d54", + "sha256:a102424e28c6b47c67923a1f337ede4a4c2bba3965b01cf707978a801fc7442c", + "sha256:a19a6cc9c6ce4b1e7e3d319a473cf0ee989cbbe2b39201d7c19e214d2dfb80c7", + "sha256:a71a5b541078d0ebe373a81a3b7e71432c61d12e660f1d67896ca62d9628045b", + "sha256:baf95683cde5bc7d0e12d8e7588a3eb754d7c4fa714548adcd96bdf90169f021", + "sha256:cab15ff4832580aa440dc9790b8a6128abd0b88b7ee4dd56abacbc52f212209d", + "sha256:ce290e62229964715f1011c3dbeab7a4a1e4971fd6f31324c4519464473ef9f2", + "sha256:d3a8ffec2a50d8ec470143ea3d15c0c52d73df882eef92de7537e8ce13475e8a", + "sha256:e204937f67b28d2dca73ca936d3039a144a081fc47a07598d44854ea2a106239", + "sha256:eb23f58a446a7f09db85eda09521a498e109f137b85fb278edb2e34841055398", + "sha256:f6dd02ec01f4468da0f234da9d9c8545c5412fef80bc590cc51d8dd084138a89" ], "markers": "python_version >= '3.8'", - "version": "==7.1.0" + "version": "==7.2" } }, "develop": { @@ -1300,12 +1286,12 @@ }, "bandit": { "hashes": [ - "sha256:59ed5caf5d92b6ada4bf65bc6437feea4a9da1093384445fed4d472acc6cff7b", - "sha256:665721d7bebbb4485a339c55161ac0eedde27d51e638000d91c8c2d68343ad02" + "sha256:b1a61d829c0968aed625381e426aa378904b996529d048f8d908fa28f6b13e38", + "sha256:b5bfe55a095abd9fe20099178a7c6c060f844bfd4fe4c76d28e35e4c52b9d31e" ], "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==1.7.10" + "markers": "python_version >= '3.9'", + "version": "==1.8.0" }, "beautifulsoup4": { "hashes": [ @@ -1346,20 +1332,20 @@ }, "blinker": { "hashes": [ - "sha256:1779309f71bf239144b9399d06ae925637cf6634cf6bd131104184531bf67c01", - "sha256:8f77b09d3bf7c795e969e9486f39c2c5e9c39d4ee07424be2bc594ece9642d83" + "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", + "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc" ], - "markers": "python_version >= '3.8'", - "version": "==1.8.2" + "markers": "python_version >= '3.9'", + "version": "==1.9.0" }, "boto3": { "hashes": [ - "sha256:2bf7e7f376aee52155fc4ae4487f29333a6bcdf3a05c3bc4fede10b972d951a6", - "sha256:e74bc6d69c04ca611b7f58afe08e2ded6cb6504a4a80557b656abeefee395f88" + "sha256:ba391982f6cada136c5bba99e85d7fe1bc4e157c53a22a78e4aca35d1b39152e", + "sha256:eecef248f8743ab30036cd9c916808a0892fc9036e1a35434d8222060c08bbd2" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.35.41" + "version": "==1.35.91" }, "boto3-mocking": { "hashes": [ @@ -1372,45 +1358,45 @@ }, "boto3-stubs": { "hashes": [ - "sha256:5884048edf0581479ecc3726c0b4b6d83640b5590d4646cbd229bae8f5a5666b", - "sha256:724c5999390eed5ed84832dcd003d1dcd1b12c941e50f6a6f63378c407d8fa0a" + "sha256:780f71406147b78f9860d78907b5c015874537d821364588ec837c4cd1eecf91", + "sha256:e4301b9d05b31fbfea382d0d1d950c2178f7fca03058b31373fac9a4cdf89438" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.35.41" + "version": "==1.35.91" }, "botocore": { "hashes": [ - "sha256:8a09a32136df8768190a6c92f0240cd59c30deb99c89026563efadbbed41fa00", - "sha256:915c4d81e3a0be3b793c1e2efdf19af1d0a9cd4a2d8de08ee18216c14d67764b" + "sha256:7b0b9c5954701fff4d2c516918f45641b04ff4ca92bbd9f5b37c0b80f8c14220", + "sha256:93de9d0f52f7e36a2c190d55520d3b2654f32c5a628fdd484bffa00bc7865e1d" ], "markers": "python_version >= '3.8'", - "version": "==1.35.41" + "version": "==1.35.91" }, "botocore-stubs": { "hashes": [ - "sha256:62e369aed694471eaf72305cd2f33c356337d49637a5fcc17fc2ef237e8f517f", - "sha256:99e8f0e20266b2abc0e095ef19e8e628a926c25c4a0edbfd25978f484677bac6" + "sha256:c6b294cae436eaaf87dcb717e4348c250ea1fc170336579da114b693663d8e42", + "sha256:f7fd78d84f49d28692662b9bdeb4c92f1bf8a5707d0c28c8544399005b02823b" ], "markers": "python_version >= '3.8'", - "version": "==1.35.41" + "version": "==1.35.90" }, "click": { "hashes": [ - "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", - "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" + "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", + "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a" ], "markers": "python_version >= '3.7'", - "version": "==8.1.7" + "version": "==8.1.8" }, "django": { "hashes": [ - "sha256:a2d4c4d4ea0b6f0895acde632071aff6400bfc331228fc978b05452a0ff3e9f1", - "sha256:b1260ed381b10a11753c73444408e19869f3241fc45c985cd55a30177c789d13" + "sha256:3a93350214ba25f178d4045c0786c61573e7dbfa3c509b3551374f1e11ba8de0", + "sha256:6b56d834cc94c8b21a8f4e775064896be3b4a4ca387f2612d4406a5927cd2fdc" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==4.2.10" + "version": "==4.2.17" }, "django-debug-toolbar": { "hashes": [ @@ -1423,27 +1409,27 @@ }, "django-model2puml": { "hashes": [ - "sha256:f7ef57efbf261e8e0f90043c2be379e9457b30603ccc01fe7a01c233d0dfa27c" + "sha256:c823366d5ddc7cc52d855b62ce3b2b0acaa54dcaa0f372b9c5f2679d9a341f54" ], "index": "pypi", - "version": "==0.5.1" + "version": "==0.6.0" }, "django-stubs": { "hashes": [ - "sha256:86128c228b65e6c9a85e5dc56eb1c6f41125917dae0e21e6cfecdf1b27e630c5", - "sha256:b98d49a80aa4adf1433a97407102d068de26c739c405431d93faad96dd282c40" + "sha256:126d354bbdff4906c4e93e6361197f6fbfb6231c3df6def85a291dae6f9f577b", + "sha256:c4dc64260bd72e6d32b9e536e8dd0d9247922f0271f82d1d5132a18f24b388ac" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==5.1.0" + "version": "==5.1.1" }, "django-stubs-ext": { "hashes": [ - "sha256:a455fc222c90b30b29ad8c53319559f5b54a99b4197205ddbb385aede03b395d", - "sha256:ed7d51c0b731651879fc75f331fb0806d98b67bfab464e96e2724db6b46ef926" + "sha256:3907f99e178c93323e2ce908aef8352adb8c047605161f8d9e5e7b4efb5a6a9c", + "sha256:db7364e4f50ae7e5360993dbd58a3a57ea4b2e7e5bab0fbd525ccdb3e7975d1c" ], "markers": "python_version >= '3.8'", - "version": "==5.1.0" + "version": "==5.1.1" }, "django-webtest": { "hashes": [ @@ -1496,42 +1482,48 @@ }, "mypy": { "hashes": [ - "sha256:060a07b10e999ac9e7fa249ce2bdcfa9183ca2b70756f3bce9df7a92f78a3c0a", - "sha256:06de0498798527451ffb60f68db0d368bd2bae2bbfb5237eae616d4330cc87aa", - "sha256:0eff042d7257f39ba4ca06641d110ca7d2ad98c9c1fb52200fe6b1c865d360ff", - "sha256:1ebf9e796521f99d61864ed89d1fb2926d9ab6a5fab421e457cd9c7e4dd65aa9", - "sha256:20c7c5ce0c1be0b0aea628374e6cf68b420bcc772d85c3c974f675b88e3e6e57", - "sha256:233e11b3f73ee1f10efada2e6da0f555b2f3a5316e9d8a4a1224acc10e7181d3", - "sha256:2c40658d4fa1ab27cb53d9e2f1066345596af2f8fe4827defc398a09c7c9519b", - "sha256:2f106db5ccb60681b622ac768455743ee0e6a857724d648c9629a9bd2ac3f721", - "sha256:4397081e620dc4dc18e2f124d5e1d2c288194c2c08df6bdb1db31c38cd1fe1ed", - "sha256:48d3e37dd7d9403e38fa86c46191de72705166d40b8c9f91a3de77350daa0893", - "sha256:4ae8959c21abcf9d73aa6c74a313c45c0b5a188752bf37dace564e29f06e9c1b", - "sha256:4b86de37a0da945f6d48cf110d5206c5ed514b1ca2614d7ad652d4bf099c7de7", - "sha256:52b9e1492e47e1790360a43755fa04101a7ac72287b1a53ce817f35899ba0521", - "sha256:5bc81701d52cc8767005fdd2a08c19980de9ec61a25dbd2a937dfb1338a826f9", - "sha256:5feee5c74eb9749e91b77f60b30771563327329e29218d95bedbe1257e2fe4b0", - "sha256:65a22d87e757ccd95cbbf6f7e181e6caa87128255eb2b6be901bb71b26d8a99d", - "sha256:684a9c508a283f324804fea3f0effeb7858eb03f85c4402a967d187f64562469", - "sha256:6b5df6c8a8224f6b86746bda716bbe4dbe0ce89fd67b1fa4661e11bfe38e8ec8", - "sha256:6cabe4cda2fa5eca7ac94854c6c37039324baaa428ecbf4de4567279e9810f9e", - "sha256:77278e8c6ffe2abfba6db4125de55f1024de9a323be13d20e4f73b8ed3402bd1", - "sha256:8462655b6694feb1c99e433ea905d46c478041a8b8f0c33f1dab00ae881b2164", - "sha256:923ea66d282d8af9e0f9c21ffc6653643abb95b658c3a8a32dca1eff09c06475", - "sha256:9b9ce1ad8daeb049c0b55fdb753d7414260bad8952645367e70ac91aec90e07e", - "sha256:a64ee25f05fc2d3d8474985c58042b6759100a475f8237da1f4faf7fcd7e6309", - "sha256:bfe012b50e1491d439172c43ccb50db66d23fab714d500b57ed52526a1020bb7", - "sha256:c72861b7139a4f738344faa0e150834467521a3fba42dc98264e5aa9507dd601", - "sha256:dcfb754dea911039ac12434d1950d69a2f05acd4d56f7935ed402be09fad145e", - "sha256:dee78a8b9746c30c1e617ccb1307b351ded57f0de0d287ca6276378d770006c0", - "sha256:e478601cc3e3fa9d6734d255a59c7a2e5c2934da4378f3dd1e3411ea8a248642", - "sha256:eafc1b7319b40ddabdc3db8d7d48e76cfc65bbeeafaa525a4e0fa6b76175467f", - "sha256:faca7ab947c9f457a08dcb8d9a8664fd438080e002b0fa3e41b0535335edcf7f", - "sha256:fd313226af375d52e1e36c383f39bf3836e1f192801116b31b090dfcd3ec5266" + "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" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.12.0" + "version": "==1.14.1" }, "mypy-extensions": { "hashes": [ @@ -1551,11 +1543,11 @@ }, "packaging": { "hashes": [ - "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", - "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124" + "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", + "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f" ], "markers": "python_version >= '3.8'", - "version": "==24.1" + "version": "==24.2" }, "pathspec": { "hashes": [ @@ -1610,7 +1602,7 @@ "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", "version": "==2.9.0.post0" }, "pyyaml": { @@ -1674,27 +1666,27 @@ }, "rich": { "hashes": [ - "sha256:51a2c62057461aaf7152b4d611168f93a9fc73068f8ded2790f29fe2b5366d0c", - "sha256:8c82a3d3f8dcfe9e734771313e606b39d8247bb6b826e196f4914b333b743cf1" + "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", + "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90" ], "markers": "python_full_version >= '3.8.0'", - "version": "==13.9.2" + "version": "==13.9.4" }, "s3transfer": { "hashes": [ - "sha256:263ed587a5803c6c708d3ce44dc4dfedaab4c1a32e8329bab818933d79ddcf5d", - "sha256:4f50ed74ab84d474ce614475e0b8d5047ff080810aac5d01ea25231cfc944b0c" + "sha256:244a76a24355363a68164241438de1b72f8781664920260c48465896b712a41e", + "sha256:29edc09801743c21eb5ecbc617a152df41d3c287f67b615f73e5f750583666a7" ], "markers": "python_version >= '3.8'", - "version": "==0.10.3" + "version": "==0.10.4" }, "six": { "hashes": [ - "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", - "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" + "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", + "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.16.0" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "version": "==1.17.0" }, "soupsieve": { "hashes": [ @@ -1706,35 +1698,65 @@ }, "sqlparse": { "hashes": [ - "sha256:773dcbf9a5ab44a090f3441e2180efe2560220203dc2f8c0b0fa141e18b505e4", - "sha256:bb6b4df465655ef332548e24f08e205afc81b9ab86cb1c45657a7ff173a3a00e" + "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", + "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca" ], "markers": "python_version >= '3.8'", - "version": "==0.5.1" + "version": "==0.5.3" }, "stevedore": { "hashes": [ - "sha256:1efd34ca08f474dad08d9b19e934a22c68bb6fe416926479ba29e5013bcc8f78", - "sha256:9a64265f4060312828151c204efbe9b7a9852a0d9228756344dbc7e4023e375a" + "sha256:79e92235ecb828fe952b6b8b0c6c87863248631922c8e8e0fa5b17b232c4514d", + "sha256:b0be3c4748b3ea7b854b265dcb4caa891015e442416422be16f8b31756107857" ], - "markers": "python_version >= '3.8'", - "version": "==5.3.0" + "markers": "python_version >= '3.9'", + "version": "==5.4.0" }, "tomli": { "hashes": [ - "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38", - "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed" + "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", + "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", + "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", + "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", + "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", + "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", + "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", + "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", + "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", + "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", + "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", + "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", + "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", + "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", + "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", + "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", + "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", + "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", + "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", + "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", + "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", + "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", + "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", + "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", + "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", + "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", + "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", + "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", + "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", + "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", + "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", + "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7" ], - "markers": "python_version < '3.11'", - "version": "==2.0.2" + "markers": "python_version >= '3.8'", + "version": "==2.2.1" }, "types-awscrt": { "hashes": [ - "sha256:67a660c90bad360c339f6a79310cc17094d12472042c7ca5a41450aaf5fc9a54", - "sha256:b2c196bbd3226bab42d80fae13c34548de9ddc195f5a366d79c15d18e5897aa9" + "sha256:405bce8c281f9e7c6c92a229225cc0bf10d30729a6a601123213389bd524b8b1", + "sha256:fbf9c221af5607b24bf17f8431217ce8b9a27917139edbc984891eb63fd5a593" ], "markers": "python_version >= '3.8'", - "version": "==0.22.0" + "version": "==0.23.6" }, "types-cachetools": { "hashes": [ @@ -1747,28 +1769,28 @@ }, "types-pyyaml": { "hashes": [ - "sha256:392b267f1c0fe6022952462bf5d6523f31e37f6cea49b14cee7ad634b6301570", - "sha256:d1405a86f9576682234ef83bcb4e6fff7c9305c8b1fbad5e0bcd4f7dbdc9c587" + "sha256:7f07622dbd34bb9c8b264fe860a17e0efcad00d50b5f27e93984909d9363498c", + "sha256:fa4d32565219b68e6dee5f67534c722e53c00d1cfc09c435ef04d7353e1e96e6" ], "markers": "python_version >= '3.8'", - "version": "==6.0.12.20240917" + "version": "==6.0.12.20241230" }, "types-requests": { "hashes": [ - "sha256:2850e178db3919d9bf809e434eef65ba49d0e7e33ac92d588f4a5e295fffd405", - "sha256:59c2f673eb55f32a99b2894faf6020e1a9f4a402ad0f192bfee0b64469054310" + "sha256:0d9cad2f27515d0e3e3da7134a1b6f28fb97129d86b867f24d9c726452634d95", + "sha256:4195d62d6d3e043a4eaaf08ff8a62184584d2e8684e9d2aa178c7915a7da3747" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==2.32.0.20240914" + "version": "==2.32.0.20241016" }, "types-s3transfer": { "hashes": [ - "sha256:d34c5a82f531af95bb550927136ff5b737a1ed3087f90a59d545591dfde5b4cc", - "sha256:f761b2876ac4c208e6c6b75cdf5f6939009768be9950c545b11b0225e7703ee7" + "sha256:03123477e3064c81efe712bf9d372c7c72f2790711431f9baa59cf96ea607267", + "sha256:22ac1aabc98f9d7f2928eb3fb4d5c02bf7435687f0913345a97dd3b84d0c217d" ], "markers": "python_version >= '3.8'", - "version": "==0.10.3" + "version": "==0.10.4" }, "typing-extensions": { "hashes": [ @@ -1781,36 +1803,35 @@ }, "urllib3": { "hashes": [ - "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", - "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9" + "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", + "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d" ], - "markers": "python_version >= '3.8'", - "version": "==2.2.3" + "markers": "python_version >= '3.9'", + "version": "==2.3.0" }, "waitress": { "hashes": [ - "sha256:26cdbc593093a15119351690752c99adc13cbc6786d75f7b6341d1234a3730ac", - "sha256:ef0c1f020d9f12a515c4ec65c07920a702613afcad1dbfdc3bcec256b6c072b3" + "sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f", + "sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e" ], - "index": "pypi", "markers": "python_full_version >= '3.9.0'", - "version": "==3.0.1" + "version": "==3.0.2" }, "webob": { "hashes": [ - "sha256:2abc1555e118fc251e705fc6dc66c7f5353bb9fbfab6d20e22f1c02b4b71bcee", - "sha256:b60ba63f05c0cf61e086a10c3781a41fcfe30027753a8ae6d819c77592ce83ea" + "sha256:45e34c58ed0c7e2ecd238ffd34432487ff13d9ad459ddfd77895e67abba7c1f9", + "sha256:ad6078e2edb6766d1334ec3dee072ac6a7f95b1e32ce10def8ff7f0f02d56589" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.8.8" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "version": "==1.8.9" }, "webtest": { "hashes": [ - "sha256:493b5c802f8948a65b5e3a1ad5b2524ee5e1ab60cd713d9a3da3b8da082c06fe", - "sha256:b3bc75d020d0576ee93a5f149666045e58fe2400ea5f0c214d7430d7d213d0d0" + "sha256:0b2de681c16f57b31da5cce6e94ff03cdc77bd86c37a57ba0ee27fed8e065ceb", + "sha256:799846e169d15e0c1233ab4ab00ee4de59a5d964407d6f2945d89249328dbbdb" ], "markers": "python_version >= '3.7'", - "version": "==3.0.1" + "version": "==3.0.2" } } } diff --git a/src/package-lock.json b/src/package-lock.json index 22fb31857..d78b5132f 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -6921,16 +6921,6 @@ "validate-npm-package-license": "^3.0.1" } }, - "node_modules/normalize-package-data/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver" - } - }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -7307,39 +7297,6 @@ "node": ">= 12" } }, - "node_modules/pa11y/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/pa11y/node_modules/semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", - "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/pa11y/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, "node_modules/parse-filepath": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz", @@ -8888,13 +8845,15 @@ } }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "license": "ISC", "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/semver-greatest-satisfied-range": { diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 52e214bb9..fba675bf7 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -21,10 +21,14 @@ from registrar.utility.admin_helpers import ( get_field_links_as_list, ) from django.conf import settings +from django.contrib.messages import get_messages +from django.contrib.admin.helpers import AdminForm from django.shortcuts import redirect 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 import EmailSendingError +from registrar.utility.email_invitations import send_portfolio_invitation_email from waffle.decorators import flag_is_active from django.contrib import admin, messages from django.contrib.auth.admin import UserAdmin as BaseUserAdmin @@ -37,7 +41,7 @@ from waffle.admin import FlagAdmin from waffle.models import Sample, Switch from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial from registrar.utility.constants import BranchChoices -from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes +from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes, MissingEmailError from registrar.utility.waffle import flag_is_active_for_user from registrar.views.utility.mixins import OrderableFieldsMixin from django.contrib.admin.views.main import ORDER_VAR @@ -1312,6 +1316,8 @@ class UserPortfolioPermissionAdmin(ListHeaderAdmin): search_fields = ["user__first_name", "user__last_name", "user__email", "portfolio__organization_name"] search_help_text = "Search by first name, last name, email, or portfolio." + change_form_template = "django/admin/user_portfolio_permission_change_form.html" + def get_roles(self, obj): readable_roles = obj.get_readable_roles() return ", ".join(readable_roles) @@ -1468,7 +1474,7 @@ class PortfolioInvitationAdmin(ListHeaderAdmin): autocomplete_fields = ["portfolio"] - change_form_template = "django/admin/email_clipboard_change_form.html" + change_form_template = "django/admin/portfolio_invitation_change_form.html" # Select portfolio invitations to change -> Portfolio invitations def changelist_view(self, request, extra_context=None): @@ -1478,6 +1484,118 @@ class PortfolioInvitationAdmin(ListHeaderAdmin): # Get the filtered values return super().changelist_view(request, extra_context=extra_context) + def save_model(self, request, obj, form, change): + """ + Override the save_model method. + + Only send email on creation of the PortfolioInvitation object. Not on updates. + Emails sent to requested user / email. + When exceptions are raised, return without saving model. + """ + if not change: # Only send email if this is a new PortfolioInvitation (creation) + portfolio = obj.portfolio + requested_email = obj.email + requestor = request.user + + permission_exists = UserPortfolioPermission.objects.filter( + user__email=requested_email, portfolio=portfolio, user__email__isnull=False + ).exists() + try: + if not permission_exists: + # if permission does not exist for a user with requested_email, send email + send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=portfolio) + messages.success(request, f"{requested_email} has been invited.") + else: + messages.warning(request, "User is already a member of this portfolio.") + except Exception as e: + # when exception is raised, handle and do not save the model + self._handle_exceptions(e, request, obj) + return + # Call the parent save method to save the object + super().save_model(request, obj, form, change) + + def _handle_exceptions(self, exception, request, obj): + """Handle exceptions raised during the process. + + Log warnings / errors, and message errors to the user. + """ + if isinstance(exception, EmailSendingError): + logger.warning( + "Could not sent email invitation to %s for portfolio %s (EmailSendingError)", + obj.email, + obj.portfolio, + exc_info=True, + ) + messages.error(request, "Could not send email invitation. Portfolio invitation not saved.") + elif isinstance(exception, MissingEmailError): + messages.error(request, str(exception)) + logger.error( + f"Can't send email to '{obj.email}' for portfolio '{obj.portfolio}'. " + f"No email exists for the requestor.", + exc_info=True, + ) + + else: + logger.warning("Could not send email invitation (Other Exception)", exc_info=True) + messages.error(request, "Could not send email invitation. Portfolio invitation not saved.") + + def response_add(self, request, obj, post_url_continue=None): + """ + Override response_add to handle rendering when exceptions are raised during add model. + + Normal flow on successful save_model on add is to redirect to changelist_view. + If there are errors, flow is modified to instead render change form. + """ + # Check if there are any error or warning messages in the `messages` framework + storage = get_messages(request) + has_errors = any(message.level_tag in ["error", "warning"] for message in storage) + + if has_errors: + # Re-render the change form if there are errors or warnings + # Prepare context for rendering the change form + + # Get the model form + ModelForm = self.get_form(request, obj=obj) + form = ModelForm(instance=obj) + + # Create an AdminForm instance + admin_form = AdminForm( + form, + list(self.get_fieldsets(request, obj)), + self.get_prepopulated_fields(request, obj), + self.get_readonly_fields(request, obj), + model_admin=self, + ) + media = self.media + form.media + + opts = obj._meta + change_form_context = { + **self.admin_site.each_context(request), # Add admin context + "title": f"Add {opts.verbose_name}", + "opts": opts, + "original": obj, + "save_as": self.save_as, + "has_change_permission": self.has_change_permission(request, obj), + "add": True, # Indicate this is an "Add" form + "change": False, # Indicate this is not a "Change" form + "is_popup": False, + "inline_admin_formsets": [], + "save_on_top": self.save_on_top, + "show_delete": self.has_delete_permission(request, obj), + "obj": obj, + "adminform": admin_form, # Pass the AdminForm instance + "media": media, + "errors": None, + } + return self.render_change_form( + request, + context=change_form_context, + add=True, + change=False, + obj=obj, + ) + return super().response_add(request, obj, post_url_continue) + class DomainInformationResource(resources.ModelResource): """defines how each field in the referenced model should be mapped to the corresponding fields in the @@ -2618,8 +2736,30 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): return response def change_view(self, request, object_id, form_url="", extra_context=None): - """Display restricted warning, - Setup the auditlog trail and pass it in extra context.""" + """Display restricted warning, setup the auditlog trail and pass it in extra context, + display warning that status cannot be changed from 'Approved' if domain is in Ready state""" + + # Fetch the domain request instance + domain_request: models.DomainRequest = models.DomainRequest.objects.get(pk=object_id) + if domain_request.approved_domain and domain_request.approved_domain.state == models.Domain.State.READY: + domain = domain_request.approved_domain + # get change url for domain + app_label = domain_request.approved_domain._meta.app_label + model_name = domain._meta.model_name + obj_id = domain.id + change_url = reverse("admin:%s_%s_change" % (app_label, model_name), args=[obj_id]) + + message = format_html( + "The status of this domain request cannot be changed because it has been joined to a domain in Ready status: " # noqa: E501 + "{}", + mark_safe(change_url), # nosec + escape(str(domain)), + ) + messages.warning( + request, + message, + ) + obj = self.get_object(request, object_id) self.display_restricted_warning(request, obj) diff --git a/src/registrar/assets/src/js/getgov-admin/domain-request-form.js b/src/registrar/assets/src/js/getgov-admin/domain-request-form.js index a815a59a1..b3d14839e 100644 --- a/src/registrar/assets/src/js/getgov-admin/domain-request-form.js +++ b/src/registrar/assets/src/js/getgov-admin/domain-request-form.js @@ -629,6 +629,51 @@ export function initRejectedEmail() { }); } + +/** + * A function that handles the suborganzation and requested suborganization fields and buttons. + * - Fieldwise: Hooks to the sub_organization, suborganization_city, and suborganization_state_territory fields. + * On change, this function checks if any of these fields are not empty: + * sub_organization, suborganization_city, and suborganization_state_territory. + * If they aren't, then we show the "clear" button. If they are, then we hide it because we don't need it. + * + * - Buttonwise: Hooks to the #clear-requested-suborganization button. + * On click, this will clear the input value of sub_organization, suborganization_city, and suborganization_state_territory. +*/ +function handleSuborgFieldsAndButtons() { + const requestedSuborganizationField = document.getElementById("id_requested_suborganization"); + const suborganizationCity = document.getElementById("id_suborganization_city"); + const suborganizationStateTerritory = document.getElementById("id_suborganization_state_territory"); + const rejectButton = document.querySelector("#clear-requested-suborganization"); + + // Ensure that every variable is present before proceeding + if (!requestedSuborganizationField || !suborganizationCity || !suborganizationStateTerritory || !rejectButton) { + console.warn("handleSuborganizationSelection() => Could not find required fields.") + return; + } + + function handleRejectButtonVisibility() { + if (requestedSuborganizationField.value || suborganizationCity.value || suborganizationStateTerritory.value) { + showElement(rejectButton); + }else { + hideElement(rejectButton) + } + } + + function handleRejectButton() { + // Clear the text fields + requestedSuborganizationField.value = ""; + suborganizationCity.value = ""; + suborganizationStateTerritory.value = ""; + // Update button visibility after clearing + handleRejectButtonVisibility(); + } + rejectButton.addEventListener("click", handleRejectButton) + requestedSuborganizationField.addEventListener("blur", handleRejectButtonVisibility); + suborganizationCity.addEventListener("blur", handleRejectButtonVisibility); + suborganizationStateTerritory.addEventListener("change", handleRejectButtonVisibility); +} + /** * A function for dynamic DomainRequest fields */ @@ -636,5 +681,6 @@ export function initDynamicDomainRequestFields(){ const domainRequestPage = document.getElementById("domainrequest_form"); if (domainRequestPage) { handlePortfolioSelection(); + handleSuborgFieldsAndButtons(); } } diff --git a/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js b/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js index 0e5946c23..9a60e1684 100644 --- a/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js +++ b/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js @@ -49,6 +49,13 @@ export function handlePortfolioSelection( const portfolioUrbanizationField = document.querySelector(".field-portfolio_urbanization"); const portfolioUrbanization = portfolioUrbanizationField.querySelector(".readonly"); const portfolioJsonUrl = document.getElementById("portfolio_json_url")?.value || null; + // These requested suborganization fields only exist on the domain request page + const rejectSuborganizationButton = document.querySelector("#clear-requested-suborganization"); + const requestedSuborganizationFieldInput = document.getElementById("id_requested_suborganization"); + const suborganizationCityInput = document.getElementById("id_suborganization_city"); + const suborganizationStateTerritoryInput = document.getElementById("id_suborganization_state_territory"); + + // Global var to track page load let isPageLoading = true; /** @@ -469,11 +476,28 @@ export function handlePortfolioSelection( if (requestedSuborganizationField) showElement(requestedSuborganizationField); if (suborganizationCity) showElement(suborganizationCity); if (suborganizationStateTerritory) showElement(suborganizationStateTerritory); + + // == LOGIC FOR THE DOMAIN REQUEST PAGE == // + // Handle rejectSuborganizationButton (display of the clear requested suborg button). + // Basically, this button should only be visible when we have data for suborg, city, and state_territory. + // The function handleSuborgFieldsAndButtons() in domain-request-form.js handles doing this same logic + // but on field input for city, state_territory, and the suborg field. + // If it doesn't exist, don't do anything. + if (rejectSuborganizationButton){ + if (requestedSuborganizationFieldInput?.value || suborganizationCityInput?.value || suborganizationStateTerritoryInput?.value) { + showElement(rejectSuborganizationButton); + }else { + hideElement(rejectSuborganizationButton); + } + } } else { // Hide suborganization request fields if suborganization is selected if (requestedSuborganizationField) hideElement(requestedSuborganizationField); if (suborganizationCity) hideElement(suborganizationCity); - if (suborganizationStateTerritory) hideElement(suborganizationStateTerritory); + if (suborganizationStateTerritory) hideElement(suborganizationStateTerritory); + + // == LOGIC FOR THE DOMAIN REQUEST PAGE == // + if (rejectSuborganizationButton) hideElement(rejectSuborganizationButton); } } diff --git a/src/registrar/assets/src/js/getgov/helpers.js b/src/registrar/assets/src/js/getgov/helpers.js index 93c25ee45..7d1449bac 100644 --- a/src/registrar/assets/src/js/getgov/helpers.js +++ b/src/registrar/assets/src/js/getgov/helpers.js @@ -1,9 +1,17 @@ export function hideElement(element) { - element.classList.add('display-none'); + if (element) { + element.classList.add('display-none'); + } else { + throw new Error('hideElement expected a passed DOM element as an argument, but none was provided.'); + } }; export function showElement(element) { - element.classList.remove('display-none'); + if (element) { + element.classList.remove('display-none'); + } else { + throw new Error('showElement expected a passed DOM element as an argument, but none was provided.'); + } }; /** diff --git a/src/registrar/assets/src/js/getgov/portfolio-member-page.js b/src/registrar/assets/src/js/getgov/portfolio-member-page.js index 02d927438..cfb83badc 100644 --- a/src/registrar/assets/src/js/getgov/portfolio-member-page.js +++ b/src/registrar/assets/src/js/getgov/portfolio-member-page.js @@ -150,14 +150,14 @@ export function initAddNewMemberPageListeners() { document.getElementById('modalEmail').textContent = emailValue; // Get selected radio button for access level - let selectedAccess = document.querySelector('input[name="member_access_level"]:checked'); + let selectedAccess = document.querySelector('input[name="role"]:checked'); // Set the selected permission text to 'Basic' or 'Admin' (the value of the selected radio button) // This value does not have the first letter capitalized so let's capitalize it let accessText = selectedAccess ? capitalizeFirstLetter(selectedAccess.value) : "No access level selected"; document.getElementById('modalAccessLevel').textContent = accessText; // Populate permission details based on access level - if (selectedAccess && selectedAccess.value === 'admin') { + if (selectedAccess && selectedAccess.value === 'organization_admin') { populatePermissionDetails('new-member-admin-permissions'); } else { populatePermissionDetails('new-member-basic-permissions'); @@ -187,10 +187,10 @@ export function initPortfolioMemberPageRadio() { ); }else if (newMemberForm){ hookupRadioTogglerListener( - 'member_access_level', + 'role', { - 'admin': 'new-member-admin-permissions', - 'basic': 'new-member-basic-permissions' + 'organization_admin': 'new-member-admin-permissions', + 'organization_member': 'new-member-basic-permissions' } ); } diff --git a/src/registrar/assets/src/js/getgov/requesting-entity.js b/src/registrar/assets/src/js/getgov/requesting-entity.js index 4e7cf8276..3bcdcd35c 100644 --- a/src/registrar/assets/src/js/getgov/requesting-entity.js +++ b/src/registrar/assets/src/js/getgov/requesting-entity.js @@ -13,6 +13,7 @@ export function handleRequestingEntityFieldset() { const selectParent = select?.parentElement; const suborgContainer = document.getElementById("suborganization-container"); const suborgDetailsContainer = document.getElementById("suborganization-container__details"); + const suborgAddtlInstruction = document.getElementById("suborganization-addtl-instruction"); const subOrgCreateNewOption = document.getElementById("option-to-add-suborg")?.value; // Make sure all crucial page elements exist before proceeding. // This more or less ensures that we are on the Requesting Entity page, and not elsewhere. @@ -26,7 +27,13 @@ export function handleRequestingEntityFieldset() { function toggleSuborganization(radio=null) { if (radio != null) requestingSuborganization = radio?.checked && radio.value === "True"; requestingSuborganization ? showElement(suborgContainer) : hideElement(suborgContainer); - requestingNewSuborganization.value = requestingSuborganization && select.value === "other" ? "True" : "False"; + if (select.options.length == 2) { // --Select-- and other are the only options + hideElement(selectParent); // Hide the select drop down and indicate requesting new suborg + hideElement(suborgAddtlInstruction); // Hide additional instruction related to the list + requestingNewSuborganization.value = "True"; + } else { + requestingNewSuborganization.value = requestingSuborganization && select.value === "other" ? "True" : "False"; + } requestingNewSuborganization.value === "True" ? showElement(suborgDetailsContainer) : hideElement(suborgDetailsContainer); } diff --git a/src/registrar/assets/src/js/getgov/table-base.js b/src/registrar/assets/src/js/getgov/table-base.js index 97c256734..e1d5c11ce 100644 --- a/src/registrar/assets/src/js/getgov/table-base.js +++ b/src/registrar/assets/src/js/getgov/table-base.js @@ -143,7 +143,7 @@ export class BaseTable { this.statusCheckboxes = document.querySelectorAll(`.${this.sectionSelector} input[name="filter-status"]`); this.statusIndicator = document.getElementById(`${this.sectionSelector}__filter-indicator`); this.statusToggle = document.getElementById(`${this.sectionSelector}__usa-button--filter`); - this.noTableWrapper = document.getElementById(`${this.sectionSelector}__no-data`); + this.noDataTableWrapper = document.getElementById(`${this.sectionSelector}__no-data`); this.noSearchResultsWrapper = document.getElementById(`${this.sectionSelector}__no-search-results`); this.portfolioElement = document.getElementById('portfolio-js-value'); this.portfolioValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-portfolio') : null; @@ -451,7 +451,7 @@ export class BaseTable { } // handle the display of proper messaging in the event that no members exist in the list or search returns no results - this.updateDisplay(data, this.tableWrapper, this.noTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm); + this.updateDisplay(data, this.tableWrapper, this.noDataTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm); // identify the DOM element where the list of results will be inserted into the DOM const tbody = this.tableWrapper.querySelector('tbody'); tbody.innerHTML = ''; diff --git a/src/registrar/assets/src/js/getgov/table-edit-member-domains.js b/src/registrar/assets/src/js/getgov/table-edit-member-domains.js index 95492d46f..86aa39c37 100644 --- a/src/registrar/assets/src/js/getgov/table-edit-member-domains.js +++ b/src/registrar/assets/src/js/getgov/table-edit-member-domains.js @@ -1,5 +1,6 @@ import { BaseTable } from './table-base.js'; +import { hideElement, showElement } from './helpers.js'; /** * EditMemberDomainsTable is used for PortfolioMember and PortfolioInvitedMember @@ -18,8 +19,14 @@ export class EditMemberDomainsTable extends BaseTable { this.initialDomainAssignmentsOnlyMember = []; // list of initially assigned domains which are readonly this.addedDomains = []; // list of domains added to member this.removedDomains = []; // list of domains removed from member + this.editModeContainer = document.getElementById('domain-assignments-edit-view'); + this.readonlyModeContainer = document.getElementById('domain-assignments-readonly-view'); + this.reviewButton = document.getElementById('review-domain-assignments'); + this.backButton = document.getElementById('back-to-edit-domain-assignments'); + this.saveButton = document.getElementById('save-domain-assignments'); this.initializeDomainAssignments(); this.initCancelEditDomainAssignmentButton(); + this.initEventListeners(); } getBaseUrl() { return document.getElementById("get_member_domains_json_url"); @@ -55,6 +62,14 @@ export class EditMemberDomainsTable extends BaseTable { getSearchParams(page, sortBy, order, searchTerm, status, portfolio) { let searchParams = super.getSearchParams(page, sortBy, order, searchTerm, status, portfolio); // Add checkedDomains to searchParams + let checkedDomains = this.getCheckedDomains(); + // Append updated checkedDomain IDs to searchParams + if (checkedDomains.length > 0) { + searchParams.append("checkedDomainIds", checkedDomains.join(",")); + } + return searchParams; + } + getCheckedDomains() { // Clone the initial domains to avoid mutating them let checkedDomains = [...this.initialDomainAssignments]; // Add IDs from addedDomains that are not already in checkedDomains @@ -70,11 +85,7 @@ export class EditMemberDomainsTable extends BaseTable { checkedDomains.splice(index, 1); } }); - // Append updated checkedDomain IDs to searchParams - if (checkedDomains.length > 0) { - searchParams.append("checkedDomainIds", checkedDomains.join(",")); - } - return searchParams; + return checkedDomains } addRow(dataObject, tbody, customTableOptions) { const domain = dataObject; @@ -217,7 +228,123 @@ export class EditMemberDomainsTable extends BaseTable { } }); } + + updateReadonlyDisplay() { + let totalAssignedDomains = this.getCheckedDomains().length; + + // Create unassigned domains list + const unassignedDomainsList = document.createElement('ul'); + unassignedDomainsList.classList.add('usa-list', 'usa-list--unstyled'); + this.removedDomains.forEach(removedDomain => { + const removedDomainListItem = document.createElement('li'); + removedDomainListItem.textContent = removedDomain.name; // Use textContent for security + unassignedDomainsList.appendChild(removedDomainListItem); + }); + + // Create assigned domains list + const assignedDomainsList = document.createElement('ul'); + assignedDomainsList.classList.add('usa-list', 'usa-list--unstyled'); + this.addedDomains.forEach(addedDomain => { + const addedDomainListItem = document.createElement('li'); + addedDomainListItem.textContent = addedDomain.name; // Use textContent for security + assignedDomainsList.appendChild(addedDomainListItem); + }); + + // Get the summary container + const domainAssignmentSummary = document.getElementById('domain-assignments-summary'); + // Clear existing content + domainAssignmentSummary.innerHTML = ''; + + // Append unassigned domains section + if (this.removedDomains.length) { + const unassignedHeader = document.createElement('h3'); + unassignedHeader.classList.add('header--body', 'text-primary', 'margin-bottom-1'); + unassignedHeader.textContent = 'Unassigned domains'; + domainAssignmentSummary.appendChild(unassignedHeader); + domainAssignmentSummary.appendChild(unassignedDomainsList); + } + + // Append assigned domains section + if (this.addedDomains.length) { + const assignedHeader = document.createElement('h3'); + assignedHeader.classList.add('header--body', 'text-primary', 'margin-bottom-1'); + assignedHeader.textContent = 'Assigned domains'; + domainAssignmentSummary.appendChild(assignedHeader); + domainAssignmentSummary.appendChild(assignedDomainsList); + } + + // Append total assigned domains section + const totalHeader = document.createElement('h3'); + totalHeader.classList.add('header--body', 'text-primary', 'margin-bottom-1'); + totalHeader.textContent = 'Total assigned domains'; + domainAssignmentSummary.appendChild(totalHeader); + const totalCount = document.createElement('p'); + totalCount.classList.add('margin-y-0'); + totalCount.textContent = totalAssignedDomains; + domainAssignmentSummary.appendChild(totalCount); + } + + showReadonlyMode() { + this.updateReadonlyDisplay(); + hideElement(this.editModeContainer); + showElement(this.readonlyModeContainer); + } + + showEditMode() { + hideElement(this.readonlyModeContainer); + showElement(this.editModeContainer); + } + + submitChanges() { + let memberDomainsEditForm = document.getElementById("member-domains-edit-form"); + if (memberDomainsEditForm) { + // Serialize data to send + const addedDomainIds = this.addedDomains.map(domain => domain.id); + const addedDomainsInput = document.createElement('input'); + addedDomainsInput.type = 'hidden'; + addedDomainsInput.name = 'added_domains'; // Backend will use this key to retrieve data + addedDomainsInput.value = JSON.stringify(addedDomainIds); // Stringify the array + + const removedDomainsIds = this.removedDomains.map(domain => domain.id); + const removedDomainsInput = document.createElement('input'); + removedDomainsInput.type = 'hidden'; + removedDomainsInput.name = 'removed_domains'; // Backend will use this key to retrieve data + removedDomainsInput.value = JSON.stringify(removedDomainsIds); // Stringify the array + + // Append input to the form + memberDomainsEditForm.appendChild(addedDomainsInput); + memberDomainsEditForm.appendChild(removedDomainsInput); + + memberDomainsEditForm.submit(); + } + } + + initEventListeners() { + if (this.reviewButton) { + this.reviewButton.addEventListener('click', () => { + this.showReadonlyMode(); + }); + } else { + console.warn('Missing DOM element. Expected element with id review-domain-assignments'); + } + + if (this.backButton) { + this.backButton.addEventListener('click', () => { + this.showEditMode(); + }); + } else { + console.warn('Missing DOM element. Expected element with id back-to-edit-domain-assignments'); + } + + if (this.saveButton) { + this.saveButton.addEventListener('click', () => { + this.submitChanges(); + }); + } else { + console.warn('Missing DOM element. Expected element with id save-domain-assignments'); + } + } } export function initEditMemberDomainsTable() { diff --git a/src/registrar/assets/src/js/getgov/table-member-domains.js b/src/registrar/assets/src/js/getgov/table-member-domains.js index 54e9d1212..7f89eee52 100644 --- a/src/registrar/assets/src/js/getgov/table-member-domains.js +++ b/src/registrar/assets/src/js/getgov/table-member-domains.js @@ -1,4 +1,5 @@ +import { showElement, hideElement } from './helpers.js'; import { BaseTable } from './table-base.js'; export class MemberDomainsTable extends BaseTable { @@ -24,7 +25,28 @@ export class MemberDomainsTable extends BaseTable { `; tbody.appendChild(row); } - + updateDisplay = (data, dataWrapper, noDataWrapper, noSearchResultsWrapper) => { + const { unfiltered_total, total } = data; + const searchSection = document.getElementById('edit-member-domains__search'); + if (!searchSection) console.warn('MemberDomainsTable updateDisplay expected an element with id edit-member-domains__search but none was found'); + if (unfiltered_total) { + showElement(searchSection); + if (total) { + showElement(dataWrapper); + hideElement(noSearchResultsWrapper); + hideElement(noDataWrapper); + } else { + hideElement(dataWrapper); + showElement(noSearchResultsWrapper); + hideElement(noDataWrapper); + } + } else { + hideElement(searchSection); + hideElement(dataWrapper); + hideElement(noSearchResultsWrapper); + showElement(noDataWrapper); + } + }; } export function initMemberDomainsTable() { diff --git a/src/registrar/assets/src/sass/_theme/_accordions.scss b/src/registrar/assets/src/sass/_theme/_accordions.scss index df4f686d8..762618415 100644 --- a/src/registrar/assets/src/sass/_theme/_accordions.scss +++ b/src/registrar/assets/src/sass/_theme/_accordions.scss @@ -40,7 +40,11 @@ top: 30px; } -tr:last-child .usa-accordion--more-actions .usa-accordion__content { +// Special positioning for the kabob menu popup in the last row on a given page +// This won't work on the Members table rows because that table has show-more rows +// Currently, that's not an issue since that Members table is not wrapped in the +// reponsive wrapper. +tr:last-of-type .usa-accordion--more-actions .usa-accordion__content { top: auto; bottom: -10px; right: 30px; diff --git a/src/registrar/assets/src/sass/_theme/_admin.scss b/src/registrar/assets/src/sass/_theme/_admin.scss index 58ce1e4df..5bb523cac 100644 --- a/src/registrar/assets/src/sass/_theme/_admin.scss +++ b/src/registrar/assets/src/sass/_theme/_admin.scss @@ -176,7 +176,16 @@ html[data-theme="dark"] { color: var(--primary-fg); } +// Reset the USWDS styles for alerts +@include at-media(desktop) { + .dashboard .usa-alert__body--widescreen { + padding-left: 4rem !important; + } + .dashboard .usa-alert__body--widescreen::before { + left: 1.5rem !important; + } +} #branding h1, h1, h2, h3, diff --git a/src/registrar/assets/src/sass/_theme/_alerts.scss b/src/registrar/assets/src/sass/_theme/_alerts.scss index 9579cc057..3164358b7 100644 --- a/src/registrar/assets/src/sass/_theme/_alerts.scss +++ b/src/registrar/assets/src/sass/_theme/_alerts.scss @@ -1,21 +1,18 @@ @use "uswds-core" as *; @use "base" as *; -// Fixes some font size disparities with the Figma -// for usa-alert alert elements -.usa-alert { - .usa-alert__heading.larger-font-sizing { - font-size: units(3); - } -} -.usa-alert__text.measure-none { - max-width: measure(none); -} +/*---------------- + Alert Layout +-----------------*/ // The icon was off center for some reason // Fixes that issue -@media (min-width: 64em){ +@include at-media(desktop) { + // NOTE: !important is used because _font.scss overrides this + .usa-alert__body { + max-width: $widescreen-max-width !important; + } .usa-alert--warning{ .usa-alert__body::before { left: 1rem !important; @@ -24,13 +21,29 @@ .usa-alert__body.margin-left-1 { margin-left: 0.5rem!important; } + + .usa-alert__body--widescreen::before { + left: 4rem !important; + } + .usa-alert__body--widescreen { + padding-left: 7rem!important; + } } -// NOTE: !important is used because _font.scss overrides this -.usa-alert__body--widescreen { - max-width: $widescreen-max-width !important; +/*---------------- + Alert Fonts +-----------------*/ +// Fixes some font size disparities with the Figma +// for usa-alert alert elements +.usa-alert { + .usa-alert__heading.larger-font-sizing { + font-size: 1.5rem; + } } +/*---------------- + Alert Coloring +-----------------*/ .usa-site-alert--hot-pink { .usa-alert { background-color: $hot-pink; @@ -47,3 +60,8 @@ background-color: color('base-darkest'); } } + +// Override the specificity of USWDS css to enable no max width on admin alerts +.usa-alert__body.maxw-none { + max-width: none; +} diff --git a/src/registrar/assets/src/sass/_theme/_base.scss b/src/registrar/assets/src/sass/_theme/_base.scss index 62f9f436e..d73becd75 100644 --- a/src/registrar/assets/src/sass/_theme/_base.scss +++ b/src/registrar/assets/src/sass/_theme/_base.scss @@ -2,6 +2,8 @@ @use "cisa_colors" as *; $widescreen-max-width: 1920px; +$widescreen-x-padding: 4.5rem; + $hot-pink: #FFC3F9; /* Styles for making visible to screen reader / AT users only. */ @@ -39,7 +41,8 @@ body { padding-top: units(5)!important; } -#wrapper.dashboard--grey-1 { +#wrapper.dashboard--grey-1, +.bg-gray-1 { background-color: color('gray-1'); } @@ -252,6 +255,15 @@ abbr[title] { max-width: $widescreen-max-width; } +// This is used in cases where we want to align content to widescreen margins +// but we don't want the content itself to have widescreen widths +@include at-media(desktop) { + .padding-x--widescreen { + padding-left: $widescreen-x-padding !important; + padding-right: $widescreen-x-padding !important; + } +} + .margin-right-neg-4px { margin-right: -4px; } @@ -265,4 +277,8 @@ abbr[title] { margin: 0; height: 1.5em; width: 1.5em; +} + +.maxw-fit-content { + max-width: fit-content; } \ No newline at end of file diff --git a/src/registrar/assets/src/sass/_theme/_containers.scss b/src/registrar/assets/src/sass/_theme/_containers.scss index 7473615ad..24ad480f2 100644 --- a/src/registrar/assets/src/sass/_theme/_containers.scss +++ b/src/registrar/assets/src/sass/_theme/_containers.scss @@ -6,3 +6,21 @@ .usa-identifier__container--widescreen { max-width: $widescreen-max-width !important; } + + +// NOTE: !important is used because we are overriding default +// USWDS paddings in a few locations +@include at-media(desktop) { + .grid-container--widescreen { + padding-left: $widescreen-x-padding !important; + padding-right: $widescreen-x-padding !important; + } +} + +// matches max-width to equal the max-width of .grid-container +// used to trick the eye into thinking we have left-aligned a +// regular grid-container within a widescreen (see instances +// where is_widescreen_centered is used in the html). +.max-width--grid-container { + max-width: 960px; +} \ No newline at end of file diff --git a/src/registrar/assets/src/sass/_theme/_forms.scss b/src/registrar/assets/src/sass/_theme/_forms.scss index 9158de174..4138c5878 100644 --- a/src/registrar/assets/src/sass/_theme/_forms.scss +++ b/src/registrar/assets/src/sass/_theme/_forms.scss @@ -78,3 +78,7 @@ legend.float-left-tablet + button.float-right-tablet { .read-only-value { margin-top: units(0); } + +.bg-gray-1 .usa-radio { + background: color('gray-1'); +} diff --git a/src/registrar/assets/src/sass/_theme/_header.scss b/src/registrar/assets/src/sass/_theme/_header.scss index 53eab90d8..ffb880a7b 100644 --- a/src/registrar/assets/src/sass/_theme/_header.scss +++ b/src/registrar/assets/src/sass/_theme/_header.scss @@ -110,8 +110,8 @@ } } .usa-nav__secondary { - // I don't know why USWDS has this at 2 rem, which puts it out of alignment - right: 3rem; + right: 1rem; + padding-right: $widescreen-x-padding; color: color('white'); bottom: 4.3rem; .usa-nav-link, diff --git a/src/registrar/assets/src/sass/_theme/_register-form.scss b/src/registrar/assets/src/sass/_theme/_register-form.scss index 41d2980e3..fcc5b5ae6 100644 --- a/src/registrar/assets/src/sass/_theme/_register-form.scss +++ b/src/registrar/assets/src/sass/_theme/_register-form.scss @@ -12,7 +12,7 @@ margin-top: units(1); } -// register-form-review-header is used on the summary page and +// header--body is used on the summary page and // should not be styled like the register form headers .register-form-step h3 { color: color('primary-dark'); @@ -25,15 +25,6 @@ } } -.register-form-review-header { - color: color('primary-dark'); - margin-top: units(2); - margin-bottom: 0; - font-weight: font-weight('semibold'); - // The units mixin can only get us close, so it's between - // hardcoding the value and using in markup - font-size: 16.96px; -} .register-form-step h4 { margin-bottom: 0; diff --git a/src/registrar/assets/src/sass/_theme/_typography.scss b/src/registrar/assets/src/sass/_theme/_typography.scss index 7c7639a83..db19a595b 100644 --- a/src/registrar/assets/src/sass/_theme/_typography.scss +++ b/src/registrar/assets/src/sass/_theme/_typography.scss @@ -23,6 +23,14 @@ h2 { color: color('primary-darker'); } +.header--body { + margin-top: units(2); + font-weight: font-weight('semibold'); + // The units mixin can only get us close, so it's between + // hardcoding the value and using in markup + font-size: 16.96px; +} + .h4--sm-05 { font-size: size('body', 'sm'); font-weight: normal; diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index 050950c9b..0111245a1 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -251,7 +251,7 @@ TEMPLATES = [ "registrar.context_processors.org_user_status", "registrar.context_processors.add_path_to_context", "registrar.context_processors.portfolio_permissions", - "registrar.context_processors.is_widescreen_mode", + "registrar.context_processors.is_widescreen_centered", ], }, }, diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 2bf7b9e5f..beb38e104 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -146,7 +146,7 @@ urlpatterns = [ # ), path( "members/new-member/", - views.NewMemberView.as_view(), + views.PortfolioAddMemberView.as_view(), name="new-member", ), path( diff --git a/src/registrar/context_processors.py b/src/registrar/context_processors.py index 7230b04c6..b3d9c3727 100644 --- a/src/registrar/context_processors.py +++ b/src/registrar/context_processors.py @@ -109,31 +109,21 @@ def portfolio_permissions(request): return portfolio_context -def is_widescreen_mode(request): - widescreen_paths = [] # If this list is meant to include specific paths, populate it. - portfolio_widescreen_paths = [ +def is_widescreen_centered(request): + include_paths = [ "/domains/", "/requests/", - "/request/", - "/no-organization-requests/", - "/no-organization-domains/", - "/domain-request/", + "/members/", ] - # widescreen_paths can be a bear as it trickles down sub-urls. exclude_paths gives us a way out. exclude_paths = [ "/domains/edit", + "members/new-member/", ] - # Check if the current path matches a widescreen path or the root path. - is_widescreen = any(path in request.path for path in widescreen_paths) or request.path == "/" + is_excluded = any(exclude_path in request.path for exclude_path in exclude_paths) - # Check if the user is an organization user and the path matches portfolio paths. - is_portfolio_widescreen = ( - hasattr(request.user, "is_org_user") - and request.user.is_org_user(request) - and any(path in request.path for path in portfolio_widescreen_paths) - and not any(exclude_path in request.path for exclude_path in exclude_paths) - ) + # Check if the current path matches a path in included_paths or the root path. + is_widescreen_centered = any(path in request.path for path in include_paths) or request.path == "/" # Return a dictionary with the widescreen mode status. - return {"is_widescreen_mode": is_widescreen or is_portfolio_widescreen} + return {"is_widescreen_centered": is_widescreen_centered and not is_excluded} diff --git a/src/registrar/fixtures/fixtures_user_portfolio_permissions.py b/src/registrar/fixtures/fixtures_user_portfolio_permissions.py index 15265cfa8..5f9fd64ef 100644 --- a/src/registrar/fixtures/fixtures_user_portfolio_permissions.py +++ b/src/registrar/fixtures/fixtures_user_portfolio_permissions.py @@ -60,7 +60,10 @@ class UserPortfolioPermissionFixture: user=user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], - additional_permissions=[UserPortfolioPermissionChoices.EDIT_MEMBERS], + additional_permissions=[ + UserPortfolioPermissionChoices.EDIT_MEMBERS, + UserPortfolioPermissionChoices.EDIT_REQUESTS, + ], ) user_portfolio_permissions_to_create.append(user_portfolio_permission) else: diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index 289b3da0b..ccdbb17ba 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -17,6 +17,7 @@ from registrar.models import Contact, DomainRequest, DraftDomain, Domain, Federa from registrar.templatetags.url_helpers import public_site_url from registrar.utility.enums import ValidationReturnType from registrar.utility.constants import BranchChoices +from django.core.exceptions import ValidationError logger = logging.getLogger(__name__) @@ -78,6 +79,20 @@ class RequestingEntityForm(RegistrarForm): # Otherwise just return the suborg as normal return self.cleaned_data.get("sub_organization") + def clean_requested_suborganization(self): + name = self.cleaned_data.get("requested_suborganization") + if ( + name + and Suborganization.objects.filter( + name__iexact=name, portfolio=self.domain_request.portfolio, name__isnull=False, portfolio__isnull=False + ).exists() + ): + raise ValidationError( + "This suborganization already exists. " + "Choose a new name, or select it directly if you would like to use it." + ) + return name + def full_clean(self): """Validation logic to remove the custom suborganization value before clean is triggered. Without this override, the form will throw an 'invalid option' error.""" @@ -114,7 +129,7 @@ class RequestingEntityForm(RegistrarForm): if requesting_entity_is_suborganization == "True": if is_requesting_new_suborganization: # Validate custom suborganization fields - if not cleaned_data.get("requested_suborganization"): + if not cleaned_data.get("requested_suborganization") and "requested_suborganization" not in self.errors: self.add_error("requested_suborganization", "Enter the name of your suborganization.") if not cleaned_data.get("suborganization_city"): self.add_error("suborganization_city", "Enter the city where your suborganization is located.") @@ -144,9 +159,12 @@ class RequestingEntityYesNoForm(BaseYesNoForm): """Extend the initialization of the form from RegistrarForm __init__""" super().__init__(*args, **kwargs) if self.domain_request.portfolio: + choose_text = ( + "(choose from list)" if self.domain_request.portfolio.portfolio_suborganizations.exists() else "" + ) self.form_choices = ( (False, self.domain_request.portfolio), - (True, "A suborganization (choose from list)"), + (True, f"A suborganization {choose_text}"), ) self.fields[self.field_name] = self.get_typed_choice_field() diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index eaa885a85..0a8c4d623 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -12,7 +12,6 @@ from registrar.models import ( DomainInformation, Portfolio, SeniorOfficial, - User, ) from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices @@ -111,170 +110,7 @@ class PortfolioSeniorOfficialForm(forms.ModelForm): return cleaned_data -class PortfolioMemberForm(forms.ModelForm): - """ - Form for updating a portfolio member. - """ - - roles = forms.MultipleChoiceField( - choices=UserPortfolioRoleChoices.choices, - widget=forms.SelectMultiple(attrs={"class": "usa-select"}), - required=False, - label="Roles", - ) - - additional_permissions = forms.MultipleChoiceField( - choices=UserPortfolioPermissionChoices.choices, - widget=forms.SelectMultiple(attrs={"class": "usa-select"}), - required=False, - label="Additional Permissions", - ) - - class Meta: - model = UserPortfolioPermission - fields = [ - "roles", - "additional_permissions", - ] - - -class PortfolioInvitedMemberForm(forms.ModelForm): - """ - Form for updating a portfolio invited member. - """ - - roles = forms.MultipleChoiceField( - choices=UserPortfolioRoleChoices.choices, - widget=forms.SelectMultiple(attrs={"class": "usa-select"}), - required=False, - label="Roles", - ) - - additional_permissions = forms.MultipleChoiceField( - choices=UserPortfolioPermissionChoices.choices, - widget=forms.SelectMultiple(attrs={"class": "usa-select"}), - required=False, - label="Additional Permissions", - ) - - class Meta: - model = PortfolioInvitation - fields = [ - "roles", - "additional_permissions", - ] - - -class NewMemberForm(forms.ModelForm): - member_access_level = forms.ChoiceField( - label="Select permission", - choices=[("admin", "Admin Access"), ("basic", "Basic Access")], - widget=forms.RadioSelect(attrs={"class": "usa-radio__input usa-radio__input--tile"}), - required=True, - error_messages={ - "required": "Member access level is required", - }, - ) - admin_org_domain_request_permissions = forms.ChoiceField( - label="Select permission", - choices=[("view_only", "View all requests"), ("view_and_create", "View all requests plus create requests")], - widget=forms.RadioSelect, - required=True, - error_messages={ - "required": "Admin domain request permission is required", - }, - ) - admin_org_members_permissions = forms.ChoiceField( - label="Select permission", - choices=[("view_only", "View all members"), ("view_and_create", "View all members plus manage members")], - widget=forms.RadioSelect, - required=True, - error_messages={ - "required": "Admin member permission is required", - }, - ) - basic_org_domain_request_permissions = forms.ChoiceField( - label="Select permission", - choices=[ - ("view_only", "View all requests"), - ("view_and_create", "View all requests plus create requests"), - ("no_access", "No access"), - ], - widget=forms.RadioSelect, - required=True, - error_messages={ - "required": "Basic member permission is required", - }, - ) - - email = forms.EmailField( - label="Enter the email of the member you'd like to invite", - max_length=None, - error_messages={ - "invalid": ("Enter an email address in the required format, like name@example.com."), - "required": ("Enter an email address in the required format, like name@example.com."), - }, - validators=[ - MaxLengthValidator( - 320, - message="Response must be less than 320 characters.", - ) - ], - required=True, - ) - - class Meta: - model = User - fields = ["email"] - - def clean(self): - cleaned_data = super().clean() - - # Lowercase the value of the 'email' field - email_value = cleaned_data.get("email") - if email_value: - cleaned_data["email"] = email_value.lower() - - ########################################## - # TODO: future ticket - # (invite new member) - ########################################## - # Check for an existing user (if there isn't any, send an invite) - # if email_value: - # try: - # existingUser = User.objects.get(email=email_value) - # except User.DoesNotExist: - # raise forms.ValidationError("User with this email does not exist.") - - member_access_level = cleaned_data.get("member_access_level") - - # Intercept the error messages so that we don't validate hidden inputs - if not member_access_level: - # If no member access level has been selected, delete error messages - # for all hidden inputs (which is everything except the e-mail input - # and member access selection) - for field in self.fields: - if field in self.errors and field != "email" and field != "member_access_level": - del self.errors[field] - return cleaned_data - - basic_dom_req_error = "basic_org_domain_request_permissions" - admin_dom_req_error = "admin_org_domain_request_permissions" - admin_member_error = "admin_org_members_permissions" - - if member_access_level == "admin" and basic_dom_req_error in self.errors: - # remove the error messages pertaining to basic permission inputs - del self.errors[basic_dom_req_error] - elif member_access_level == "basic": - # remove the error messages pertaining to admin permission inputs - if admin_dom_req_error in self.errors: - del self.errors[admin_dom_req_error] - if admin_member_error in self.errors: - del self.errors[admin_member_error] - return cleaned_data - - -class BasePortfolioMemberForm(forms.Form): +class BasePortfolioMemberForm(forms.ModelForm): """Base form for the PortfolioMemberForm and PortfolioInvitedMemberForm""" # The label for each of these has a red "required" star. We can just embed that here for simplicity. @@ -345,13 +181,18 @@ class BasePortfolioMemberForm(forms.Form): ], } - def __init__(self, *args, instance=None, **kwargs): - """Initialize self.instance, self.initial, and descriptions under each radio button. - Uses map_instance_to_initial to set the initial dictionary.""" + class Meta: + model = None + fields = ["roles", "additional_permissions"] + + def __init__(self, *args, **kwargs): + """ + Override the form's initialization. + + Map existing model values to custom form fields. + Update field descriptions. + """ super().__init__(*args, **kwargs) - if instance: - self.instance = instance - self.initial = self.map_instance_to_initial(self.instance) # Adds a

description beneath each role option self.fields["role"].descriptions = { "organization_admin": UserPortfolioRoleChoices.get_role_description( @@ -361,17 +202,15 @@ class BasePortfolioMemberForm(forms.Form): UserPortfolioRoleChoices.ORGANIZATION_MEMBER ), } - - def save(self): - """Saves self.instance by grabbing data from self.cleaned_data. - Uses map_cleaned_data_to_instance. - """ - self.instance = self.map_cleaned_data_to_instance(self.cleaned_data, self.instance) - self.instance.save() - return self.instance + # Map model instance values to custom form fields + if self.instance: + self.map_instance_to_initial() def clean(self): - """Validates form data based on selected role and its required fields.""" + """Validates form data based on selected role and its required fields. + Updates roles and additional_permissions in cleaned_data so they can be properly + mapped to the model. + """ cleaned_data = super().clean() role = cleaned_data.get("role") @@ -389,20 +228,30 @@ class BasePortfolioMemberForm(forms.Form): if cleaned_data.get("domain_request_permission_member") == "no_access": cleaned_data["domain_request_permission_member"] = None + # Handle roles + cleaned_data["roles"] = [role] + + # Handle additional_permissions + valid_fields = self.ROLE_REQUIRED_FIELDS.get(role, []) + additional_permissions = {cleaned_data.get(field) for field in valid_fields if cleaned_data.get(field)} + + # Handle EDIT permissions (should be accompanied with a view permission) + if UserPortfolioPermissionChoices.EDIT_MEMBERS in additional_permissions: + additional_permissions.add(UserPortfolioPermissionChoices.VIEW_MEMBERS) + + if UserPortfolioPermissionChoices.EDIT_REQUESTS in additional_permissions: + additional_permissions.add(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS) + + # Only set unique permissions not already defined in the base role + role_permissions = UserPortfolioPermission.get_portfolio_permissions(cleaned_data["roles"], [], get_list=False) + cleaned_data["additional_permissions"] = list(additional_permissions - role_permissions) + return cleaned_data - # Explanation of how map_instance_to_initial / map_cleaned_data_to_instance work: - # map_instance_to_initial => called on init to set self.initial. - # Converts the incoming object (usually PortfolioInvitation or UserPortfolioPermission) - # into a dictionary representation for the form to use automatically. - - # map_cleaned_data_to_instance => called on save() to save the instance to the db. - # Takes the self.cleaned_data dict, and converts this dict back to the object. - - def map_instance_to_initial(self, instance): + def map_instance_to_initial(self): """ Maps self.instance to self.initial, handling roles and permissions. - Returns form data dictionary with appropriate permission levels based on user role: + Updates self.initial dictionary with appropriate permission levels based on user role: { "role": "organization_admin" or "organization_member", "member_permission_admin": permission level if admin, @@ -410,12 +259,12 @@ class BasePortfolioMemberForm(forms.Form): "domain_request_permission_member": permission level if member } """ + if self.initial is None: + self.initial = {} # Function variables - form_data = {} perms = UserPortfolioPermission.get_portfolio_permissions( - instance.roles, instance.additional_permissions, get_list=False + self.instance.roles, self.instance.additional_permissions, get_list=False ) - # Get the available options for roles, domains, and member. roles = [ UserPortfolioRoleChoices.ORGANIZATION_ADMIN, @@ -433,49 +282,62 @@ class BasePortfolioMemberForm(forms.Form): # Build form data based on role (which options are available). # Get which one should be "selected" by assuming that EDIT takes precedence over view, # and ADMIN takes precedence over MEMBER. - roles = instance.roles or [] + roles = self.instance.roles or [] selected_role = next((role for role in roles if role in roles), None) - form_data = {"role": selected_role} + self.initial["role"] = selected_role is_admin = selected_role == UserPortfolioRoleChoices.ORGANIZATION_ADMIN if is_admin: selected_domain_permission = next((perm for perm in domain_perms if perm in perms), None) selected_member_permission = next((perm for perm in member_perms if perm in perms), None) - form_data["domain_request_permission_admin"] = selected_domain_permission - form_data["member_permission_admin"] = selected_member_permission + self.initial["domain_request_permission_admin"] = selected_domain_permission + self.initial["member_permission_admin"] = selected_member_permission else: # Edgecase: Member uses a special form value for None called "no_access". This ensures a form selection. selected_domain_permission = next((perm for perm in domain_perms if perm in perms), "no_access") - form_data["domain_request_permission_member"] = selected_domain_permission + self.initial["domain_request_permission_member"] = selected_domain_permission - return form_data - def map_cleaned_data_to_instance(self, cleaned_data, instance): - """ - Maps self.cleaned_data to self.instance, setting roles and permissions. - Args: - cleaned_data (dict): Cleaned data containing role and permission choices - instance: Instance to update +class PortfolioMemberForm(BasePortfolioMemberForm): + """ + Form for updating a portfolio member. + """ - Returns: - instance: Updated instance - """ - role = cleaned_data.get("role") + class Meta: + model = UserPortfolioPermission + fields = ["roles", "additional_permissions"] - # Handle roles - instance.roles = [role] - # Handle additional_permissions - valid_fields = self.ROLE_REQUIRED_FIELDS.get(role, []) - additional_permissions = {cleaned_data.get(field) for field in valid_fields if cleaned_data.get(field)} +class PortfolioInvitedMemberForm(BasePortfolioMemberForm): + """ + Form for updating a portfolio invited member. + """ - # Handle EDIT permissions (should be accompanied with a view permission) - if UserPortfolioPermissionChoices.EDIT_MEMBERS in additional_permissions: - additional_permissions.add(UserPortfolioPermissionChoices.VIEW_MEMBERS) + class Meta: + model = PortfolioInvitation + fields = ["roles", "additional_permissions"] - if UserPortfolioPermissionChoices.EDIT_REQUESTS in additional_permissions: - additional_permissions.add(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS) - # Only set unique permissions not already defined in the base role - role_permissions = UserPortfolioPermission.get_portfolio_permissions(instance.roles, [], get_list=False) - instance.additional_permissions = list(additional_permissions - role_permissions) - return instance +class PortfolioNewMemberForm(BasePortfolioMemberForm): + """ + Form for adding a portfolio invited member. + """ + + email = forms.EmailField( + label="Enter the email of the member you'd like to invite", + max_length=None, + error_messages={ + "invalid": ("Enter an email address in the required format, like name@example.com."), + "required": ("Enter an email address in the required format, like name@example.com."), + }, + validators=[ + MaxLengthValidator( + 320, + message="Response must be less than 320 characters.", + ) + ], + required=True, + ) + + class Meta: + model = PortfolioInvitation + fields = ["portfolio", "email", "roles", "additional_permissions"] diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index 44d8511b0..3d3aac769 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -12,6 +12,7 @@ from registrar.models.utility.generic_helper import CreateOrUpdateOrganizationTy 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 @@ -671,6 +672,59 @@ class DomainRequest(TimeStampedModel): # Store original values for caching purposes. Used to compare them on save. self._cache_status_and_status_reasons() + def clean(self): + """ + Validates suborganization-related fields in two scenarios: + 1. New suborganization request: Prevents duplicate names within same portfolio + 2. Partial suborganization data: Enforces a all-or-nothing rule for city/state/name fields + when portfolio exists without selected suborganization + + Add new domain request validation rules here to ensure they're + enforced during both model save and form submission. + Not presently used on the domain request wizard, though. + """ + super().clean() + # Validation logic for a suborganization request + if self.is_requesting_new_suborganization(): + # Raise an error if this suborganization already exists + Suborganization = apps.get_model("registrar.Suborganization") + if ( + self.requested_suborganization + and Suborganization.objects.filter( + name__iexact=self.requested_suborganization, + portfolio=self.portfolio, + name__isnull=False, + portfolio__isnull=False, + ).exists() + ): + # Add a field-level error to requested_suborganization. + # To pass in field-specific errors, we need to embed a dict of + # field: validationerror then pass that into a validation error itself. + # This is slightly confusing, but it just adds it at that level. + msg = ( + "This suborganization already exists. " + "Choose a new name, or select it directly if you would like to use it." + ) + errors = {"requested_suborganization": ValidationError(msg)} + raise ValidationError(errors) + elif self.portfolio and not self.sub_organization: + # You cannot create a new suborganization without these fields + required_suborg_fields = { + "requested_suborganization": self.requested_suborganization, + "suborganization_city": self.suborganization_city, + "suborganization_state_territory": self.suborganization_state_territory, + } + # If at least one value is populated, enforce a all-or-nothing rule + if any(bool(value) for value in required_suborg_fields.values()): + # Find which fields are empty and throw an error on the field + errors = {} + for field_name, value in required_suborg_fields.items(): + if not value: + errors[field_name] = ValidationError( + "This field is required when creating a new suborganization.", + ) + raise ValidationError(errors) + def save(self, *args, **kwargs): """Save override for custom properties""" self.sync_organization_type() @@ -690,6 +744,18 @@ class DomainRequest(TimeStampedModel): # Update the cached values after saving self._cache_status_and_status_reasons() + def create_requested_suborganization(self): + """Creates the requested suborganization. + Adds the name, portfolio, city, and state_territory fields. + Returns the created suborganization.""" + Suborganization = apps.get_model("registrar.Suborganization") + return Suborganization.objects.create( + name=self.requested_suborganization, + portfolio=self.portfolio, + city=self.suborganization_city, + state_territory=self.suborganization_state_territory, + ) + def send_custom_status_update_email(self, status): """Helper function to send out a second status email when the status remains the same, but the reason has changed.""" @@ -784,7 +850,9 @@ class DomainRequest(TimeStampedModel): return True def delete_and_clean_up_domain(self, called_from): + # Delete the approved domain try: + # Clean up the approved domain domain_state = self.approved_domain.state # Only reject if it exists on EPP if domain_state != Domain.State.UNKNOWN: @@ -796,6 +864,39 @@ class DomainRequest(TimeStampedModel): logger.error(err) logger.error(f"Can't query an approved domain while attempting {called_from}") + # Delete the suborg as long as this is the only place it is used + self._cleanup_dangling_suborg() + + def _cleanup_dangling_suborg(self): + """Deletes the existing suborg if its only being used by the deleted record""" + # Nothing to delete, so we just smile and walk away + if self.sub_organization is None: + return + + Suborganization = apps.get_model("registrar.Suborganization") + + # Stored as so because we need to set the reference to none first, + # so we can't just use the self.sub_organization property + suborg = Suborganization.objects.get(id=self.sub_organization.id) + requests = suborg.request_sub_organization + domain_infos = suborg.information_sub_organization + + # Check if this is the only reference to the suborganization + if requests.count() != 1 or domain_infos.count() > 1: + return + + # Remove the suborganization reference from request. + self.sub_organization = None + self.save() + + # Remove the suborganization reference from domain if it exists. + if domain_infos.count() == 1: + domain_infos.update(sub_organization=None) + + # Delete the now-orphaned suborganization + logger.info(f"_cleanup_dangling_suborg() -> Deleting orphan suborganization: {suborg}") + suborg.delete() + def _send_status_update_email( self, new_status, @@ -984,6 +1085,7 @@ class DomainRequest(TimeStampedModel): if self.status == self.DomainRequestStatus.APPROVED: self.delete_and_clean_up_domain("action_needed") + elif self.status == self.DomainRequestStatus.REJECTED: self.rejection_reason = None @@ -1014,8 +1116,16 @@ class DomainRequest(TimeStampedModel): 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 @@ -1148,7 +1258,7 @@ class DomainRequest(TimeStampedModel): def is_requesting_new_suborganization(self) -> bool: """Determines if a user is trying to request a new suborganization using the domain request form, rather than one that already exists. - Used for the RequestingEntity page. + Used for the RequestingEntity page and on DomainInformation.create_from_da(). Returns True if a sub_organization does not exist and if requested_suborganization, suborganization_city, and suborganization_state_territory all exist. diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py index 25abb6748..03a01b80d 100644 --- a/src/registrar/models/user_portfolio_permission.py +++ b/src/registrar/models/user_portfolio_permission.py @@ -171,8 +171,10 @@ class UserPortfolioPermission(TimeStampedModel): # The solution to this is to only grab what is only COMMONLY "forbidden". # This will scale if we add more roles in the future. # This is thes same as applying the `&` operator across all sets for each role. - common_forbidden_perms = set.intersection( - *[set(cls.FORBIDDEN_PORTFOLIO_ROLE_PERMISSIONS.get(role, [])) for role in roles] + common_forbidden_perms = ( + set.intersection(*[set(cls.FORBIDDEN_PORTFOLIO_ROLE_PERMISSIONS.get(role, [])) for role in roles]) + if roles + else set() ) # Check if the users current permissions overlap with any forbidden permissions diff --git a/src/registrar/templates/401.html b/src/registrar/templates/401.html index d7c7f83ae..7698c4f82 100644 --- a/src/registrar/templates/401.html +++ b/src/registrar/templates/401.html @@ -5,8 +5,8 @@ {% block title %}{% translate "Unauthorized | " %}{% endblock %} {% block content %} -

-
+
+

{% translate "You are not authorized to view this page" %} diff --git a/src/registrar/templates/403.html b/src/registrar/templates/403.html index 999d5f98e..a04453fe9 100644 --- a/src/registrar/templates/403.html +++ b/src/registrar/templates/403.html @@ -5,8 +5,8 @@ {% block title %}{% translate "Forbidden | " %}{% endblock %} {% block content %} -
-
+
+

{% translate "You're not authorized to view this page." %} diff --git a/src/registrar/templates/404.html b/src/registrar/templates/404.html index 471575558..2bf9ecf02 100644 --- a/src/registrar/templates/404.html +++ b/src/registrar/templates/404.html @@ -5,8 +5,8 @@ {% block title %}{% translate "Page not found | " %}{% endblock %} {% block content %} -
-
+
+

{% translate "We couldn’t find that page" %} diff --git a/src/registrar/templates/500.html b/src/registrar/templates/500.html index a0663816b..fad909ddb 100644 --- a/src/registrar/templates/500.html +++ b/src/registrar/templates/500.html @@ -5,8 +5,8 @@ {% block title %}{% translate "Server error | " %}{% endblock %} {% block content %} -
-
+
+

{% translate "We're having some trouble." %} diff --git a/src/registrar/templates/admin/app_list.html b/src/registrar/templates/admin/app_list.html index 49fb59e79..aaf3dc423 100644 --- a/src/registrar/templates/admin/app_list.html +++ b/src/registrar/templates/admin/app_list.html @@ -39,7 +39,7 @@ {% for model in app.models %} {% if model.admin_url %} - {{ model.name }} + {{ model.name }} {% else %} {{ model.name }} {% endif %} diff --git a/src/registrar/templates/admin/fieldset.html b/src/registrar/templates/admin/fieldset.html index 40cd98ca8..20b76217b 100644 --- a/src/registrar/templates/admin/fieldset.html +++ b/src/registrar/templates/admin/fieldset.html @@ -61,7 +61,7 @@ https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/ {% if field.field.help_text %} {# .gov override #} {% block help_text %} -
+
{{ field.field.help_text|safe }}
{% endblock help_text %} diff --git a/src/registrar/templates/admin/transfer_user.html b/src/registrar/templates/admin/transfer_user.html index 3ba136b93..61444b173 100644 --- a/src/registrar/templates/admin/transfer_user.html +++ b/src/registrar/templates/admin/transfer_user.html @@ -43,7 +43,7 @@ {% endif %} + {% elif field.field.name == "requested_suborganization" %} + {{ field.field }} +
+ +
{% else %} {{ field.field }} {% endif %} diff --git a/src/registrar/templates/django/admin/includes/details_button.html b/src/registrar/templates/django/admin/includes/details_button.html index 73748f170..65c6d768b 100644 --- a/src/registrar/templates/django/admin/includes/details_button.html +++ b/src/registrar/templates/django/admin/includes/details_button.html @@ -1,6 +1,6 @@ {% comment %} This view provides a detail button that can be used to show/hide content {% endcomment %} -
+
Details
{% block detail_content %} diff --git a/src/registrar/templates/django/admin/includes/domain_fieldset.html b/src/registrar/templates/django/admin/includes/domain_fieldset.html index d5f5bc1af..c621deaac 100644 --- a/src/registrar/templates/django/admin/includes/domain_fieldset.html +++ b/src/registrar/templates/django/admin/includes/domain_fieldset.html @@ -11,7 +11,7 @@ {% endblock %} {% block help_text %} -
+
{% if field.field.name == "state" %}
{{ state_help_message }}
{% else %} diff --git a/src/registrar/templates/django/admin/multiple_choice_list_filter.html b/src/registrar/templates/django/admin/multiple_choice_list_filter.html index 66643f4ec..167059594 100644 --- a/src/registrar/templates/django/admin/multiple_choice_list_filter.html +++ b/src/registrar/templates/django/admin/multiple_choice_list_filter.html @@ -6,14 +6,14 @@
    {% for choice in choices %} {% if choice.reset %} - + {{ choice.display }} {% endif %} {% endfor %} {% for choice in choices %} {% if not choice.reset %} - + {% if choice.selected and choice.exclude_query_string %} {{ choice.display }} + {{ block.super }} +{% endblock %} diff --git a/src/registrar/templates/django/admin/user_portfolio_permission_change_form.html b/src/registrar/templates/django/admin/user_portfolio_permission_change_form.html index 1249a486c..489d67bc5 100644 --- a/src/registrar/templates/django/admin/user_portfolio_permission_change_form.html +++ b/src/registrar/templates/django/admin/user_portfolio_permission_change_form.html @@ -2,15 +2,13 @@ {% load custom_filters %} {% load i18n static %} -{% block field_sets %} - {% for fieldset in adminform %} - {% comment %} - This is a placeholder for now. - - Disclaimer: - When extending the fieldset view consider whether you need to make a new one that extends from detail_table_fieldset. - detail_table_fieldset is used on multiple admin pages, so a change there can have unintended consequences. - {% endcomment %} - {% include "django/admin/includes/user_portfolio_permission_fieldset.html" with original_object=original %} - {% endfor %} -{% endblock %} \ No newline at end of file +{% block content_subtitle %} +
    +
    +

    + If you add someone to a portfolio here, it will not trigger an invitation email. To trigger an email, use the Portfolio invitations table instead. +

    +
    +
    + {{ block.super }} +{% endblock %} diff --git a/src/registrar/templates/domain_base.html b/src/registrar/templates/domain_base.html index de8e88791..c88492a93 100644 --- a/src/registrar/templates/domain_base.html +++ b/src/registrar/templates/domain_base.html @@ -6,10 +6,10 @@ {% block title %}{{ domain.name }} | {% endblock %} {% block content %} -
    +
    -
    -
    +
    +

    diff --git a/src/registrar/templates/domain_request_dotgov_domain.html b/src/registrar/templates/domain_request_dotgov_domain.html index 6c62c6497..38347ad96 100644 --- a/src/registrar/templates/domain_request_dotgov_domain.html +++ b/src/registrar/templates/domain_request_dotgov_domain.html @@ -6,11 +6,7 @@

    • Be available
    • Relate to your organization’s name, location, and/or services
    • - {% if portfolio %} -
    • Be clear to the general public. Your domain name must not be easily confused with other organizations.
    • - {% else %}
    • Be unlikely to mislead or confuse the general public (even if your domain is only intended for a specific audience)
    • - {% endif %}

    diff --git a/src/registrar/templates/domain_request_form.html b/src/registrar/templates/domain_request_form.html index 2c2716a3c..d8b020cc1 100644 --- a/src/registrar/templates/domain_request_form.html +++ b/src/registrar/templates/domain_request_form.html @@ -3,8 +3,8 @@ {% block title %}{{form_titles|get_item:steps.current}} | Request a .gov | {% endblock %} {% block content %} -
    -
    +
    +
    {% include 'domain_request_sidebar.html' %}
    diff --git a/src/registrar/templates/domain_request_intro.html b/src/registrar/templates/domain_request_intro.html index dd5b7ec6e..263201393 100644 --- a/src/registrar/templates/domain_request_intro.html +++ b/src/registrar/templates/domain_request_intro.html @@ -4,41 +4,42 @@ {% block title %} Start a request | {% endblock %} {% block content %} -
    -
    +
    +
    +
    -
    - {% csrf_token %} + + {% csrf_token %} -

    You’re about to start your .gov domain request.

    -

    You don’t have to complete the process in one session. You can save what you enter and come back to it when you’re ready.

    - {% if portfolio %} -

    We’ll use the information you provide to verify your domain request meets our guidelines.

    - {% else %} -

    We’ll use the information you provide to verify your organization’s eligibility for a .gov domain. We’ll also verify that the domain you request meets our guidelines.

    - {% endif %} -

    Time to complete the form

    -

    If you have all the information you need, - completing your domain request might take around 15 minutes.

    -

    How we’ll reach you

    -

    While reviewing your domain request, we may need to reach out with questions. We’ll also email you when we complete our review. If the contact information below is not correct, visit your profile to make updates.

    - {% include "includes/profile_information.html" with user=user%} - +

    You’re about to start your .gov domain request.

    +

    You don’t have to complete the process in one session. You can save what you enter and come back to it when you’re ready.

    + {% if portfolio %} +

    We’ll use the information you provide to verify your domain request meets our guidelines.

    + {% else %} +

    We’ll use the information you provide to verify your organization’s eligibility for a .gov domain. We’ll also verify that the domain you request meets our guidelines.

    + {% endif %} +

    Time to complete the form

    +

    If you have all the information you need, + completing your domain request might take around 15 minutes.

    +

    How we’ll reach you

    +

    While reviewing your domain request, we may need to reach out with questions. We’ll also email you when we complete our review. If the contact information below is not correct, visit your profile to make updates.

    + {% include "includes/profile_information.html" with user=user%} + -{% block form_buttons %} -
    - -
    -{% endblock %} + {% block form_buttons %} +
    + +
    + {% endblock %} -
    + -
    Paperwork Reduction Act statement (OMB control number: 1670-0049; expiration date: 10/31/2026)
    -
    +
    Paperwork Reduction Act statement (OMB control number: 1670-0049; expiration date: 10/31/2026)
    +
    {% endblock %} diff --git a/src/registrar/templates/domain_request_requesting_entity.html b/src/registrar/templates/domain_request_requesting_entity.html index 9ed83f2d0..d889a8f44 100644 --- a/src/registrar/templates/domain_request_requesting_entity.html +++ b/src/registrar/templates/domain_request_requesting_entity.html @@ -38,8 +38,9 @@

    Add suborganization information

    - This information will be published in .gov’s public data. If you don’t see your suborganization in the list, - select “other.” + This information will be published in .gov’s public data. + If you don’t see your suborganization in the list, + select “other.”

    {% with attr_required=True %} {% input_with_errors forms.1.sub_organization %} diff --git a/src/registrar/templates/domain_request_withdraw_confirmation.html b/src/registrar/templates/domain_request_withdraw_confirmation.html index e1a5f0c2a..525c6784a 100644 --- a/src/registrar/templates/domain_request_withdraw_confirmation.html +++ b/src/registrar/templates/domain_request_withdraw_confirmation.html @@ -8,18 +8,20 @@ {% endblock wrapperdiv %} {% block content %} -
    -
    - +
    +
    +
    + -

    Withdraw request for {{ DomainRequest.requested_domain.name }}?

    +

    Withdraw request for {{ DomainRequest.requested_domain.name }}?

    -

    If you withdraw your request, we won't review it. Once you withdraw your request, you can edit it and submit it again.

    +

    If you withdraw your request, we won't review it. Once you withdraw your request, you can edit it and submit it again.

    -

    Withdraw request - Cancel

    +

    Withdraw request + Cancel

    -
    +
    +
    {% endblock %} diff --git a/src/registrar/templates/domain_sidebar.html b/src/registrar/templates/domain_sidebar.html index dc97f5ca1..9d71ebf63 100644 --- a/src/registrar/templates/domain_sidebar.html +++ b/src/registrar/templates/domain_sidebar.html @@ -18,7 +18,7 @@
  • {% url 'domain-dns' pk=domain.id as url %} - + DNS {% if request.path|startswith:url %} diff --git a/src/registrar/templates/emails/portfolio_invitation.txt b/src/registrar/templates/emails/portfolio_invitation.txt new file mode 100644 index 000000000..775b74c7c --- /dev/null +++ b/src/registrar/templates/emails/portfolio_invitation.txt @@ -0,0 +1,34 @@ +{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #} +Hi. + +{{ requestor_email }} has invited you to {{ portfolio.organization_name }}. + +You can view this organization on the .gov registrar . + +---------------------------------------------------------------- + +YOU NEED A LOGIN.GOV ACCOUNT +You’ll need a Login.gov account to access this .gov organization. That account +needs to be associated with the following email address: {{ email }} + +Login.gov provides a simple and secure process for signing in to many government +services with one account. If you don’t already have one, follow these steps to +create your Login.gov account . + + +SOMETHING WRONG? +If you’re not affiliated with {{ portfolio.organization_name }} or think you received this +message in error, reply to this email. + + +THANK YOU +.Gov helps the public identify official, trusted information. Thank you for using a .gov domain. + +---------------------------------------------------------------- + +The .gov team +Contact us: +Learn about .gov + +The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) +{% endautoescape %} diff --git a/src/registrar/templates/emails/portfolio_invitation_subject.txt b/src/registrar/templates/emails/portfolio_invitation_subject.txt new file mode 100644 index 000000000..552bb2bec --- /dev/null +++ b/src/registrar/templates/emails/portfolio_invitation_subject.txt @@ -0,0 +1 @@ +You’ve been invited to a .gov organization \ No newline at end of file diff --git a/src/registrar/templates/home.html b/src/registrar/templates/home.html index b1c3775df..de4d9e712 100644 --- a/src/registrar/templates/home.html +++ b/src/registrar/templates/home.html @@ -5,12 +5,12 @@ {% block title %} Home | {% endblock %} {% block content %} -
    +
    {% if user.is_authenticated %} {# the entire logged in page goes here #} {% block homepage_content %} -
    +
    {% block messages %} {% include "includes/form_messages.html" %} {% endblock %} diff --git a/src/registrar/templates/includes/banner-error.html b/src/registrar/templates/includes/banner-error.html index 7b5c32ed1..10582e268 100644 --- a/src/registrar/templates/includes/banner-error.html +++ b/src/registrar/templates/includes/banner-error.html @@ -1,6 +1,6 @@
    -
    +

    Header

    diff --git a/src/registrar/templates/includes/banner-info.html b/src/registrar/templates/includes/banner-info.html index e5d54e483..cf379c50d 100644 --- a/src/registrar/templates/includes/banner-info.html +++ b/src/registrar/templates/includes/banner-info.html @@ -1,6 +1,6 @@
    -
    +

    Header

    diff --git a/src/registrar/templates/includes/banner-non-production-alert.html b/src/registrar/templates/includes/banner-non-production-alert.html index 61d4eed51..7b66d543b 100644 --- a/src/registrar/templates/includes/banner-non-production-alert.html +++ b/src/registrar/templates/includes/banner-non-production-alert.html @@ -1,6 +1,6 @@
    -
    +

    Attention: You are on a test site.

    diff --git a/src/registrar/templates/includes/banner-service-disruption.html b/src/registrar/templates/includes/banner-service-disruption.html index fc89ee65d..6ee4b976b 100644 --- a/src/registrar/templates/includes/banner-service-disruption.html +++ b/src/registrar/templates/includes/banner-service-disruption.html @@ -1,6 +1,6 @@
    -
    +

    Service disruption

    diff --git a/src/registrar/templates/includes/banner-site-alert.html b/src/registrar/templates/includes/banner-site-alert.html index 52256f46b..8dd657267 100644 --- a/src/registrar/templates/includes/banner-site-alert.html +++ b/src/registrar/templates/includes/banner-site-alert.html @@ -1,6 +1,6 @@
    -
    +

    Header here

    diff --git a/src/registrar/templates/includes/banner-system-outage.html b/src/registrar/templates/includes/banner-system-outage.html index 911fa4487..60fc4eb03 100644 --- a/src/registrar/templates/includes/banner-system-outage.html +++ b/src/registrar/templates/includes/banner-system-outage.html @@ -1,6 +1,6 @@
    -
    +

    System outage

    diff --git a/src/registrar/templates/includes/banner-warning.html b/src/registrar/templates/includes/banner-warning.html index 6838aef7b..762d0b47c 100644 --- a/src/registrar/templates/includes/banner-warning.html +++ b/src/registrar/templates/includes/banner-warning.html @@ -1,6 +1,6 @@
    -
    +

    Header

    diff --git a/src/registrar/templates/includes/domain_request_status_manage.html b/src/registrar/templates/includes/domain_request_status_manage.html deleted file mode 100644 index 2a254df4b..000000000 --- a/src/registrar/templates/includes/domain_request_status_manage.html +++ /dev/null @@ -1,236 +0,0 @@ -{% load custom_filters %} -{% load static url_helpers %} -
    -
    - {% block breadcrumb %} - {% if portfolio %} - {% url 'domain-requests' as url %} - {% else %} - {% url 'home' as url %} - {% endif %} - - {% endblock breadcrumb %} - - {% block header %} - {% if not DomainRequest.requested_domain and DomainRequest.status == DomainRequest.DomainRequestStatus.STARTED %} -

    New domain request

    - {% else %} -

    Domain request for {{ DomainRequest.requested_domain.name }}

    - {% endif %} - {% endblock header %} - - {% block status_summary %} -
    -
    -

    - - Status: - - {{ DomainRequest.get_status_display|default:"ERROR Please contact technical support/dev" }} -

    -
    -
    -
    - {% endblock status_summary %} - - {% block status_metadata %} - - {% if portfolio %} - {% if DomainRequest.creator %} -

    - Created by: {{DomainRequest.creator.email|default:DomainRequest.creator.get_formatted_name }} -

    - {% else %} -

    - No creator found: this is an error, please email help@get.gov. -

    - {% endif %} - {% endif %} - - {% with statuses=DomainRequest.DomainRequestStatus last_submitted=DomainRequest.last_submitted_date|date:"F j, Y" first_submitted=DomainRequest.first_submitted_date|date:"F j, Y" last_status_update=DomainRequest.last_status_update|date:"F j, Y" %} - {% comment %} - These are intentionally seperated this way. - There is some code repetition, but it gives us more flexibility rather than a dense reduction. - Leave it this way until we've solidified our requirements. - {% endcomment %} - {% if DomainRequest.status == statuses.STARTED %} - {% with first_started_date=DomainRequest.get_first_status_started_date|date:"F j, Y" %} -

    - {% comment %} - A newly created domain request will not have a value for last_status update. - This is because the status never really updated. - However, if this somehow goes back to started we can default to displaying that new date. - {% endcomment %} - Started on: {{last_status_update|default:first_started_date}} -

    - {% endwith %} - {% elif DomainRequest.status == statuses.SUBMITTED %} -

    - Submitted on: {{last_submitted|default:first_submitted }} -

    -

    - Last updated on: {{DomainRequest.updated_at|date:"F j, Y"}} -

    - {% elif DomainRequest.status == statuses.ACTION_NEEDED %} -

    - Submitted on: {{last_submitted|default:first_submitted }} -

    -

    - Last updated on: {{DomainRequest.updated_at|date:"F j, Y"}} -

    - {% elif DomainRequest.status == statuses.REJECTED %} -

    - Submitted on: {{last_submitted|default:first_submitted }} -

    -

    - Rejected on: {{last_status_update}} -

    - {% elif DomainRequest.status == statuses.WITHDRAWN %} -

    - Submitted on: {{last_submitted|default:first_submitted }} -

    -

    - Withdrawn on: {{last_status_update}} -

    - {% else %} - {% comment %} Shown for in_review, approved, ineligible {% endcomment %} -

    - Last updated on: {{DomainRequest.updated_at|date:"F j, Y"}} -

    - {% endif %} - {% endwith %} - {% endblock status_metadata %} - - {% block status_blurb %} - {% if DomainRequest.is_awaiting_review %} -

    {% include "includes/domain_request_awaiting_review.html" with show_withdraw_text=DomainRequest.is_withdrawable %}

    - {% endif %} - {% endblock status_blurb %} - - {% block modify_request %} - {% if DomainRequest.is_withdrawable %} -

    - Withdraw request -

    - {% endif %} - {% endblock modify_request %} -
    - -
    - {% block request_summary_header %} -

    Summary of your domain request

    - {% endblock request_summary_header%} - - {% block request_summary %} - {% with heading_level='h3' %} - {% with org_type=DomainRequest.get_generic_org_type_display %} - {% include "includes/summary_item.html" with title='Type of organization' value=org_type heading_level=heading_level %} - {% endwith %} - - {% if DomainRequest.tribe_name %} - {% include "includes/summary_item.html" with title='Tribal government' value=DomainRequest.tribe_name heading_level=heading_level %} - - {% if DomainRequest.federally_recognized_tribe %} -

    Federally-recognized tribe

    - {% endif %} - - {% if DomainRequest.state_recognized_tribe %} -

    State-recognized tribe

    - {% endif %} - - {% endif %} - - {% if DomainRequest.get_federal_type_display %} - {% include "includes/summary_item.html" with title='Federal government branch' value=DomainRequest.get_federal_type_display heading_level=heading_level %} - {% endif %} - - {% if DomainRequest.is_election_board %} - {% with value=DomainRequest.is_election_board|yesno:"Yes,No,Incomplete" %} - {% include "includes/summary_item.html" with title='Election office' value=value heading_level=heading_level %} - {% endwith %} - {% endif %} - - {% if DomainRequest.organization_name %} - {% include "includes/summary_item.html" with title='Organization' value=DomainRequest address='true' heading_level=heading_level %} - {% endif %} - - {% if DomainRequest.about_your_organization %} - {% include "includes/summary_item.html" with title='About your organization' value=DomainRequest.about_your_organization heading_level=heading_level %} - {% endif %} - - {% if DomainRequest.senior_official %} - {% include "includes/summary_item.html" with title='Senior official' value=DomainRequest.senior_official contact='true' heading_level=heading_level %} - {% endif %} - - {% if DomainRequest.current_websites.all %} - {% include "includes/summary_item.html" with title='Current websites' value=DomainRequest.current_websites.all list='true' heading_level=heading_level %} - {% endif %} - - {% if DomainRequest.requested_domain %} - {% include "includes/summary_item.html" with title='.gov domain' value=DomainRequest.requested_domain heading_level=heading_level %} - {% endif %} - - {% if DomainRequest.alternative_domains.all %} - {% include "includes/summary_item.html" with title='Alternative domains' value=DomainRequest.alternative_domains.all list='true' heading_level=heading_level %} - {% endif %} - - {% if DomainRequest.purpose %} - {% include "includes/summary_item.html" with title='Purpose of your domain' value=DomainRequest.purpose heading_level=heading_level %} - {% endif %} - - {% if DomainRequest.creator %} - {% include "includes/summary_item.html" with title='Your contact information' value=DomainRequest.creator contact='true' heading_level=heading_level %} - {% endif %} - - {% if DomainRequest.other_contacts.all %} - {% include "includes/summary_item.html" with title='Other employees from your organization' value=DomainRequest.other_contacts.all contact='true' list='true' heading_level=heading_level %} - {% else %} - {% include "includes/summary_item.html" with title='Other employees from your organization' value=DomainRequest.no_other_contacts_rationale heading_level=heading_level %} - {% endif %} - - {# We always show this field even if None #} - {% if DomainRequest %} -

    CISA Regional Representative

    -
      - {% if DomainRequest.cisa_representative_first_name %} - {{ DomainRequest.get_formatted_cisa_rep_name }} - {% else %} - No - {% endif %} -
    -

    Anything else

    -
      - {% if DomainRequest.anything_else %} - {{DomainRequest.anything_else}} - {% else %} - No - {% endif %} -
    - {% endif %} - {% endwith %} - {% endblock request_summary%} -
    -
    \ No newline at end of file diff --git a/src/registrar/templates/includes/domain_requests_table.html b/src/registrar/templates/includes/domain_requests_table.html index 56cdc2cec..c48e2c9a6 100644 --- a/src/registrar/templates/includes/domain_requests_table.html +++ b/src/registrar/templates/includes/domain_requests_table.html @@ -4,8 +4,8 @@ {% url 'get_domain_requests_json' as url %} -
    -
    +
    +
    {% if not portfolio %}

    Domain requests

    {% else %} @@ -13,7 +13,7 @@ {% endif %} -

-
+
\ No newline at end of file diff --git a/src/registrar/templates/includes/required_fields.html b/src/registrar/templates/includes/required_fields.html index be0395979..a9d860f24 100644 --- a/src/registrar/templates/includes/required_fields.html +++ b/src/registrar/templates/includes/required_fields.html @@ -1,3 +1,3 @@ -

+

Required fields are marked with an asterisk (*).

diff --git a/src/registrar/templates/includes/senior_official.html b/src/registrar/templates/includes/senior_official.html index 19b443fcd..52b4ab6eb 100644 --- a/src/registrar/templates/includes/senior_official.html +++ b/src/registrar/templates/includes/senior_official.html @@ -1,5 +1,9 @@ {% load static field_helpers url_helpers %} + +{% block messages %} +{% include "includes/form_messages.html" %} +{% endblock messages%} {% if can_edit %} {% include "includes/form_errors.html" with form=form %} {% endif %} diff --git a/src/registrar/templates/includes/summary_item.html b/src/registrar/templates/includes/summary_item.html index facc8956a..df55c2edb 100644 --- a/src/registrar/templates/includes/summary_item.html +++ b/src/registrar/templates/includes/summary_item.html @@ -22,7 +22,7 @@

{% endif %} {% if sub_header_text %} -

{{ sub_header_text }}

+

{{ sub_header_text }}

{% endif %} {% if permissions %} {% include "includes/member_permissions.html" with permissions=value %} diff --git a/src/registrar/templates/portfolio_base.html b/src/registrar/templates/portfolio_base.html index c2a60b7ba..9f43c7251 100644 --- a/src/registrar/templates/portfolio_base.html +++ b/src/registrar/templates/portfolio_base.html @@ -4,26 +4,26 @@
{% block content %} -
+
{% if user.is_authenticated %} {# the entire logged in page goes here #} -
- {% block messages %} - {% include "includes/form_messages.html" %} - {% endblock %} +
+
{% block portfolio_content %}{% endblock %} -
- {% else %} {# not user.is_authenticated #} - {# the entire logged out page goes here #} - -

- Sign in -

- - {% endif %} +
+ {% else %} {# not user.is_authenticated #} + {# the entire logged out page goes here #} + +

+ Sign in +

+ + {% endif %} +
+
{% endblock content%} diff --git a/src/registrar/templates/portfolio_domains.html b/src/registrar/templates/portfolio_domains.html index 4fd99ce8e..55d807e78 100644 --- a/src/registrar/templates/portfolio_domains.html +++ b/src/registrar/templates/portfolio_domains.html @@ -9,9 +9,11 @@ {% endblock %} {% block portfolio_content %} + + {% block messages %} {% include "includes/form_messages.html" %} -{% endblock %} +{% endblock messages%}

Domains

diff --git a/src/registrar/templates/portfolio_member.html b/src/registrar/templates/portfolio_member.html index f492dbd2f..3fa4fed14 100644 --- a/src/registrar/templates/portfolio_member.html +++ b/src/registrar/templates/portfolio_member.html @@ -8,7 +8,12 @@ Organization member {% load static %} {% block portfolio_content %} -
+
+ + {% include "includes/form_errors.html" with form=form %} + {% block messages %} + {% include "includes/form_messages.html" %} + {% endblock messages%} {% url 'members' as url %}

- {% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %} + {% with group_classes="usa-form-editable usa-form-editable--no-border bg-gray-1 padding-top-0" %} {% input_with_errors form.member_permission_admin %} {% endwith %}
- -
-

Basic member permissions

-

Member permissions available for basic-level acccess.

+ +
+

Basic member permissions

+

Member permissions available for basic-level acccess.

Organization domain requests

- {% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %} + {% with group_classes="usa-form-editable usa-form-editable--no-border bg-gray-1 padding-top-0" %} {% input_with_errors form.domain_request_permission_member %} {% endwith %}
- -
- - Cancel - - -
- + +
+ + Cancel + + +
+ +
{% endblock portfolio_content%} diff --git a/src/registrar/templates/portfolio_members.html b/src/registrar/templates/portfolio_members.html index 332d1c16c..720d60e59 100644 --- a/src/registrar/templates/portfolio_members.html +++ b/src/registrar/templates/portfolio_members.html @@ -10,6 +10,11 @@ {% block portfolio_content %} + +{% block messages %} + {% include "includes/form_messages.html" %} +{% endblock messages%} +
diff --git a/src/registrar/templates/portfolio_no_domains.html b/src/registrar/templates/portfolio_no_domains.html index 995f391a2..d0abd9e9a 100644 --- a/src/registrar/templates/portfolio_no_domains.html +++ b/src/registrar/templates/portfolio_no_domains.html @@ -4,8 +4,13 @@ {% block title %} Domains | {% endblock %} + +{% block messages %} + {% include "includes/form_messages.html" %} +{% endblock messages%} + {% block portfolio_content %} -
+

Domains

diff --git a/src/registrar/templates/portfolio_no_requests.html b/src/registrar/templates/portfolio_no_requests.html index a51a034a8..5d443d6f7 100644 --- a/src/registrar/templates/portfolio_no_requests.html +++ b/src/registrar/templates/portfolio_no_requests.html @@ -4,7 +4,13 @@ {% block title %} Domain Requests | {% endblock %} + +{% block messages %} + {% include "includes/form_messages.html" %} +{% endblock messages%} + {% block portfolio_content %} +

Domain requests

@@ -27,4 +33,5 @@ {% endif %}
+
{% endblock %} diff --git a/src/registrar/templates/portfolio_organization.html b/src/registrar/templates/portfolio_organization.html index ce32555a5..55064d902 100644 --- a/src/registrar/templates/portfolio_organization.html +++ b/src/registrar/templates/portfolio_organization.html @@ -18,12 +18,11 @@
- - {% block messages %} - {% include "includes/form_messages.html" %} - {% endblock %} - + {% include "includes/form_errors.html" with form=form %} + {% block messages %} + {% include "includes/form_messages.html" %} + {% endblock messages%}

Organization

diff --git a/src/registrar/templates/portfolio_requests.html b/src/registrar/templates/portfolio_requests.html index 467141077..58fbde10c 100644 --- a/src/registrar/templates/portfolio_requests.html +++ b/src/registrar/templates/portfolio_requests.html @@ -9,9 +9,11 @@ {% endblock %} {% block portfolio_content %} + + {% block messages %} {% include "includes/form_messages.html" %} -{% endblock %} +{% endblock messages%}

Domain requests

@@ -19,7 +21,7 @@ {% if has_edit_request_portfolio_permission %}
-

Domain requests can only be modified by the person who created the request.

+

Domain requests can only be modified by the person who created the request.

diff --git a/src/registrar/templates/portfolio_senior_official.html b/src/registrar/templates/portfolio_senior_official.html index 631db0206..d521c015b 100644 --- a/src/registrar/templates/portfolio_senior_official.html +++ b/src/registrar/templates/portfolio_senior_official.html @@ -6,9 +6,6 @@ {% load static %} {% block portfolio_content %} -{% block messages %} - {% include "includes/form_messages.html" %} -{% endblock %}
diff --git a/src/registrar/templates/profile.html b/src/registrar/templates/profile.html index 7d365d9c1..1d91a2597 100644 --- a/src/registrar/templates/profile.html +++ b/src/registrar/templates/profile.html @@ -11,90 +11,92 @@ Edit your User Profile | {% endblock %} {% block content %} -
-
- {% if messages %} - {% for message in messages %} -
-
- {{ message }} -
-
- {% endfor %} - {% endif %} - {% include "includes/form_errors.html" with form=form %} +
+
+
+ {% if messages %} + {% for message in messages %} +
+
+ {{ message }} +
+
+ {% endfor %} + {% endif %} + {% include "includes/form_errors.html" with form=form %} - {% if show_back_button %} - - -

- {{ profile_back_button_text }} -

-
- {% endif %} - - {% if show_confirmation_modal %} - -
-
-
-

- Add contact information -

-
-

- .Gov domain registrants must maintain accurate contact information in the .gov registrar. - Before you can manage your domain, we need you to add your contact information. -

-
-

+
+

+ .Gov domain registrants must maintain accurate contact information in the .gov registrar. + Before you can manage your domain, we need you to add your contact information. +

+
+ +
+
- - + {% endif %} + + + + {% endblock content %} + + {% block content_bottom %} + {% include "includes/profile_form.html" with form=form %} - {% endif %} - - - -{% endblock content %} - -{% block content_bottom %} - {% include "includes/profile_form.html" with form=form %} - +
{% endblock content_bottom %} diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index af4345a14..8eca0108e 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -1034,6 +1034,10 @@ def completed_domain_request( # noqa action_needed_reason=None, portfolio=None, organization_name=None, + sub_organization=None, + requested_suborganization=None, + suborganization_city=None, + suborganization_state_territory=None, ): """A completed domain request.""" if not user: @@ -1098,6 +1102,18 @@ def completed_domain_request( # noqa if portfolio: domain_request_kwargs["portfolio"] = portfolio + if sub_organization: + domain_request_kwargs["sub_organization"] = sub_organization + + if requested_suborganization: + domain_request_kwargs["requested_suborganization"] = requested_suborganization + + if suborganization_city: + domain_request_kwargs["suborganization_city"] = suborganization_city + + if suborganization_state_territory: + domain_request_kwargs["suborganization_state_territory"] = suborganization_state_territory + domain_request, _ = DomainRequest.objects.get_or_create(**domain_request_kwargs) if has_other_contacts: diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index a259e5bef..dceb3a79e 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -2,6 +2,8 @@ from datetime import datetime from django.utils import timezone from django.test import TestCase, RequestFactory, Client from django.contrib.admin.sites import AdminSite +from registrar.utility.email import EmailSendingError +from registrar.utility.errors import MissingEmailError from waffle.testutils import override_flag from django_webtest import WebTest # type: ignore from api.tests.common import less_console_noise_decorator @@ -277,6 +279,29 @@ class TestUserPortfolioPermissionAdmin(TestCase): # Should return the forbidden permissions for member role self.assertEqual(member_only_permissions, set(member_forbidden)) + @less_console_noise_decorator + def test_has_change_form_description(self): + """Tests if this model has a model description on the change form view""" + self.client.force_login(self.superuser) + + user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( + user=self.superuser, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + + response = self.client.get( + "/admin/registrar/userportfoliopermission/{}/change/".format(user_portfolio_permission.pk), + follow=True, + ) + + # Make sure that the page is loaded correctly + self.assertEqual(response.status_code, 200) + + # Test for a description snippet + self.assertContains( + response, + "If you add someone to a portfolio here, it will not trigger an invitation email.", + ) + class TestPortfolioInvitationAdmin(TestCase): """Tests for the PortfolioInvitationAdmin class as super user @@ -432,6 +457,30 @@ class TestPortfolioInvitationAdmin(TestCase): ) self.assertContains(response, "Show more") + @less_console_noise_decorator + def test_has_change_form_description(self): + """Tests if this model has a model description on the change form view""" + self.client.force_login(self.superuser) + + invitation, _ = PortfolioInvitation.objects.get_or_create( + email=self.superuser.email, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + + response = self.client.get( + "/admin/registrar/portfolioinvitation/{}/change/".format(invitation.pk), + follow=True, + ) + + # Make sure that the page is loaded correctly + self.assertEqual(response.status_code, 200) + + # Test for a description snippet + self.assertContains( + response, + "If you add someone to a portfolio here, it will trigger an invitation email when you click", + ) + + @less_console_noise_decorator def test_get_filters(self): """Ensures that our filters are displaying correctly""" with less_console_noise(): @@ -456,6 +505,176 @@ class TestPortfolioInvitationAdmin(TestCase): self.assertContains(response, invited_html, count=1) self.assertContains(response, retrieved_html, count=1) + @less_console_noise_decorator + @patch("registrar.admin.send_portfolio_invitation_email") + @patch("django.contrib.messages.success") # Mock the `messages.warning` call + def test_save_sends_email(self, mock_messages_warning, mock_send_email): + """On save_model, an email is NOT sent if an invitation already exists.""" + self.client.force_login(self.superuser) + + # Create an instance of the admin class + admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=None) + + # Create a PortfolioInvitation instance + portfolio_invitation = PortfolioInvitation( + email="james.gordon@gotham.gov", + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + ) + + # Create a request object + request = self.factory.post("/admin/registrar/PortfolioInvitation/add/") + request.user = self.superuser + + # Call the save_model method + admin_instance.save_model(request, portfolio_invitation, None, None) + + # Assert that send_portfolio_invitation_email is not called + mock_send_email.assert_called() + + # Get the arguments passed to send_portfolio_invitation_email + _, called_kwargs = mock_send_email.call_args + + # Assert the email content + self.assertEqual(called_kwargs["email"], "james.gordon@gotham.gov") + self.assertEqual(called_kwargs["requestor"], self.superuser) + self.assertEqual(called_kwargs["portfolio"], self.portfolio) + + # Assert that a warning message was triggered + mock_messages_warning.assert_called_once_with(request, "james.gordon@gotham.gov has been invited.") + + @less_console_noise_decorator + @patch("registrar.admin.send_portfolio_invitation_email") + @patch("django.contrib.messages.warning") # Mock the `messages.warning` call + def test_save_does_not_send_email_if_requested_user_exists(self, mock_messages_warning, mock_send_email): + """On save_model, an email is NOT sent if an the requested email belongs to an existing user. + It also throws a warning.""" + self.client.force_login(self.superuser) + + # Create an instance of the admin class + admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=None) + + # Mock the UserPortfolioPermission query to simulate the invitation already existing + existing_user = create_user() + UserPortfolioPermission.objects.create(user=existing_user, portfolio=self.portfolio) + + # Create a PortfolioInvitation instance + portfolio_invitation = PortfolioInvitation( + email=existing_user.email, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + ) + + # Create a request object + request = self.factory.post("/admin/registrar/PortfolioInvitation/add/") + request.user = self.superuser + + # Call the save_model method + admin_instance.save_model(request, portfolio_invitation, None, None) + + # Assert that send_portfolio_invitation_email is not called + mock_send_email.assert_not_called() + + # Assert that a warning message was triggered + mock_messages_warning.assert_called_once_with(request, "User is already a member of this portfolio.") + + @less_console_noise_decorator + @patch("registrar.admin.send_portfolio_invitation_email") + @patch("django.contrib.messages.error") # Mock the `messages.error` call + def test_save_exception_email_sending_error(self, mock_messages_error, mock_send_email): + """Handle EmailSendingError correctly when sending the portfolio invitation fails.""" + self.client.force_login(self.superuser) + + # Mock the email sending function to raise EmailSendingError + mock_send_email.side_effect = EmailSendingError("Email service unavailable") + + # Create an instance of the admin class + admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=None) + + # Create a PortfolioInvitation instance + portfolio_invitation = PortfolioInvitation( + email="james.gordon@gotham.gov", + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + ) + + # Create a request object + request = self.factory.post("/admin/registrar/PortfolioInvitation/add/") + request.user = self.superuser + + # Call the save_model method + admin_instance.save_model(request, portfolio_invitation, None, None) + + # Assert that messages.error was called with the correct message + mock_messages_error.assert_called_once_with( + request, "Could not send email invitation. Portfolio invitation not saved." + ) + + @less_console_noise_decorator + @patch("registrar.admin.send_portfolio_invitation_email") + @patch("django.contrib.messages.error") # Mock the `messages.error` call + def test_save_exception_missing_email_error(self, mock_messages_error, mock_send_email): + """Handle MissingEmailError correctly when no email exists for the requestor.""" + self.client.force_login(self.superuser) + + # Mock the email sending function to raise MissingEmailError + mock_send_email.side_effect = MissingEmailError() + + # Create an instance of the admin class + admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=None) + + # Create a PortfolioInvitation instance + portfolio_invitation = PortfolioInvitation( + email="james.gordon@gotham.gov", + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + ) + + # Create a request object + request = self.factory.post("/admin/registrar/PortfolioInvitation/add/") + request.user = self.superuser + + # Call the save_model method + admin_instance.save_model(request, portfolio_invitation, None, None) + + # Assert that messages.error was called with the correct message + mock_messages_error.assert_called_once_with( + request, + "Can't send invitation email. No email is associated with your user account.", + ) + + @less_console_noise_decorator + @patch("registrar.admin.send_portfolio_invitation_email") + @patch("django.contrib.messages.error") # Mock the `messages.error` call + def test_save_exception_generic_error(self, mock_messages_error, mock_send_email): + """Handle generic exceptions correctly during portfolio invitation.""" + self.client.force_login(self.superuser) + + # Mock the email sending function to raise a generic exception + mock_send_email.side_effect = Exception("Unexpected error") + + # Create an instance of the admin class + admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=None) + + # Create a PortfolioInvitation instance + portfolio_invitation = PortfolioInvitation( + email="james.gordon@gotham.gov", + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + ) + + # Create a request object + request = self.factory.post("/admin/registrar/PortfolioInvitation/add/") + request.user = self.superuser + + # Call the save_model method + admin_instance.save_model(request, portfolio_invitation, None, None) + + # Assert that messages.error was called with the correct message + mock_messages_error.assert_called_once_with( + request, "Could not send email invitation. Portfolio invitation not saved." + ) + class TestHostAdmin(TestCase): """Tests for the HostAdmin class as super user diff --git a/src/registrar/tests/test_admin_request.py b/src/registrar/tests/test_admin_request.py index 439f4fab0..da789a1b5 100644 --- a/src/registrar/tests/test_admin_request.py +++ b/src/registrar/tests/test_admin_request.py @@ -1,5 +1,7 @@ from datetime import datetime +from django.forms import ValidationError from django.utils import timezone +from waffle.testutils import override_flag import re from django.test import RequestFactory, Client, TestCase, override_settings from django.contrib.admin.sites import AdminSite @@ -24,7 +26,10 @@ from registrar.models import ( SeniorOfficial, Portfolio, AllowedEmail, + Suborganization, ) +from registrar.models.host import Host +from registrar.models.public_contact import PublicContact from .common import ( MockSESClient, completed_domain_request, @@ -36,7 +41,7 @@ from .common import ( MockEppLib, GenericTestHelper, ) -from unittest.mock import patch +from unittest.mock import ANY, patch from django.conf import settings import boto3_mocking # type: ignore @@ -76,12 +81,15 @@ class TestDomainRequestAdmin(MockEppLib): def tearDown(self): super().tearDown() + Host.objects.all().delete() + PublicContact.objects.all().delete() Domain.objects.all().delete() DomainInformation.objects.all().delete() DomainRequest.objects.all().delete() Contact.objects.all().delete() Website.objects.all().delete() SeniorOfficial.objects.all().delete() + Suborganization.objects.all().delete() Portfolio.objects.all().delete() self.mock_client.EMAILS_SENT.clear() @@ -91,6 +99,83 @@ class TestDomainRequestAdmin(MockEppLib): User.objects.all().delete() AllowedEmail.objects.all().delete() + @override_flag("organization_feature", active=True) + @less_console_noise_decorator + def test_clean_validates_duplicate_suborganization(self): + """Tests that clean() prevents duplicate suborganization names within the same portfolio""" + # Create a portfolio and existing suborganization + portfolio = Portfolio.objects.create(organization_name="Test Portfolio", creator=self.superuser) + + # Create an existing suborganization + Suborganization.objects.create(name="Existing Suborg", portfolio=portfolio) + + # Create a domain request trying to use the same suborganization name + # (intentionally lowercase) + domain_request = completed_domain_request( + name="test1234.gov", + portfolio=portfolio, + requested_suborganization="existing suborg", + suborganization_city="Rome", + suborganization_state_territory=DomainRequest.StateTerritoryChoices.OHIO, + ) + + # Assert that the validation error is raised + with self.assertRaises(ValidationError) as err: + domain_request.clean() + + self.assertIn("This suborganization already exists", str(err.exception)) + + # Test that a different name is allowed. Should not raise a error. + domain_request.requested_suborganization = "New Suborg" + domain_request.clean() + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + def test_clean_validates_partial_suborganization_fields(self): + """Tests that clean() enforces all-or-nothing rule for suborganization fields""" + portfolio = Portfolio.objects.create(organization_name="Test Portfolio", creator=self.superuser) + + # Create domain request with only city filled out + domain_request = completed_domain_request( + name="test1234.gov", + portfolio=portfolio, + suborganization_city="Test City", + ) + + # Assert validation error is raised with correct missing fields + with self.assertRaises(ValidationError) as err: + domain_request.clean() + + error_dict = err.exception.error_dict + expected_missing = ["requested_suborganization", "suborganization_state_territory"] + + # Verify correct fields are flagged as required + self.assertEqual(sorted(error_dict.keys()), sorted(expected_missing)) + + # Verify error message + for field in expected_missing: + self.assertEqual( + str(error_dict[field][0].message), "This field is required when creating a new suborganization." + ) + + # When all data is passed in, this should validate correctly + domain_request.requested_suborganization = "Complete Suborg" + domain_request.suborganization_state_territory = DomainRequest.StateTerritoryChoices.OHIO + # Assert that no ValidationError is raised + try: + domain_request.clean() + except ValidationError as e: + self.fail(f"ValidationError was raised unexpectedly: {e}") + + # Also ensure that no validation error is raised if nothing is passed in at all + domain_request.suborganization_city = None + domain_request.requested_suborganization = None + domain_request.suborganization_state_territory = None + try: + domain_request.clean() + except ValidationError as e: + self.fail(f"ValidationError was raised unexpectedly: {e}") + @less_console_noise_decorator def test_domain_request_senior_official_is_alphabetically_sorted(self): """Tests if the senior offical dropdown is alphanetically sorted in the django admin display""" @@ -1808,6 +1893,37 @@ class TestDomainRequestAdmin(MockEppLib): "Cannot edit a domain request with a restricted creator.", ) + @less_console_noise_decorator + def test_approved_domain_request_with_ready_domain_has_warning_message(self): + """Tests if the domain request has a warning message when the approved domain is in Ready state""" + # Create an instance of the model + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + # Approve the domain request + domain_request.approve() + domain_request.save() + + # Add nameservers to get to Ready state + domain_request.approved_domain.nameservers = [ + ("ns1.city.gov", ["1.1.1.1"]), + ("ns2.city.gov", ["1.1.1.2"]), + ] + domain_request.approved_domain.save() + + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + with patch("django.contrib.messages.warning") as mock_warning: + # Create a request object + self.client.force_login(self.superuser) + self.client.get( + "/admin/registrar/domainrequest/{}/change/".format(domain_request.pk), + follow=True, + ) + + # Assert that the error message was called with the correct argument + mock_warning.assert_called_once_with( + ANY, # don't care about the request argument + f"The status of this domain request cannot be changed because it has been joined to a domain in Ready status: {domain_request.approved_domain.name}", # noqa + ) + def trigger_saving_approved_to_another_state(self, domain_is_active, another_state, rejection_reason=None): """Helper method that triggers domain request state changes from approved to another state, with an associated domain that can be either active (READY) or not. diff --git a/src/registrar/tests/test_forms.py b/src/registrar/tests/test_forms.py index 12d9af8ac..a2960deac 100644 --- a/src/registrar/tests/test_forms.py +++ b/src/registrar/tests/test_forms.py @@ -18,7 +18,17 @@ from registrar.forms.domain_request_wizard import ( AboutYourOrganizationForm, ) from registrar.forms.domain import ContactForm -from registrar.tests.common import MockEppLib +from registrar.forms.portfolio import ( + PortfolioInvitedMemberForm, + PortfolioMemberForm, + PortfolioNewMemberForm, +) +from registrar.models.portfolio import Portfolio +from registrar.models.portfolio_invitation import PortfolioInvitation +from registrar.models.user import User +from registrar.models.user_portfolio_permission import UserPortfolioPermission +from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices +from registrar.tests.common import MockEppLib, create_user from django.contrib.auth import get_user_model @@ -408,3 +418,196 @@ class TestContactForm(TestCase): def test_contact_form_email_invalid2(self): form = ContactForm(data={"email": "@"}) self.assertEqual(form.errors["email"], ["Enter a valid email address."]) + + +class TestBasePortfolioMemberForms(TestCase): + """We test on the child forms instead of BasePortfolioMemberForm because the base form + is a model form with no model bound.""" + + def setUp(self): + super().setUp() + self.user = create_user() + self.portfolio, _ = Portfolio.objects.get_or_create( + creator_id=self.user.id, organization_name="Hotel California" + ) + + def tearDown(self): + super().tearDown() + Portfolio.objects.all().delete() + UserPortfolioPermission.objects.all().delete() + PortfolioInvitation.objects.all().delete() + User.objects.all().delete() + + def _assert_form_is_valid(self, form_class, data, instance=None): + if instance is not None: + form = form_class(data=data, instance=instance) + else: + print("no instance") + form = form_class(data=data) + self.assertTrue(form.is_valid(), f"Form {form_class.__name__} failed validation with data: {data}") + return form + + def _assert_form_has_error(self, form_class, data, field_name): + form = form_class(data=data) + self.assertFalse(form.is_valid()) + self.assertIn(field_name, form.errors) + + def _assert_initial_data(self, form_class, instance, expected_initial_data): + """Helper to check if the instance data is correctly mapped to the initial form values.""" + form = form_class(instance=instance) + for field, expected_value in expected_initial_data.items(): + self.assertEqual(form.initial[field], expected_value) + + def _assert_permission_mapping(self, form_class, data, expected_permissions): + """Helper to check if permissions are correctly handled and mapped.""" + form = self._assert_form_is_valid(form_class, data) + cleaned_data = form.cleaned_data + for permission in expected_permissions: + self.assertIn(permission, cleaned_data["additional_permissions"]) + + def test_required_field_for_admin(self): + """Test that required fields are validated for an admin role.""" + data = { + "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value, + "domain_request_permission_admin": "", # Simulate missing field + "member_permission_admin": "", # Simulate missing field + } + + # Check required fields for all forms + self._assert_form_has_error(PortfolioMemberForm, data, "domain_request_permission_admin") + self._assert_form_has_error(PortfolioMemberForm, data, "member_permission_admin") + + self._assert_form_has_error(PortfolioInvitedMemberForm, data, "domain_request_permission_admin") + self._assert_form_has_error(PortfolioInvitedMemberForm, data, "member_permission_admin") + + self._assert_form_has_error(PortfolioNewMemberForm, data, "domain_request_permission_admin") + self._assert_form_has_error(PortfolioNewMemberForm, data, "member_permission_admin") + + def test_required_field_for_member(self): + """Test that required fields are validated for a member role.""" + data = { + "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, + "domain_request_permission_member": "", # Simulate missing field + } + + # Check required fields for all forms + self._assert_form_has_error(PortfolioMemberForm, data, "domain_request_permission_member") + self._assert_form_has_error(PortfolioInvitedMemberForm, data, "domain_request_permission_member") + self._assert_form_has_error(PortfolioNewMemberForm, data, "domain_request_permission_member") + + def test_clean_validates_required_fields_for_role(self): + """Test that the `clean` method validates the correct fields for each role. + + For PortfolioMemberForm and PortfolioInvitedMemberForm, we pass an object as the instance to the form. + For UserPortfolioPermissionChoices, we add a portfolio and an email to the POST data. + + These things are handled in the views.""" + + user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( + portfolio=self.portfolio, user=self.user + ) + portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(portfolio=self.portfolio, email="hi@ho") + + data = { + "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value, + "domain_request_permission_admin": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, + "member_permission_admin": UserPortfolioPermissionChoices.EDIT_MEMBERS.value, + } + + # Check form validity for all forms + form = self._assert_form_is_valid(PortfolioMemberForm, data, user_portfolio_permission) + cleaned_data = form.cleaned_data + self.assertEqual(cleaned_data["roles"], [UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value]) + self.assertEqual(cleaned_data["additional_permissions"], [UserPortfolioPermissionChoices.EDIT_MEMBERS]) + + form = self._assert_form_is_valid(PortfolioInvitedMemberForm, data, portfolio_invitation) + cleaned_data = form.cleaned_data + self.assertEqual(cleaned_data["roles"], [UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value]) + self.assertEqual(cleaned_data["additional_permissions"], [UserPortfolioPermissionChoices.EDIT_MEMBERS]) + + data = { + "email": "hi@ho.com", + "portfolio": self.portfolio.id, + "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value, + "domain_request_permission_admin": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, + "member_permission_admin": UserPortfolioPermissionChoices.EDIT_MEMBERS.value, + } + + form = self._assert_form_is_valid(PortfolioNewMemberForm, data) + cleaned_data = form.cleaned_data + self.assertEqual(cleaned_data["roles"], [UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value]) + self.assertEqual(cleaned_data["additional_permissions"], [UserPortfolioPermissionChoices.EDIT_MEMBERS]) + + def test_clean_member_permission_edgecase(self): + """Test that the clean method correctly handles the special "no_access" value for members. + We'll need to add a portfolio, which in the app is handled by the view post.""" + + user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( + portfolio=self.portfolio, user=self.user + ) + portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(portfolio=self.portfolio, email="hi@ho") + + data = { + "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, + "domain_request_permission_member": "no_access", # Simulate no access permission + } + + form = self._assert_form_is_valid(PortfolioMemberForm, data, user_portfolio_permission) + cleaned_data = form.cleaned_data + self.assertEqual(cleaned_data["domain_request_permission_member"], None) + + form = self._assert_form_is_valid(PortfolioInvitedMemberForm, data, portfolio_invitation) + cleaned_data = form.cleaned_data + self.assertEqual(cleaned_data["domain_request_permission_member"], None) + + def test_map_instance_to_initial_admin_role(self): + """Test that instance data is correctly mapped to the initial form values for an admin role.""" + user_portfolio_permission = UserPortfolioPermission( + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[UserPortfolioPermissionChoices.VIEW_MEMBERS], + ) + portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create( + portfolio=self.portfolio, + email="hi@ho", + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[UserPortfolioPermissionChoices.VIEW_MEMBERS], + ) + + expected_initial_data = { + "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN, + "domain_request_permission_admin": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, + "member_permission_admin": UserPortfolioPermissionChoices.VIEW_MEMBERS, + } + self._assert_initial_data(PortfolioMemberForm, user_portfolio_permission, expected_initial_data) + self._assert_initial_data(PortfolioInvitedMemberForm, portfolio_invitation, expected_initial_data) + + def test_map_instance_to_initial_member_role(self): + """Test that instance data is correctly mapped to the initial form values for a member role.""" + user_portfolio_permission = UserPortfolioPermission( + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS], + ) + portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create( + portfolio=self.portfolio, + email="hi@ho", + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS], + ) + expected_initial_data = { + "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER, + "domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, + } + self._assert_initial_data(PortfolioMemberForm, user_portfolio_permission, expected_initial_data) + self._assert_initial_data(PortfolioInvitedMemberForm, portfolio_invitation, expected_initial_data) + + def test_invalid_data_for_admin(self): + """Test invalid form submission for an admin role with missing permissions.""" + data = { + "email": "hi@ho.com", + "portfolio": self.portfolio.id, + "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value, + "domain_request_permission_admin": "", # Missing field + "member_permission_admin": "", # Missing field + } + self._assert_form_has_error(PortfolioMemberForm, data, "domain_request_permission_admin") + self._assert_form_has_error(PortfolioInvitedMemberForm, data, "member_permission_admin") diff --git a/src/registrar/tests/test_models_requests.py b/src/registrar/tests/test_models_requests.py index da474224c..983a12b3c 100644 --- a/src/registrar/tests/test_models_requests.py +++ b/src/registrar/tests/test_models_requests.py @@ -15,6 +15,7 @@ from registrar.models import ( FederalAgency, AllowedEmail, Portfolio, + Suborganization, ) import boto3_mocking @@ -23,6 +24,8 @@ from registrar.utility.errors import FSMDomainRequestError from .common import ( MockSESClient, + create_user, + create_superuser, less_console_noise, completed_domain_request, set_domain_request_investigators, @@ -1070,3 +1073,142 @@ class TestDomainRequest(TestCase): ) self.assertEqual(domain_request2.generic_org_type, domain_request2.converted_generic_org_type) self.assertEqual(domain_request2.federal_agency, domain_request2.converted_federal_agency) + + +class TestDomainRequestSuborganization(TestCase): + """Tests for the suborganization fields on domain requests""" + + def setUp(self): + super().setUp() + self.user = create_user() + self.superuser = create_superuser() + + def tearDown(self): + super().tearDown() + DomainInformation.objects.all().delete() + DomainRequest.objects.all().delete() + Domain.objects.all().delete() + Suborganization.objects.all().delete() + Portfolio.objects.all().delete() + + @less_console_noise_decorator + def test_approve_creates_requested_suborganization(self): + """Test that approving a domain request with a requested suborganization creates it""" + portfolio = Portfolio.objects.create(organization_name="Test Org", creator=self.user) + + domain_request = completed_domain_request( + name="test.gov", + portfolio=portfolio, + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + requested_suborganization="Boom", + suborganization_city="Explody town", + suborganization_state_territory=DomainRequest.StateTerritoryChoices.OHIO, + ) + domain_request.investigator = self.superuser + domain_request.save() + + domain_request.approve() + + created_suborg = Suborganization.objects.filter( + name="Boom", + city="Explody town", + state_territory=DomainRequest.StateTerritoryChoices.OHIO, + portfolio=portfolio, + ).first() + + self.assertIsNotNone(created_suborg) + self.assertEqual(domain_request.sub_organization, created_suborg) + + @less_console_noise_decorator + def test_approve_without_requested_suborganization_makes_no_changes(self): + """Test that approving without a requested suborganization doesn't create one""" + portfolio = Portfolio.objects.create(organization_name="Test Org", creator=self.user) + + domain_request = completed_domain_request( + name="test.gov", + portfolio=portfolio, + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + ) + domain_request.investigator = self.superuser + domain_request.save() + + initial_suborg_count = Suborganization.objects.count() + domain_request.approve() + + self.assertEqual(Suborganization.objects.count(), initial_suborg_count) + self.assertIsNone(domain_request.sub_organization) + + @less_console_noise_decorator + def test_approve_with_existing_suborganization_makes_no_changes(self): + """Test that approving with an existing suborganization doesn't create a new one""" + portfolio = Portfolio.objects.create(organization_name="Test Org", creator=self.user) + existing_suborg = Suborganization.objects.create(name="Existing Division", portfolio=portfolio) + + domain_request = completed_domain_request( + name="test.gov", + portfolio=portfolio, + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + sub_organization=existing_suborg, + ) + domain_request.investigator = self.superuser + domain_request.save() + + initial_suborg_count = Suborganization.objects.count() + domain_request.approve() + + self.assertEqual(Suborganization.objects.count(), initial_suborg_count) + self.assertEqual(domain_request.sub_organization, existing_suborg) + + @less_console_noise_decorator + def test_cleanup_dangling_suborg_with_single_reference(self): + """Test that a suborganization is deleted when it's only referenced once""" + portfolio = Portfolio.objects.create(organization_name="Test Org", creator=self.user) + suborg = Suborganization.objects.create(name="Test Division", portfolio=portfolio) + + domain_request = completed_domain_request( + name="test.gov", + portfolio=portfolio, + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + sub_organization=suborg, + ) + domain_request.approve() + + # set it back to in review + domain_request.in_review() + domain_request.refresh_from_db() + + # Verify the suborganization was deleted + self.assertFalse(Suborganization.objects.filter(id=suborg.id).exists()) + self.assertIsNone(domain_request.sub_organization) + + @less_console_noise_decorator + def test_cleanup_dangling_suborg_with_multiple_references(self): + """Test that a suborganization is preserved when it has multiple references""" + portfolio = Portfolio.objects.create(organization_name="Test Org", creator=self.user) + suborg = Suborganization.objects.create(name="Test Division", portfolio=portfolio) + + # Create two domain requests using the same suborganization + domain_request1 = completed_domain_request( + name="test1.gov", + portfolio=portfolio, + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + sub_organization=suborg, + ) + domain_request2 = completed_domain_request( + name="test2.gov", + portfolio=portfolio, + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + sub_organization=suborg, + ) + + domain_request1.approve() + domain_request2.approve() + + # set one back to in review + domain_request1.in_review() + domain_request1.refresh_from_db() + + # Verify the suborganization still exists + self.assertTrue(Suborganization.objects.filter(id=suborg.id).exists()) + self.assertEqual(domain_request1.sub_organization, suborg) + self.assertEqual(domain_request2.sub_organization, suborg) diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py index 995782eea..4a41238c7 100644 --- a/src/registrar/tests/test_reports.py +++ b/src/registrar/tests/test_reports.py @@ -16,7 +16,7 @@ from registrar.utility.csv_export import ( DomainDataType, DomainDataFederal, DomainDataTypeUser, - DomainRequestsDataType, + DomainRequestDataType, DomainGrowth, DomainManaged, DomainUnmanaged, @@ -456,11 +456,11 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): portfolio.delete() def _run_domain_request_data_type_user_export(self, request): - """Helper function to run the exporting_dr_data_to_csv function on DomainRequestsDataType""" + """Helper function to run the export_data_to_csv function on DomainRequestDataType""" csv_file = StringIO() - DomainRequestsDataType.exporting_dr_data_to_csv(csv_file, request=request) + DomainRequestDataType.export_data_to_csv(csv_file, request=request) csv_file.seek(0) @@ -773,9 +773,9 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # Content "city5.gov,Approved,Federal,Executive,,Testorg,N/A,,NY,2,,,,1,0,city1.gov,Testy,Tester,testy@town.com," "Chief Tester,Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n" - "city2.gov,In review,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,0,1,city1.gov,,,,," + "city2.gov,In review,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,NY,2,,,,0,1,city1.gov,,,,," "Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n" - "city3.gov,Submitted,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,0,1," + "city3.gov,Submitted,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,NY,2,,,,0,1," '"cheeseville.gov, city1.gov, igorville.gov",,,,,Purpose of the site,CISA-first-name CISA-last-name | ' 'There is more,"Meow Tester24 te2@town.com, Testy1232 Tester24 te2@town.com, ' 'Testy Tester testy2@town.com",' @@ -785,7 +785,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): "Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more," "Testy Tester testy2@town.com," "cisaRep@igorville.gov,city.com,\n" - "city6.gov,Submitted,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,0,1,city1.gov,,,,," + "city6.gov,Submitted,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,NY,2,,,,0,1,city1.gov,,,,," "Purpose of the site,CISA-first-name CISA-last-name | There is more,Testy Tester testy2@town.com," "cisaRep@igorville.gov,city.com,\n" ) @@ -794,6 +794,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # spaces and leading/trailing whitespace csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip() expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip() + self.maxDiff = None self.assertEqual(csv_content, expected_content) diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py index 744bf2894..25f79d7e0 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -4,6 +4,8 @@ from unittest.mock import MagicMock, ANY, patch from django.conf import settings from django.urls import reverse from django.contrib.auth import get_user_model +from registrar.models.portfolio_invitation import PortfolioInvitation +from registrar.utility.email import EmailSendingError from waffle.testutils import override_flag from api.tests.common import less_console_noise_decorator from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices @@ -681,6 +683,7 @@ class TestDomainManagers(TestDomainOverview): self.portfolio = Portfolio.objects.create(creator=self.user, organization_name="Ice Cream") # Add the portfolio to the domain_information object self.domain_information.portfolio = self.portfolio + self.domain_information.save() # Add portfolio perms to the user object self.portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( user=self.user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] @@ -693,6 +696,7 @@ class TestDomainManagers(TestDomainOverview): def tearDown(self): """Ensure that the user has its original permissions""" + PortfolioInvitation.objects.all().delete() super().tearDown() @less_console_noise_decorator @@ -725,7 +729,7 @@ class TestDomainManagers(TestDomainOverview): @less_console_noise_decorator def test_domain_user_add_form(self): """Adding an existing user works.""" - other_user, _ = get_user_model().objects.get_or_create(email="mayor@igorville.gov") + get_user_model().objects.get_or_create(email="mayor@igorville.gov") add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] @@ -748,6 +752,148 @@ class TestDomainManagers(TestDomainOverview): success_page = success_result.follow() self.assertContains(success_page, "mayor@igorville.gov") + @boto3_mocking.patching + @override_flag("organization_feature", active=True) + @less_console_noise_decorator + @patch("registrar.views.domain.send_portfolio_invitation_email") + @patch("registrar.views.domain.send_domain_invitation_email") + def test_domain_user_add_form_sends_portfolio_invitation(self, mock_send_domain_email, mock_send_portfolio_email): + """Adding an existing user works and sends portfolio invitation when + user is not member of portfolio.""" + get_user_model().objects.get_or_create(email="mayor@igorville.gov") + add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + + add_page.form["email"] = "mayor@igorville.gov" + + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + success_result = add_page.form.submit() + + self.assertEqual(success_result.status_code, 302) + self.assertEqual( + success_result["Location"], + reverse("domain-users", kwargs={"pk": self.domain.id}), + ) + + # Verify that the invitation emails were sent + mock_send_portfolio_email.assert_called_once_with( + email="mayor@igorville.gov", requestor=self.user, portfolio=self.portfolio + ) + mock_send_domain_email.assert_called_once() + call_args = mock_send_domain_email.call_args.kwargs + self.assertEqual(call_args["email"], "mayor@igorville.gov") + self.assertEqual(call_args["requestor"], self.user) + self.assertEqual(call_args["domain"], self.domain) + self.assertIsNone(call_args.get("is_member_of_different_org")) + + # Assert that the PortfolioInvitation is created + portfolio_invitation = PortfolioInvitation.objects.filter( + email="mayor@igorville.gov", portfolio=self.portfolio + ).first() + self.assertIsNotNone(portfolio_invitation, "Portfolio invitation should be created.") + self.assertEqual(portfolio_invitation.email, "mayor@igorville.gov") + self.assertEqual(portfolio_invitation.portfolio, self.portfolio) + + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + success_page = success_result.follow() + self.assertContains(success_page, "mayor@igorville.gov") + + @boto3_mocking.patching + @override_flag("organization_feature", active=True) + @less_console_noise_decorator + @patch("registrar.views.domain.send_portfolio_invitation_email") + @patch("registrar.views.domain.send_domain_invitation_email") + def test_domain_user_add_form_doesnt_send_portfolio_invitation_if_already_member( + self, mock_send_domain_email, mock_send_portfolio_email + ): + """Adding an existing user works and sends portfolio invitation when + user is not member of portfolio.""" + other_user, _ = get_user_model().objects.get_or_create(email="mayor@igorville.gov") + UserPortfolioPermission.objects.get_or_create( + user=other_user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + + add_page.form["email"] = "mayor@igorville.gov" + + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + success_result = add_page.form.submit() + + self.assertEqual(success_result.status_code, 302) + self.assertEqual( + success_result["Location"], + reverse("domain-users", kwargs={"pk": self.domain.id}), + ) + + # Verify that the invitation emails were sent + mock_send_portfolio_email.assert_not_called() + mock_send_domain_email.assert_called_once() + call_args = mock_send_domain_email.call_args.kwargs + self.assertEqual(call_args["email"], "mayor@igorville.gov") + self.assertEqual(call_args["requestor"], self.user) + self.assertEqual(call_args["domain"], self.domain) + self.assertIsNone(call_args.get("is_member_of_different_org")) + + # Assert that no PortfolioInvitation is created + portfolio_invitation_exists = PortfolioInvitation.objects.filter( + email="mayor@igorville.gov", portfolio=self.portfolio + ).exists() + self.assertFalse( + portfolio_invitation_exists, "Portfolio invitation should not be created when the user is already a member." + ) + + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + success_page = success_result.follow() + self.assertContains(success_page, "mayor@igorville.gov") + + @boto3_mocking.patching + @override_flag("organization_feature", active=True) + @less_console_noise_decorator + @patch("registrar.views.domain.send_portfolio_invitation_email") + @patch("registrar.views.domain.send_domain_invitation_email") + def test_domain_user_add_form_sends_portfolio_invitation_raises_email_sending_error( + self, mock_send_domain_email, mock_send_portfolio_email + ): + """Adding an existing user works and attempts to send portfolio invitation when + user is not member of portfolio and send raises an error.""" + mock_send_portfolio_email.side_effect = EmailSendingError("Failed to send email.") + get_user_model().objects.get_or_create(email="mayor@igorville.gov") + add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + + add_page.form["email"] = "mayor@igorville.gov" + + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + success_result = add_page.form.submit() + + self.assertEqual(success_result.status_code, 302) + self.assertEqual( + success_result["Location"], + reverse("domain-users", kwargs={"pk": self.domain.id}), + ) + + # Verify that the invitation emails were sent + mock_send_portfolio_email.assert_called_once_with( + email="mayor@igorville.gov", requestor=self.user, portfolio=self.portfolio + ) + mock_send_domain_email.assert_not_called() + + # Assert that no PortfolioInvitation is created + portfolio_invitation_exists = PortfolioInvitation.objects.filter( + email="mayor@igorville.gov", portfolio=self.portfolio + ).exists() + self.assertFalse( + portfolio_invitation_exists, "Portfolio invitation should not be created when email fails to send." + ) + + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + success_page = success_result.follow() + self.assertContains(success_page, "Could not send email invitation.") + @boto3_mocking.patching @less_console_noise_decorator def test_domain_invitation_created(self): @@ -960,39 +1106,20 @@ class TestDomainManagers(TestDomainOverview): self.assertNotIn("Last", email_content) self.assertNotIn("First Last", email_content) - @boto3_mocking.patching @less_console_noise_decorator - def test_domain_invitation_email_displays_error_non_existent(self): - """Inviting a non existent user sends them an email, with email as the name.""" - # make sure there is no user with this email - email_address = "mayor@igorville.gov" - User.objects.filter(email=email_address).delete() - - # Give the user who is sending the email an invalid email address - self.user.email = "" - self.user.save() - + def test_domain_invitation_email_validation_blocks_bad_email(self): + """Inviting a bad email blocks at validation.""" + email_address = "mayor" self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain) - mock_client = MagicMock() - mock_error_message = MagicMock() - with boto3_mocking.clients.handler_for("sesv2", mock_client): - with patch("django.contrib.messages.error") as mock_error_message: - add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) - session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] - add_page.form["email"] = email_address - self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - add_page.form.submit().follow() + add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + add_page.form["email"] = email_address + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + response = add_page.form.submit() - expected_message_content = "Can't send invitation email. No email is associated with your account." + self.assertContains(response, "Enter an email address in the required format, like name@example.com.") - # Grab the message content - returned_error_message = mock_error_message.call_args[0][1] - - # Check that the message content is what we expect - self.assertEqual(expected_message_content, returned_error_message) - - @boto3_mocking.patching @less_console_noise_decorator def test_domain_invitation_email_displays_error(self): """When the requesting user has no email, an error is displayed""" @@ -1003,28 +1130,25 @@ class TestDomainManagers(TestDomainOverview): # Give the user who is sending the email an invalid email address self.user.email = "" + self.user.is_staff = False self.user.save() self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain) - mock_client = MagicMock() + with patch("django.contrib.messages.error") as mock_error: + add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + add_page.form["email"] = email_address + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + add_page.form.submit() - mock_error_message = MagicMock() - with boto3_mocking.clients.handler_for("sesv2", mock_client): - with patch("django.contrib.messages.error") as mock_error_message: - add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) - session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] - add_page.form["email"] = email_address - self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - add_page.form.submit().follow() + expected_message_content = "Can't send invitation email. No email is associated with your user account." - expected_message_content = "Can't send invitation email. No email is associated with your account." - - # Grab the message content - returned_error_message = mock_error_message.call_args[0][1] - - # Check that the message content is what we expect - self.assertEqual(expected_message_content, returned_error_message) + # Assert that the error message was called with the correct argument + mock_error.assert_called_once_with( + ANY, + expected_message_content, + ) @less_console_noise_decorator def test_domain_invitation_cancel(self): diff --git a/src/registrar/tests/test_views_member_domains_json.py b/src/registrar/tests/test_views_member_domains_json.py index c9f1e38cc..091ad6151 100644 --- a/src/registrar/tests/test_views_member_domains_json.py +++ b/src/registrar/tests/test_views_member_domains_json.py @@ -94,6 +94,12 @@ class GetPortfolioMemberDomainsJsonTest(TestWithUser, WebTest): DomainInvitation.objects.create( email=cls.invited_member_email, domain=cls.domain2, status=DomainInvitation.DomainInvitationStatus.INVITED ) + DomainInvitation.objects.create( + email=cls.invited_member_email, domain=cls.domain3, status=DomainInvitation.DomainInvitationStatus.CANCELED + ) + DomainInvitation.objects.create( + email=cls.invited_member_email, domain=cls.domain4, status=DomainInvitation.DomainInvitationStatus.RETRIEVED + ) @classmethod def tearDownClass(cls): @@ -138,7 +144,8 @@ class GetPortfolioMemberDomainsJsonTest(TestWithUser, WebTest): @override_flag("organization_feature", active=True) @override_flag("organization_members", active=True) def test_get_portfolio_invitedmember_domains_json_authenticated(self): - """Test that portfolio invitedmember's domains are returned properly for an authenticated user.""" + """Test that portfolio invitedmember's domains are returned properly for an authenticated user. + CANCELED and RETRIEVED invites should be ignored.""" response = self.app.get( reverse("get_member_domains_json"), params={"portfolio": self.portfolio.id, "email": self.invited_member_email, "member_only": "true"}, diff --git a/src/registrar/tests/test_views_members_json.py b/src/registrar/tests/test_views_members_json.py index d7b9f3a9f..8082a1a30 100644 --- a/src/registrar/tests/test_views_members_json.py +++ b/src/registrar/tests/test_views_members_json.py @@ -157,7 +157,7 @@ class GetPortfolioMembersJsonTest(MockEppLib, WebTest): @override_flag("organization_members", active=True) def test_get_portfolio_invited_json_authenticated(self): """Test that portfolio invitees are returned properly for an authenticated user.""" - """Also tests that reposnse is 200 when no domains""" + """Also tests that response is 200 when no domains""" UserPortfolioPermission.objects.create( user=self.user, portfolio=self.portfolio, @@ -258,13 +258,14 @@ class GetPortfolioMembersJsonTest(MockEppLib, WebTest): role=UserDomainRole.Roles.MANAGER, ) - # create domain for which user is manager and domain not in portfolio + # create another domain in the portfolio domain2 = Domain.objects.create( - name="somedomain2.com", + name="thissecondpermtestsmultipleperms@lets.notbreak", ) DomainInformation.objects.create( creator=self.user, domain=domain2, + portfolio=self.portfolio, ) UserDomainRole.objects.create( user=self.user, @@ -272,6 +273,20 @@ class GetPortfolioMembersJsonTest(MockEppLib, WebTest): role=UserDomainRole.Roles.MANAGER, ) + # create domain for which user is manager and domain not in portfolio + domain3 = Domain.objects.create( + name="somedomain3.com", + ) + DomainInformation.objects.create( + creator=self.user, + domain=domain3, + ) + UserDomainRole.objects.create( + user=self.user, + domain=domain3, + role=UserDomainRole.Roles.MANAGER, + ) + response = self.app.get(reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id}) self.assertEqual(response.status_code, 200) data = response.json @@ -279,7 +294,8 @@ class GetPortfolioMembersJsonTest(MockEppLib, WebTest): # Check if the domain appears in the response JSON and that domain2 does not domain_names = [domain_name for member in data["members"] for domain_name in member.get("domain_names", [])] self.assertIn("somedomain1.com", domain_names) - self.assertNotIn("somedomain2.com", domain_names) + self.assertIn("thissecondpermtestsmultipleperms@lets.notbreak", domain_names) + self.assertNotIn("somedomain3.com", domain_names) @less_console_noise_decorator @override_flag("organization_feature", active=True) @@ -318,19 +334,33 @@ class GetPortfolioMembersJsonTest(MockEppLib, WebTest): domain=domain, ) - # create a domain not in the portfolio + # create another domain in the portfolio domain2 = Domain.objects.create( - name="somedomain2.com", + name="thissecondinvitetestsasubqueryinjson@lets.notbreak", ) DomainInformation.objects.create( creator=self.user, domain=domain2, + portfolio=self.portfolio, ) DomainInvitation.objects.create( email=self.email6, domain=domain2, ) + # create a domain not in the portfolio + domain3 = Domain.objects.create( + name="somedomain3.com", + ) + DomainInformation.objects.create( + creator=self.user, + domain=domain3, + ) + DomainInvitation.objects.create( + email=self.email6, + domain=domain3, + ) + response = self.app.get(reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id}) self.assertEqual(response.status_code, 200) data = response.json @@ -338,7 +368,8 @@ class GetPortfolioMembersJsonTest(MockEppLib, WebTest): # Check if the domain appears in the response JSON and domain2 does not domain_names = [domain_name for member in data["members"] for domain_name in member.get("domain_names", [])] self.assertIn("somedomain1.com", domain_names) - self.assertNotIn("somedomain2.com", domain_names) + self.assertIn("thissecondinvitetestsasubqueryinjson@lets.notbreak", domain_names) + self.assertNotIn("somedomain3.com", domain_names) @less_console_noise_decorator @override_flag("organization_feature", active=True) diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index 01383ae77..9bc97874d 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -14,17 +14,21 @@ from registrar.models import ( Suborganization, AllowedEmail, ) +from registrar.models.domain_invitation import DomainInvitation from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.user_group import UserGroup from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.tests.test_views import TestWithUser +from registrar.utility.email import EmailSendingError +from registrar.utility.errors import MissingEmailError from .common import MockSESClient, completed_domain_request, create_test_user, create_user from waffle.testutils import override_flag from django.contrib.sessions.middleware import SessionMiddleware import boto3_mocking # type: ignore from django.test import Client import logging +import json logger = logging.getLogger(__name__) @@ -1927,7 +1931,7 @@ class TestPortfolioMemberDomainsView(TestWithUser, WebTest): cls.portfolio = Portfolio.objects.create(creator=cls.user, organization_name="Test Portfolio") # Assign permissions to the user making requests - UserPortfolioPermission.objects.create( + cls.portfolio_permission = UserPortfolioPermission.objects.create( user=cls.user, portfolio=cls.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], @@ -2106,11 +2110,22 @@ class TestPortfolioMemberDomainsEditView(TestPortfolioMemberDomainsView): @classmethod def setUpClass(cls): super().setUpClass() + cls.url = reverse("member-domains-edit", kwargs={"pk": cls.portfolio_permission.pk}) @classmethod def tearDownClass(cls): super().tearDownClass() + def setUp(self): + super().setUp() + names = ["1.gov", "2.gov", "3.gov"] + Domain.objects.bulk_create([Domain(name=name) for name in names]) + + def tearDown(self): + super().tearDown() + UserDomainRole.objects.all().delete() + Domain.objects.all().delete() + @less_console_noise_decorator @override_flag("organization_feature", active=True) @override_flag("organization_members", active=True) @@ -2162,16 +2177,140 @@ class TestPortfolioMemberDomainsEditView(TestPortfolioMemberDomainsView): # Make sure the response is not found self.assertEqual(response.status_code, 404) + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_post_with_valid_added_domains(self): + """Test that domains can be successfully added.""" + self.client.force_login(self.user) + + data = { + "added_domains": json.dumps([1, 2, 3]), # Mock domain IDs + } + response = self.client.post(self.url, data) + + # Check that the UserDomainRole objects were created + self.assertEqual(UserDomainRole.objects.filter(user=self.user, role=UserDomainRole.Roles.MANAGER).count(), 3) + + # Check for a success message and a redirect + self.assertRedirects(response, reverse("member-domains", kwargs={"pk": self.portfolio_permission.pk})) + messages = list(response.wsgi_request._messages) + self.assertEqual(len(messages), 1) + self.assertEqual(str(messages[0]), "The domain assignment changes have been saved.") + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_post_with_valid_removed_domains(self): + """Test that domains can be successfully removed.""" + self.client.force_login(self.user) + + # Create some UserDomainRole objects + domains = [1, 2, 3] + UserDomainRole.objects.bulk_create([UserDomainRole(domain_id=domain, user=self.user) for domain in domains]) + + data = { + "removed_domains": json.dumps([1, 2]), + } + response = self.client.post(self.url, data) + + # Check that the UserDomainRole objects were deleted + self.assertEqual(UserDomainRole.objects.filter(user=self.user).count(), 1) + self.assertEqual(UserDomainRole.objects.filter(domain_id=3, user=self.user).count(), 1) + + # Check for a success message and a redirect + self.assertRedirects(response, reverse("member-domains", kwargs={"pk": self.portfolio_permission.pk})) + messages = list(response.wsgi_request._messages) + self.assertEqual(len(messages), 1) + self.assertEqual(str(messages[0]), "The domain assignment changes have been saved.") + + UserDomainRole.objects.all().delete() + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_post_with_invalid_added_domains_data(self): + """Test that an error is returned for invalid added domains data.""" + self.client.force_login(self.user) + + data = { + "added_domains": "json-statham", + } + response = self.client.post(self.url, data) + + # Check that no UserDomainRole objects were created + self.assertEqual(UserDomainRole.objects.filter(user=self.user).count(), 0) + + # Check for an error message and a redirect + self.assertRedirects(response, reverse("member-domains", kwargs={"pk": self.portfolio_permission.pk})) + messages = list(response.wsgi_request._messages) + self.assertEqual(len(messages), 1) + self.assertEqual( + str(messages[0]), "Invalid data for added domains. If the issue persists, please contact help@get.gov." + ) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_post_with_invalid_removed_domains_data(self): + """Test that an error is returned for invalid removed domains data.""" + self.client.force_login(self.user) + + data = { + "removed_domains": "not-a-json", + } + response = self.client.post(self.url, data) + + # Check that no UserDomainRole objects were deleted + self.assertEqual(UserDomainRole.objects.filter(user=self.user).count(), 0) + + # Check for an error message and a redirect + self.assertRedirects(response, reverse("member-domains", kwargs={"pk": self.portfolio_permission.pk})) + messages = list(response.wsgi_request._messages) + self.assertEqual(len(messages), 1) + self.assertEqual( + str(messages[0]), "Invalid data for removed domains. If the issue persists, please contact help@get.gov." + ) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_post_with_no_changes(self): + """Test that no changes message is displayed when no changes are made.""" + self.client.force_login(self.user) + + response = self.client.post(self.url, {}) + + # Check that no UserDomainRole objects were created or deleted + self.assertEqual(UserDomainRole.objects.filter(user=self.user).count(), 0) + + # Check for an info message and a redirect + self.assertRedirects(response, reverse("member-domains", kwargs={"pk": self.portfolio_permission.pk})) + messages = list(response.wsgi_request._messages) + self.assertEqual(len(messages), 1) + self.assertEqual(str(messages[0]), "No changes detected.") + class TestPortfolioInvitedMemberEditDomainsView(TestPortfolioInvitedMemberDomainsView): @classmethod def setUpClass(cls): super().setUpClass() + cls.url = reverse("invitedmember-domains-edit", kwargs={"pk": cls.invitation.pk}) @classmethod def tearDownClass(cls): super().tearDownClass() + def setUp(self): + super().setUp() + names = ["1.gov", "2.gov", "3.gov"] + Domain.objects.bulk_create([Domain(name=name) for name in names]) + + def tearDown(self): + super().tearDown() + Domain.objects.all().delete() + DomainInvitation.objects.all().delete() + @less_console_noise_decorator @override_flag("organization_feature", active=True) @override_flag("organization_members", active=True) @@ -2222,6 +2361,175 @@ class TestPortfolioInvitedMemberEditDomainsView(TestPortfolioInvitedMemberDomain # Make sure the response is not found self.assertEqual(response.status_code, 404) + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_post_with_valid_added_domains(self): + """Test adding new domains successfully.""" + self.client.force_login(self.user) + + data = { + "added_domains": json.dumps([1, 2, 3]), # Mock domain IDs + } + response = self.client.post(self.url, data) + + # Check that the DomainInvitation objects were created + self.assertEqual( + DomainInvitation.objects.filter( + email="invited@example.com", status=DomainInvitation.DomainInvitationStatus.INVITED + ).count(), + 3, + ) + + # Check for a success message and a redirect + self.assertRedirects(response, reverse("invitedmember-domains", kwargs={"pk": self.invitation.pk})) + messages = list(response.wsgi_request._messages) + self.assertEqual(len(messages), 1) + self.assertEqual(str(messages[0]), "The domain assignment changes have been saved.") + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_post_with_existing_and_new_added_domains(self): + """Test updating existing and adding new invitations.""" + self.client.force_login(self.user) + + # Create existing invitations + DomainInvitation.objects.bulk_create( + [ + DomainInvitation( + domain_id=1, email="invited@example.com", status=DomainInvitation.DomainInvitationStatus.CANCELED + ), + DomainInvitation( + domain_id=2, email="invited@example.com", status=DomainInvitation.DomainInvitationStatus.INVITED + ), + ] + ) + + data = { + "added_domains": json.dumps([1, 2, 3]), + } + response = self.client.post(self.url, data) + + # Check that status for domain_id=1 was updated to INVITED + self.assertEqual( + DomainInvitation.objects.get(domain_id=1, email="invited@example.com").status, + DomainInvitation.DomainInvitationStatus.INVITED, + ) + + # Check that domain_id=3 was created as INVITED + self.assertTrue( + DomainInvitation.objects.filter( + domain_id=3, email="invited@example.com", status=DomainInvitation.DomainInvitationStatus.INVITED + ).exists() + ) + + # Check for a success message and a redirect + self.assertRedirects(response, reverse("invitedmember-domains", kwargs={"pk": self.invitation.pk})) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_post_with_valid_removed_domains(self): + """Test removing domains successfully.""" + self.client.force_login(self.user) + + # Create existing invitations + DomainInvitation.objects.bulk_create( + [ + DomainInvitation( + domain_id=1, email="invited@example.com", status=DomainInvitation.DomainInvitationStatus.INVITED + ), + DomainInvitation( + domain_id=2, email="invited@example.com", status=DomainInvitation.DomainInvitationStatus.INVITED + ), + ] + ) + + data = { + "removed_domains": json.dumps([1]), + } + response = self.client.post(self.url, data) + + # Check that the status for domain_id=1 was updated to CANCELED + self.assertEqual( + DomainInvitation.objects.get(domain_id=1, email="invited@example.com").status, + DomainInvitation.DomainInvitationStatus.CANCELED, + ) + + # Check that domain_id=2 remains INVITED + self.assertEqual( + DomainInvitation.objects.get(domain_id=2, email="invited@example.com").status, + DomainInvitation.DomainInvitationStatus.INVITED, + ) + + # Check for a success message and a redirect + self.assertRedirects(response, reverse("invitedmember-domains", kwargs={"pk": self.invitation.pk})) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_post_with_invalid_added_domains_data(self): + """Test handling of invalid JSON for added domains.""" + self.client.force_login(self.user) + + data = { + "added_domains": "not-a-json", + } + response = self.client.post(self.url, data) + + # Check that no DomainInvitation objects were created + self.assertEqual(DomainInvitation.objects.count(), 0) + + # Check for an error message and a redirect + self.assertRedirects(response, reverse("invitedmember-domains", kwargs={"pk": self.invitation.pk})) + messages = list(response.wsgi_request._messages) + self.assertEqual(len(messages), 1) + self.assertEqual( + str(messages[0]), "Invalid data for added domains. If the issue persists, please contact help@get.gov." + ) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_post_with_invalid_removed_domains_data(self): + """Test handling of invalid JSON for removed domains.""" + self.client.force_login(self.user) + + data = { + "removed_domains": "json-sudeikis", + } + response = self.client.post(self.url, data) + + # Check that no DomainInvitation objects were updated + self.assertEqual(DomainInvitation.objects.count(), 0) + + # Check for an error message and a redirect + self.assertRedirects(response, reverse("invitedmember-domains", kwargs={"pk": self.invitation.pk})) + messages = list(response.wsgi_request._messages) + self.assertEqual(len(messages), 1) + self.assertEqual( + str(messages[0]), "Invalid data for removed domains. If the issue persists, please contact help@get.gov." + ) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_post_with_no_changes(self): + """Test the case where no changes are made.""" + self.client.force_login(self.user) + + response = self.client.post(self.url, {}) + + # Check that no DomainInvitation objects were created or updated + self.assertEqual(DomainInvitation.objects.count(), 0) + + # Check for an info message and a redirect + self.assertRedirects(response, reverse("invitedmember-domains", kwargs={"pk": self.invitation.pk})) + messages = list(response.wsgi_request._messages) + self.assertEqual(len(messages), 1) + self.assertEqual(str(messages[0]), "No changes detected.") + class TestRequestingEntity(WebTest): """The requesting entity page is a domain request form that only exists @@ -2268,6 +2576,46 @@ class TestRequestingEntity(WebTest): User.objects.all().delete() super().tearDown() + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_requests", active=True) + def test_form_validates_duplicate_suborganization(self): + """Tests that form validation prevents duplicate suborganization names within the same portfolio""" + # Create an existing suborganization + suborganization = Suborganization.objects.create(name="Existing Suborg", portfolio=self.portfolio) + + # Start the domain request process + response = self.app.get(reverse("domain-request:start")) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + + # Navigate past the intro page + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + form = response.forms[0] + response = form.submit().follow() + + # Fill out the requesting entity form + form = response.forms[0] + form["portfolio_requesting_entity-requesting_entity_is_suborganization"] = "True" + form["portfolio_requesting_entity-is_requesting_new_suborganization"] = "True" + form["portfolio_requesting_entity-requested_suborganization"] = suborganization.name.lower() + form["portfolio_requesting_entity-suborganization_city"] = "Eggnog" + form["portfolio_requesting_entity-suborganization_state_territory"] = DomainRequest.StateTerritoryChoices.OHIO + + # Submit form and verify error + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + response = form.submit() + self.assertContains(response, "This suborganization already exists") + + # Test that a different name is allowed + form["portfolio_requesting_entity-requested_suborganization"] = "New Suborg" + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + response = form.submit().follow() + + # Verify successful submission by checking we're on the next page + self.assertContains(response, "Current websites") + @override_flag("organization_feature", active=True) @override_flag("organization_requests", active=True) @less_console_noise_decorator @@ -2531,7 +2879,9 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest): ], ) - cls.new_member_email = "new_user@example.com" + cls.new_member_email = "davekenn4242@gmail.com" + + AllowedEmail.objects.get_or_create(email=cls.new_member_email) # Assign permissions to the user making requests UserPortfolioPermission.objects.create( @@ -2550,8 +2900,10 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest): UserPortfolioPermission.objects.all().delete() Portfolio.objects.all().delete() User.objects.all().delete() + AllowedEmail.objects.all().delete() super().tearDownClass() + @boto3_mocking.patching @less_console_noise_decorator @override_flag("organization_feature", active=True) @override_flag("organization_members", active=True) @@ -2563,30 +2915,240 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest): session_id = self.client.session.session_key self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - # Simulate submission of member invite for new user - final_response = self.client.post( - reverse("new-member"), - { - "member_access_level": "basic", - "basic_org_domain_request_permissions": "view_only", - "email": self.new_member_email, - }, - ) + mock_client_class = MagicMock() + mock_client = mock_client_class.return_value - # Ensure the final submission is successful - self.assertEqual(final_response.status_code, 302) # redirects after success + with boto3_mocking.clients.handler_for("sesv2", mock_client_class): + # Simulate submission of member invite for new user + final_response = self.client.post( + reverse("new-member"), + { + "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, + "domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, + "email": self.new_member_email, + }, + ) - # Validate Database Changes - portfolio_invite = PortfolioInvitation.objects.filter( - email=self.new_member_email, portfolio=self.portfolio - ).first() - self.assertIsNotNone(portfolio_invite) - self.assertEqual(portfolio_invite.email, self.new_member_email) + # Ensure the final submission is successful + self.assertEqual(final_response.status_code, 302) # Redirects + + # Validate Database Changes + portfolio_invite = PortfolioInvitation.objects.filter( + email=self.new_member_email, portfolio=self.portfolio + ).first() + self.assertIsNotNone(portfolio_invite) + self.assertEqual(portfolio_invite.email, self.new_member_email) + + # Check that an email was sent + self.assertTrue(mock_client.send_email.called) + + @boto3_mocking.patching + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_member_invite_for_new_users_initial_ajax_call_passes(self): + """Tests the member invitation flow for new users.""" + self.client.force_login(self.user) + + # Simulate a session to ensure continuity + session_id = self.client.session.session_key + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + mock_client_class = MagicMock() + mock_client = mock_client_class.return_value + + with boto3_mocking.clients.handler_for("sesv2", mock_client_class): + # Simulate submission of member invite for new user + final_response = self.client.post( + reverse("new-member"), + { + "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, + "domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, + "email": self.new_member_email, + }, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + + # Ensure the prep ajax submission is successful + self.assertEqual(final_response.status_code, 200) + + # Check that the response is a JSON response with is_valid + json_response = final_response.json() + self.assertIn("is_valid", json_response) + self.assertTrue(json_response["is_valid"]) + + # assert that portfolio invitation is not created + self.assertFalse( + PortfolioInvitation.objects.filter(email=self.new_member_email, portfolio=self.portfolio).exists(), + "Portfolio invitation should not be created when an Exception occurs.", + ) + + # Check that an email was not sent + self.assertFalse(mock_client.send_email.called) @less_console_noise_decorator @override_flag("organization_feature", active=True) @override_flag("organization_members", active=True) - def test_member_invite_for_previously_invited_member(self): + @patch("registrar.views.portfolios.send_portfolio_invitation_email") + def test_member_invite_for_previously_invited_member_initial_ajax_call_fails(self, mock_send_email): + """Tests the initial ajax call in the member invitation flow for existing portfolio member.""" + self.client.force_login(self.user) + + # Simulate a session to ensure continuity + session_id = self.client.session.session_key + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + invite_count_before = PortfolioInvitation.objects.count() + + # Simulate submission of member invite for user who has already been invited + response = self.client.post( + reverse("new-member"), + { + "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, + "domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, + "email": self.invited_member_email, + }, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + self.assertEqual(response.status_code, 200) + + # Check that the response is a JSON response with is_valid == False + json_response = response.json() + self.assertIn("is_valid", json_response) + self.assertFalse(json_response["is_valid"]) + + # Validate Database has not changed + invite_count_after = PortfolioInvitation.objects.count() + self.assertEqual(invite_count_after, invite_count_before) + + # assert that send_portfolio_invitation_email is not called + mock_send_email.assert_not_called() + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + @patch("registrar.views.portfolios.send_portfolio_invitation_email") + def test_submit_new_member_raises_email_sending_error(self, mock_send_email): + """Test when adding a new member and email_send method raises EmailSendingError.""" + mock_send_email.side_effect = EmailSendingError("Failed to send email.") + + self.client.force_login(self.user) + + # Simulate a session to ensure continuity + session_id = self.client.session.session_key + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + form_data = { + "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, + "domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, + "email": self.new_member_email, + } + + # Act + with patch("django.contrib.messages.warning") as mock_warning: + response = self.client.post(reverse("new-member"), data=form_data) + + # Assert + # assert that the send_portfolio_invitation_email called + mock_send_email.assert_called_once_with( + email=self.new_member_email, requestor=self.user, portfolio=self.portfolio + ) + # assert that response is a redirect to reverse("members") + self.assertRedirects(response, reverse("members")) + # assert that messages contains message, "Could not send email invitation" + mock_warning.assert_called_once_with(response.wsgi_request, "Could not send email invitation.") + # assert that portfolio invitation is not created + self.assertFalse( + PortfolioInvitation.objects.filter(email=self.new_member_email, portfolio=self.portfolio).exists(), + "Portfolio invitation should not be created when an EmailSendingError occurs.", + ) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + @patch("registrar.views.portfolios.send_portfolio_invitation_email") + def test_submit_new_member_raises_missing_email_error(self, mock_send_email): + """Test when adding a new member and email_send method raises MissingEmailError.""" + mock_send_email.side_effect = MissingEmailError() + + self.client.force_login(self.user) + + # Simulate a session to ensure continuity + session_id = self.client.session.session_key + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + form_data = { + "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, + "domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, + "email": self.new_member_email, + } + + # Act + with patch("django.contrib.messages.error") as mock_error: + response = self.client.post(reverse("new-member"), data=form_data) + + # Assert + # assert that the send_portfolio_invitation_email called + mock_send_email.assert_called_once_with( + email=self.new_member_email, requestor=self.user, portfolio=self.portfolio + ) + # assert that response is a redirect to reverse("members") + self.assertRedirects(response, reverse("members")) + # assert that messages contains message, "Could not send email invitation" + mock_error.assert_called_once_with( + response.wsgi_request, + "Can't send invitation email. No email is associated with your user account.", + ) + # assert that portfolio invitation is not created + self.assertFalse( + PortfolioInvitation.objects.filter(email=self.new_member_email, portfolio=self.portfolio).exists(), + "Portfolio invitation should not be created when a MissingEmailError occurs.", + ) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + @patch("registrar.views.portfolios.send_portfolio_invitation_email") + def test_submit_new_member_raises_exception(self, mock_send_email): + """Test when adding a new member and email_send method raises Exception.""" + mock_send_email.side_effect = Exception("Generic exception") + + self.client.force_login(self.user) + + # Simulate a session to ensure continuity + session_id = self.client.session.session_key + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + form_data = { + "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, + "domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, + "email": self.new_member_email, + } + + # Act + with patch("django.contrib.messages.warning") as mock_warning: + response = self.client.post(reverse("new-member"), data=form_data) + + # Assert + # assert that the send_portfolio_invitation_email called + mock_send_email.assert_called_once_with( + email=self.new_member_email, requestor=self.user, portfolio=self.portfolio + ) + # assert that response is a redirect to reverse("members") + self.assertRedirects(response, reverse("members")) + # assert that messages contains message, "Could not send email invitation" + mock_warning.assert_called_once_with(response.wsgi_request, "Could not send email invitation.") + # assert that portfolio invitation is not created + self.assertFalse( + PortfolioInvitation.objects.filter(email=self.new_member_email, portfolio=self.portfolio).exists(), + "Portfolio invitation should not be created when an Exception occurs.", + ) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + @patch("registrar.views.portfolios.send_portfolio_invitation_email") + def test_member_invite_for_previously_invited_member(self, mock_send_email): """Tests the member invitation flow for existing portfolio member.""" self.client.force_login(self.user) @@ -2600,23 +3162,35 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest): response = self.client.post( reverse("new-member"), { - "member_access_level": "basic", - "basic_org_domain_request_permissions": "view_only", + "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, + "domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "email": self.invited_member_email, }, ) - self.assertEqual(response.status_code, 302) # Redirects + self.assertEqual(response.status_code, 200) - # TODO: verify messages + # verify messages + self.assertContains( + response, + ( + "This user is already assigned to a portfolio invitation. " + "Based on current waffle flag settings, users cannot be assigned " + "to multiple portfolios." + ), + ) # Validate Database has not changed invite_count_after = PortfolioInvitation.objects.count() self.assertEqual(invite_count_after, invite_count_before) + # assert that send_portfolio_invitation_email is not called + mock_send_email.assert_not_called() + @less_console_noise_decorator @override_flag("organization_feature", active=True) @override_flag("organization_members", active=True) - def test_member_invite_for_existing_member(self): + @patch("registrar.views.portfolios.send_portfolio_invitation_email") + def test_member_invite_for_existing_member(self, mock_send_email): """Tests the member invitation flow for existing portfolio member.""" self.client.force_login(self.user) @@ -2630,19 +3204,30 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest): response = self.client.post( reverse("new-member"), { - "member_access_level": "basic", - "basic_org_domain_request_permissions": "view_only", + "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, + "domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "email": self.user.email, }, ) - self.assertEqual(response.status_code, 302) # Redirects + self.assertEqual(response.status_code, 200) - # TODO: verify messages + # Verify messages + self.assertContains( + response, + ( + "This user is already assigned to a portfolio. " + "Based on current waffle flag settings, users cannot be " + "assigned to multiple portfolios." + ), + ) # Validate Database has not changed invite_count_after = PortfolioInvitation.objects.count() self.assertEqual(invite_count_after, invite_count_before) + # assert that send_portfolio_invitation_email is not called + mock_send_email.assert_not_called() + class TestEditPortfolioMemberView(WebTest): """Tests for the edit member page on portfolios""" @@ -2783,7 +3368,13 @@ class TestEditPortfolioMemberView(WebTest): @override_flag("organization_feature", active=True) @override_flag("organization_members", active=True) def test_admin_removing_own_admin_role(self): - """Tests an admin removing their own admin role redirects to home.""" + """Tests an admin removing their own admin role redirects to home. + + Removing the admin role will remove both view and edit members permissions. + Note: The user can remove the edit members permissions but as long as they + stay in admin role, they will at least still have view members permissions. + """ + self.client.force_login(self.user) # Get the user's admin permission diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index 66809777b..7addf041d 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -417,7 +417,7 @@ class MemberExport(BaseExport): # Adding a order_by increases output predictability. # Doesn't matter as much for normal use, but makes tests easier. # We should also just be ordering by default anyway. - members = permissions.union(invitations).order_by("email_display") + members = permissions.union(invitations).order_by("email_display", "member_display", "first_name", "last_name") return convert_queryset_to_dict(members, is_model=False) @classmethod @@ -538,11 +538,23 @@ class DomainExport(BaseExport): # model objects as we export data, trying to reinstate model objects in order to grab @property # values negatively impacts performance. Therefore, we will follow best practice and use annotations return { - "converted_generic_org_type": Case( - # When portfolio is present, use its value instead - When(portfolio__isnull=False, then=F("portfolio__organization_type")), + "converted_org_type": Case( + # When portfolio is present and is_election_board is True + When( + portfolio__isnull=False, + portfolio__organization_type__isnull=False, + is_election_board=True, + then=Concat(F("portfolio__organization_type"), Value("_election")), + ), + # When portfolio is present and is_election_board is False or None + When( + Q(is_election_board=False) | Q(is_election_board__isnull=True), + portfolio__isnull=False, + portfolio__organization_type__isnull=False, + then=F("portfolio__organization_type"), + ), # Otherwise, return the natively assigned value - default=F("generic_org_type"), + default=F("organization_type"), output_field=CharField(), ), "converted_federal_agency": Case( @@ -573,20 +585,6 @@ class DomainExport(BaseExport): default=F("organization_name"), output_field=CharField(), ), - "converted_city": Case( - # When portfolio is present, use its value instead - When(portfolio__isnull=False, then=F("portfolio__city")), - # Otherwise, return the natively assigned value - default=F("city"), - output_field=CharField(), - ), - "converted_state_territory": Case( - # When portfolio is present, use its value instead - When(portfolio__isnull=False, then=F("portfolio__state_territory")), - # Otherwise, return the natively assigned value - default=F("state_territory"), - output_field=CharField(), - ), "converted_so_email": Case( # When portfolio is present, use its value instead When(portfolio__isnull=False, then=F("portfolio__senior_official__email")), @@ -727,7 +725,8 @@ class DomainExport(BaseExport): first_ready_on = "(blank)" # organization_type has organization_type AND is_election - domain_org_type = model.get("converted_generic_org_type") + # domain_org_type includes "- Election" org_type variants + domain_org_type = model.get("converted_org_type") human_readable_domain_org_type = DomainRequest.OrgChoicesElectionOffice.get_org_label(domain_org_type) domain_federal_type = model.get("converted_federal_type") human_readable_domain_federal_type = BranchChoices.get_branch_label(domain_federal_type) @@ -772,8 +771,8 @@ class DomainExport(BaseExport): "Domain type": model.get("domain_type"), "Agency": model.get("converted_federal_agency"), "Organization name": model.get("converted_organization_name"), - "City": model.get("converted_city"), - "State": model.get("converted_state_territory"), + "City": model.get("city"), + "State": model.get("state_territory"), "SO": model.get("converted_so_name"), "SO email": model.get("converted_so_email"), "Security contact email": model.get("security_contact_email"), @@ -908,7 +907,7 @@ class DomainDataType(DomainExport): """ # Coalesce is used to replace federal_type of None with ZZZZZ return [ - "converted_generic_org_type", + "converted_org_type", Coalesce("converted_federal_type", Value("ZZZZZ")), "converted_federal_agency", "domain__name", @@ -987,105 +986,6 @@ class DomainDataTypeUser(DomainDataType): return Q(domain__id__in=request.user.get_user_domain_ids(request)) -class DomainRequestsDataType: - """ - The DomainRequestsDataType report, but filtered based on the current request user - """ - - @classmethod - def get_filter_conditions(cls, request=None, **kwargs): - if request is None or not hasattr(request, "user") or not request.user.is_authenticated: - return Q(id__in=[]) - - request_ids = request.user.get_user_domain_request_ids(request) - return Q(id__in=request_ids) - - @classmethod - def get_queryset(cls, request): - return DomainRequest.objects.filter(cls.get_filter_conditions(request)) - - def safe_get(attribute, default="N/A"): - # Return the attribute value or default if not present - return attribute if attribute is not None else default - - @classmethod - def exporting_dr_data_to_csv(cls, response, request=None): - import csv - - writer = csv.writer(response) - - # CSV headers - writer.writerow( - [ - "Domain request", - "Region", - "Status", - "Election office", - "Federal type", - "Domain type", - "Request additional details", - "Creator approved domains count", - "Creator active requests count", - "Alternative domains", - "Other contacts", - "Current websites", - "Federal agency", - "SO first name", - "SO last name", - "SO email", - "SO title/role", - "Creator first name", - "Creator last name", - "Creator email", - "Organization name", - "City", - "State/territory", - "Request purpose", - "CISA regional representative", - "Last submitted date", - "First submitted date", - "Last status update", - ] - ) - - queryset = cls.get_queryset(request) - for request in queryset: - writer.writerow( - [ - request.requested_domain, - cls.safe_get(getattr(request, "region_field", None)), - request.status, - cls.safe_get(getattr(request, "election_office", None)), - request.converted_federal_type, - cls.safe_get(getattr(request, "domain_type", None)), - cls.safe_get(getattr(request, "additional_details", None)), - cls.safe_get(getattr(request, "creator_approved_domains_count", None)), - cls.safe_get(getattr(request, "creator_active_requests_count", None)), - cls.safe_get(getattr(request, "all_alternative_domains", None)), - cls.safe_get(getattr(request, "all_other_contacts", None)), - cls.safe_get(getattr(request, "all_current_websites", None)), - cls.safe_get(getattr(request, "converted_federal_agency", None)), - cls.safe_get(getattr(request.converted_senior_official, "first_name", None)), - cls.safe_get(getattr(request.converted_senior_official, "last_name", None)), - cls.safe_get(getattr(request.converted_senior_official, "email", None)), - cls.safe_get(getattr(request.converted_senior_official, "title", None)), - cls.safe_get(getattr(request.creator, "first_name", None)), - cls.safe_get(getattr(request.creator, "last_name", None)), - cls.safe_get(getattr(request.creator, "email", None)), - cls.safe_get(getattr(request, "converted_organization_name", None)), - cls.safe_get(getattr(request, "converted_city", None)), - cls.safe_get(getattr(request, "converted_state_territory", None)), - cls.safe_get(getattr(request, "purpose", None)), - cls.safe_get(getattr(request, "cisa_representative_email", None)), - cls.safe_get(getattr(request, "last_submitted_date", None)), - cls.safe_get(getattr(request, "first_submitted_date", None)), - cls.safe_get(getattr(request, "last_status_update", None)), - ] - ) - - return response - - class DomainDataFull(DomainExport): """ Shows security contacts, filtered by state @@ -1760,20 +1660,6 @@ class DomainRequestExport(BaseExport): default=F("organization_name"), output_field=CharField(), ), - "converted_city": Case( - # When portfolio is present, use its value instead - When(portfolio__isnull=False, then=F("portfolio__city")), - # Otherwise, return the natively assigned value - default=F("city"), - output_field=CharField(), - ), - "converted_state_territory": Case( - # When portfolio is present, use its value instead - When(portfolio__isnull=False, then=F("portfolio__state_territory")), - # Otherwise, return the natively assigned value - default=F("state_territory"), - output_field=CharField(), - ), "converted_so_email": Case( # When portfolio is present, use its value instead When(portfolio__isnull=False, then=F("portfolio__senior_official__email")), @@ -1952,8 +1838,8 @@ class DomainRequestExport(BaseExport): "Investigator": model.get("investigator__email"), # Untouched fields "Organization name": model.get("converted_organization_name"), - "City": model.get("converted_city"), - "State/territory": model.get("converted_state_territory"), + "City": model.get("city"), + "State/territory": model.get("state_territory"), "Request purpose": model.get("purpose"), "CISA regional representative": model.get("cisa_representative_email"), "Last submitted date": model.get("last_submitted_date"), @@ -1965,6 +1851,92 @@ class DomainRequestExport(BaseExport): return row +class DomainRequestDataType(DomainRequestExport): + """ + The DomainRequestDataType report, but filtered based on the current request user + """ + + @classmethod + def get_columns(cls): + """ + Overrides the columns for CSV export specific to DomainRequestDataType. + """ + return [ + "Domain request", + "Region", + "Status", + "Election office", + "Federal type", + "Domain type", + "Request additional details", + "Creator approved domains count", + "Creator active requests count", + "Alternative domains", + "Other contacts", + "Current websites", + "Federal agency", + "SO first name", + "SO last name", + "SO email", + "SO title/role", + "Creator first name", + "Creator last name", + "Creator email", + "Organization name", + "City", + "State/territory", + "Request purpose", + "CISA regional representative", + "Last submitted date", + "First submitted date", + "Last status update", + ] + + @classmethod + def get_filter_conditions(cls, request=None, **kwargs): + """ + Get a Q object of filter conditions to filter when building queryset. + """ + if request is None or not hasattr(request, "user") or not request.user: + # Return nothing + return Q(id__in=[]) + else: + # Get all domain requests the user is associated with + return Q(id__in=request.user.get_user_domain_request_ids(request)) + + @classmethod + def get_select_related(cls): + """ + Get a list of tables to pass to select_related when building queryset. + """ + return ["creator", "senior_official", "federal_agency", "investigator", "requested_domain"] + + @classmethod + def get_prefetch_related(cls): + """ + Get a list of tables to pass to prefetch_related when building queryset. + """ + return ["current_websites", "other_contacts", "alternative_domains"] + + @classmethod + def get_related_table_fields(cls): + """ + Get a list of fields from related tables. + """ + return [ + "requested_domain__name", + "federal_agency__agency", + "senior_official__first_name", + "senior_official__last_name", + "senior_official__email", + "senior_official__title", + "creator__first_name", + "creator__last_name", + "creator__email", + "investigator__email", + ] + + class DomainRequestGrowth(DomainRequestExport): """ Shows submitted requests within a date range, sorted diff --git a/src/registrar/utility/email_invitations.py b/src/registrar/utility/email_invitations.py new file mode 100644 index 000000000..7171b8902 --- /dev/null +++ b/src/registrar/utility/email_invitations.py @@ -0,0 +1,114 @@ +from django.conf import settings +from registrar.models import DomainInvitation +from registrar.utility.errors import ( + AlreadyDomainInvitedError, + AlreadyDomainManagerError, + MissingEmailError, + OutsideOrgMemberError, +) +from registrar.utility.waffle import flag_is_active_for_user +from registrar.utility.email import send_templated_email +import logging + +logger = logging.getLogger(__name__) + + +def send_domain_invitation_email(email: str, requestor, domain, is_member_of_different_org): + """ + Sends a domain invitation email to the specified address. + + Raises exceptions for validation or email-sending issues. + + Args: + email (str): Email address of the recipient. + requestor (User): The user initiating the invitation. + domain (Domain): The domain object for which the invitation is being sent. + is_member_of_different_org (bool): if an email belongs to a different org + + Raises: + MissingEmailError: If the requestor has no email associated with their account. + AlreadyDomainManagerError: If the email corresponds to an existing domain manager. + AlreadyDomainInvitedError: If an invitation has already been sent. + OutsideOrgMemberError: If the requested_user is part of a different organization. + EmailSendingError: If there is an error while sending the email. + """ + # Default email address for staff + requestor_email = settings.DEFAULT_FROM_EMAIL + + # Check if the requestor is staff and has an email + if not requestor.is_staff: + if not requestor.email or requestor.email.strip() == "": + raise MissingEmailError + else: + requestor_email = requestor.email + + # Check if the recipient is part of a different organization + # COMMENT: this does not account for multiple_portfolios flag being active + if ( + flag_is_active_for_user(requestor, "organization_feature") + and not flag_is_active_for_user(requestor, "multiple_portfolios") + and is_member_of_different_org + ): + raise OutsideOrgMemberError + + # Check for an existing invitation + try: + invite = DomainInvitation.objects.get(email=email, domain=domain) + if invite.status == DomainInvitation.DomainInvitationStatus.RETRIEVED: + raise AlreadyDomainManagerError(email) + elif invite.status == DomainInvitation.DomainInvitationStatus.CANCELED: + invite.update_cancellation_status() + invite.save() + else: + raise AlreadyDomainInvitedError(email) + except DomainInvitation.DoesNotExist: + pass + + # Send the email + send_templated_email( + "emails/domain_invitation.txt", + "emails/domain_invitation_subject.txt", + to_address=email, + context={ + "domain": domain, + "requestor_email": requestor_email, + }, + ) + + +def send_portfolio_invitation_email(email: str, requestor, portfolio): + """ + Sends a portfolio member invitation email to the specified address. + + Raises exceptions for validation or email-sending issues. + + Args: + email (str): Email address of the recipient + requestor (User): The user initiating the invitation. + portfolio (Portfolio): The portfolio object for which the invitation is being sent. + + Raises: + MissingEmailError: If the requestor has no email associated with their account. + EmailSendingError: If there is an error while sending the email. + """ + + # Default email address for staff + requestor_email = settings.DEFAULT_FROM_EMAIL + + # Check if the requestor is staff and has an email + if not requestor.is_staff: + if not requestor.email or requestor.email.strip() == "": + raise MissingEmailError + else: + requestor_email = requestor.email + + send_templated_email( + "emails/portfolio_invitation.txt", + "emails/portfolio_invitation_subject.txt", + to_address=email, + context={ + "portfolio": portfolio, + "requestor_email": requestor_email, + "email": email, + }, + ) diff --git a/src/registrar/utility/errors.py b/src/registrar/utility/errors.py index e70f06d1e..039fb3696 100644 --- a/src/registrar/utility/errors.py +++ b/src/registrar/utility/errors.py @@ -23,6 +23,33 @@ class InvalidDomainError(ValueError): pass +class InvitationError(Exception): + """Base exception for invitation-related errors.""" + + pass + + +class AlreadyDomainManagerError(InvitationError): + """Raised when the user is already a manager for the domain.""" + + def __init__(self, email): + super().__init__(f"{email} is already a manager for this domain.") + + +class AlreadyDomainInvitedError(InvitationError): + """Raised when the user has already been invited to the domain.""" + + def __init__(self, email): + super().__init__(f"{email} has already been invited to this domain.") + + +class MissingEmailError(InvitationError): + """Raised when the requestor has no email associated with their account.""" + + def __init__(self): + super().__init__("Can't send invitation email. No email is associated with your user account.") + + class OutsideOrgMemberError(ValueError): """ Error raised when an org member tries adding a user from a different .gov org. diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 7ce0d7e1a..b849459f2 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -25,14 +25,17 @@ from registrar.models import ( PortfolioInvitation, User, UserDomainRole, - UserPortfolioPermission, PublicContact, ) +from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices from registrar.utility.enums import DefaultEmail from registrar.utility.errors import ( + AlreadyDomainInvitedError, + AlreadyDomainManagerError, GenericError, GenericErrorCodes, + MissingEmailError, NameserverError, NameserverErrorCodes as nsErrorCodes, DsDataError, @@ -63,6 +66,7 @@ from epplibwrapper import ( ) from ..utility.email import send_templated_email, EmailSendingError +from ..utility.email_invitations import send_domain_invitation_email, send_portfolio_invitation_email from .utility import DomainPermissionView, DomainInvitationPermissionCancelView from django import forms @@ -1179,7 +1183,10 @@ class DomainUsersView(DomainBaseView): # If any of the PortfolioInvitations have the ORGANIZATION_ADMIN role, set the flag to True for portfolio_invitation in portfolio_invitations: - if UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_invitation.roles: + if ( + portfolio_invitation.roles + and UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_invitation.roles + ): has_admin_flag = True break # Once we find one match, no need to check further @@ -1221,170 +1228,172 @@ class DomainAddUserView(DomainFormBaseView): def get_success_url(self): return reverse("domain-users", kwargs={"pk": self.object.pk}) - def _domain_abs_url(self): - """Get an absolute URL for this domain.""" - return self.request.build_absolute_uri(reverse("domain", kwargs={"pk": self.object.id})) + def _get_org_membership(self, requestor_org, requested_email, requested_user): + """ + Verifies if an email belongs to a different organization as a member or invited member. + Verifies if an email belongs to this organization as a member or invited member. + User does not belong to any org can be deduced from the tuple returned. - def _is_member_of_different_org(self, email, requestor, requested_user): - """Verifies if an email belongs to a different organization as a member or invited member.""" - # Check if user is a already member of a different organization than the requestor's org - requestor_org = UserPortfolioPermission.objects.filter(user=requestor).first().portfolio - existing_org_permission = UserPortfolioPermission.objects.filter(user=requested_user).first() - existing_org_invitation = PortfolioInvitation.objects.filter(email=email).first() - - return (existing_org_permission and existing_org_permission.portfolio != requestor_org) or ( - existing_org_invitation and existing_org_invitation.portfolio != requestor_org - ) - - def _check_invite_status(self, invite, email): - """Check if invitation status is canceled or retrieved, and gives the appropiate response""" - if invite.status == DomainInvitation.DomainInvitationStatus.RETRIEVED: - messages.warning( - self.request, - f"{email} is already a manager for this domain.", - ) - return False - elif invite.status == DomainInvitation.DomainInvitationStatus.CANCELED: - invite.update_cancellation_status() - invite.save() - return True - else: - # else if it has been sent but not accepted - messages.warning(self.request, f"{email} has already been invited to this domain") - return False - - def _send_domain_invitation_email(self, email: str, requestor: User, requested_user=None, add_success=True): - """Performs the sending of the domain invitation email, - does not make a domain information object - email: string- email to send to - add_success: bool- default True indicates: - adding a success message to the view if the email sending succeeds - - raises EmailSendingError + Returns a tuple (member_of_a_different_org, member_of_this_org). """ - # Set a default email address to send to for staff - requestor_email = settings.DEFAULT_FROM_EMAIL + # COMMENT: this code does not take into account multiple portfolios flag - # Check if the email requestor has a valid email address - if not requestor.is_staff and requestor.email is not None and requestor.email.strip() != "": - requestor_email = requestor.email - elif not requestor.is_staff: - messages.error(self.request, "Can't send invitation email. No email is associated with your account.") - logger.error( - f"Can't send email to '{email}' on domain '{self.object}'." - f"No email exists for the requestor '{requestor.username}'.", - exc_info=True, - ) + # COMMENT: shouldn't this code be based on the organization of the domain, not the org + # of the requestor? requestor could have multiple portfolios + + # Check for existing permissions or invitations for the requested user + existing_org_permission = UserPortfolioPermission.objects.filter(user=requested_user).first() + existing_org_invitation = PortfolioInvitation.objects.filter(email=requested_email).first() + + # Determine membership in a different organization + member_of_a_different_org = ( + existing_org_permission and existing_org_permission.portfolio != requestor_org + ) or (existing_org_invitation and existing_org_invitation.portfolio != requestor_org) + + # Determine membership in the same organization + member_of_this_org = (existing_org_permission and existing_org_permission.portfolio == requestor_org) or ( + existing_org_invitation and existing_org_invitation.portfolio == requestor_org + ) + + return member_of_a_different_org, member_of_this_org + + def form_valid(self, form): + """Add the specified user to this domain.""" + requested_email = form.cleaned_data["email"] + requestor = self.request.user + + # Look up a user with that email + requested_user = self._get_requested_user(requested_email) + # NOTE: This does not account for multiple portfolios flag being set to True + domain_org = self.object.domain_info.portfolio + + # requestor can only send portfolio invitations if they are staff or if they are a member + # of the domain's portfolio + requestor_can_update_portfolio = ( + UserPortfolioPermission.objects.filter(user=requestor, portfolio=domain_org).first() is not None + or requestor.is_staff + ) + + member_of_a_different_org, member_of_this_org = self._get_org_membership( + domain_org, requested_email, requested_user + ) + + # determine portfolio of the domain (code currently is looking at requestor's portfolio) + # if requested_email/user is not member or invited member of this portfolio + # COMMENT: this code does not take into account multiple portfolios flag + # send portfolio invitation email + # create portfolio invitation + # create message to view + if ( + flag_is_active_for_user(requestor, "organization_feature") + and not flag_is_active_for_user(requestor, "multiple_portfolios") + and domain_org is not None + and requestor_can_update_portfolio + and not member_of_this_org + ): + try: + send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=domain_org) + PortfolioInvitation.objects.get_or_create(email=requested_email, portfolio=domain_org) + messages.success(self.request, f"{requested_email} has been invited to the organization: {domain_org}") + except Exception as e: + self._handle_portfolio_exceptions(e, requested_email, domain_org) + # If that first invite does not succeed take an early exit + return redirect(self.get_success_url()) + + try: + if requested_user is None: + self._handle_new_user_invitation(requested_email, requestor, member_of_a_different_org) + else: + self._handle_existing_user(requested_email, requestor, requested_user, member_of_a_different_org) + except Exception as e: + self._handle_exceptions(e, requested_email) + + return redirect(self.get_success_url()) + + def _get_requested_user(self, email): + """Retrieve a user by email or return None if the user doesn't exist.""" + try: + return User.objects.get(email=email) + except User.DoesNotExist: return None - # Check is user is a member or invited member of a different org from this domain's org - if flag_is_active_for_user(requestor, "organization_feature") and self._is_member_of_different_org( - email, requestor, requested_user - ): - add_success = False - raise OutsideOrgMemberError + def _handle_new_user_invitation(self, email, requestor, member_of_different_org): + """Handle invitation for a new user who does not exist in the system.""" + send_domain_invitation_email( + email=email, + requestor=requestor, + domain=self.object, + is_member_of_different_org=member_of_different_org, + ) + DomainInvitation.objects.get_or_create(email=email, domain=self.object) + messages.success(self.request, f"{email} has been invited to the domain: {self.object}") - # Check to see if an invite has already been sent - try: - invite = DomainInvitation.objects.get(email=email, domain=self.object) - # check if the invite has already been accepted or has a canceled invite - add_success = self._check_invite_status(invite, email) - except Exception: - logger.error("An error occured") + def _handle_existing_user(self, email, requestor, requested_user, member_of_different_org): + """Handle adding an existing user to the domain.""" + send_domain_invitation_email( + email=email, + requestor=requestor, + domain=self.object, + is_member_of_different_org=member_of_different_org, + ) + UserDomainRole.objects.create( + user=requested_user, + domain=self.object, + role=UserDomainRole.Roles.MANAGER, + ) + messages.success(self.request, f"Added user {email}.") - try: - send_templated_email( - "emails/domain_invitation.txt", - "emails/domain_invitation_subject.txt", - to_address=email, - context={ - "domain_url": self._domain_abs_url(), - "domain": self.object, - "requestor_email": requestor_email, - }, - ) - except EmailSendingError as exc: - logger.warn( - "Could not sent email invitation to %s for domain %s", + def _handle_exceptions(self, exception, email): + """Handle exceptions raised during the process.""" + if isinstance(exception, EmailSendingError): + logger.warning( + "Could not send email invitation to %s for domain %s (EmailSendingError)", email, self.object, exc_info=True, ) - logger.info(exc) - raise EmailSendingError("Could not send email invitation.") from exc - else: - if add_success: - messages.success(self.request, f"{email} has been invited to this domain.") - - def _make_invitation(self, email_address: str, requestor: User): - """Make a Domain invitation for this email and redirect with a message.""" - try: - self._send_domain_invitation_email(email=email_address, requestor=requestor) - except EmailSendingError: messages.warning(self.request, "Could not send email invitation.") + elif isinstance(exception, OutsideOrgMemberError): + logger.warning( + "Could not send email. Can not invite member of a .gov organization to a different organization.", + self.object, + exc_info=True, + ) + messages.error( + self.request, + f"{email} is already a member of another .gov organization.", + ) + elif isinstance(exception, AlreadyDomainManagerError): + messages.warning(self.request, str(exception)) + elif isinstance(exception, AlreadyDomainInvitedError): + messages.warning(self.request, str(exception)) + elif isinstance(exception, MissingEmailError): + messages.error(self.request, str(exception)) + logger.error( + f"Can't send email to '{email}' on domain '{self.object}'. No email exists for the requestor.", + exc_info=True, + ) + elif isinstance(exception, IntegrityError): + messages.warning(self.request, f"{email} is already a manager for this domain") else: - # (NOTE: only create a domainInvitation if the e-mail sends correctly) - DomainInvitation.objects.get_or_create(email=email_address, domain=self.object) - return redirect(self.get_success_url()) + logger.warning("Could not send email invitation (Other Exception)", exc_info=True) + messages.warning(self.request, "Could not send email invitation.") - def form_valid(self, form): - """Add the specified user on this domain. - Throws EmailSendingError.""" - requested_email = form.cleaned_data["email"] - requestor = self.request.user - email_success = False - # look up a user with that email - try: - requested_user = User.objects.get(email=requested_email) - except User.DoesNotExist: - # no matching user, go make an invitation - email_success = True - return self._make_invitation(requested_email, requestor) + def _handle_portfolio_exceptions(self, exception, email, portfolio): + """Handle exceptions raised during the process.""" + if isinstance(exception, EmailSendingError): + logger.warning("Could not send email invitation (EmailSendingError)", exc_info=True) + messages.warning(self.request, "Could not send email invitation.") + elif isinstance(exception, MissingEmailError): + messages.error(self.request, str(exception)) + logger.error( + f"Can't send email to '{email}' for portfolio '{portfolio}'. No email exists for the requestor.", + exc_info=True, + ) else: - # if user already exists then just send an email - try: - self._send_domain_invitation_email( - requested_email, requestor, requested_user=requested_user, add_success=False - ) - email_success = True - except EmailSendingError: - logger.warn( - "Could not send email invitation (EmailSendingError)", - self.object, - exc_info=True, - ) - messages.warning(self.request, "Could not send email invitation.") - email_success = True - except OutsideOrgMemberError: - logger.warn( - "Could not send email. Can not invite member of a .gov organization to a different organization.", - self.object, - exc_info=True, - ) - messages.error( - self.request, - f"{requested_email} is already a member of another .gov organization.", - ) - except Exception: - logger.warn( - "Could not send email invitation (Other Exception)", - self.object, - exc_info=True, - ) - messages.warning(self.request, "Could not send email invitation.") - if email_success: - try: - UserDomainRole.objects.create( - user=requested_user, - domain=self.object, - role=UserDomainRole.Roles.MANAGER, - ) - messages.success(self.request, f"Added user {requested_email}.") - except IntegrityError: - messages.warning(self.request, f"{requested_email} is already a manager for this domain") - - return redirect(self.get_success_url()) + logger.warning("Could not send email invitation (Other Exception)", exc_info=True) + messages.warning(self.request, "Could not send email invitation.") class DomainInvitationCancelView(SuccessMessageMixin, DomainInvitationPermissionCancelView): diff --git a/src/registrar/views/member_domains_json.py b/src/registrar/views/member_domains_json.py index 125059692..3d24336bb 100644 --- a/src/registrar/views/member_domains_json.py +++ b/src/registrar/views/member_domains_json.py @@ -90,7 +90,9 @@ class PortfolioMemberDomainsJson(PortfolioMemberDomainsPermission, View): domain_info_ids = DomainInformation.objects.filter(portfolio=portfolio).values_list( "domain_id", flat=True ) - domain_invitations = DomainInvitation.objects.filter(email=email).values_list("domain_id", flat=True) + domain_invitations = DomainInvitation.objects.filter( + email=email, status=DomainInvitation.DomainInvitationStatus.INVITED + ).values_list("domain_id", flat=True) return domain_info_ids.intersection(domain_invitations) else: domain_infos = DomainInformation.objects.filter(portfolio=portfolio) diff --git a/src/registrar/views/portfolio_members_json.py b/src/registrar/views/portfolio_members_json.py index b5c608eab..11e58e112 100644 --- a/src/registrar/views/portfolio_members_json.py +++ b/src/registrar/views/portfolio_members_json.py @@ -12,6 +12,7 @@ from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.views.utility.mixins import PortfolioMembersPermission from registrar.models.utility.orm_helper import ArrayRemoveNull +from django.contrib.postgres.aggregates import StringAgg class PortfolioMembersJson(PortfolioMembersPermission, View): @@ -119,11 +120,22 @@ class PortfolioMembersJson(PortfolioMembersPermission, View): def initial_invitations_search(self, portfolio): """Perform initial invitations search and get related DomainInvitation data based on the email.""" - # Get DomainInvitation query for matching email and for the portfolio - domain_invitations = DomainInvitation.objects.filter( - email=OuterRef("email"), # Check if email matches the OuterRef("email") - domain__domain_info__portfolio=portfolio, # Check if the domain's portfolio matches the given portfolio - ).annotate(domain_info=Concat(F("domain__id"), Value(":"), F("domain__name"), output_field=CharField())) + + # Subquery to get concatenated domain information for each email + domain_invitations = ( + DomainInvitation.objects.filter(email=OuterRef("email"), domain__domain_info__portfolio=portfolio) + .annotate( + concatenated_info=Concat(F("domain__id"), Value(":"), F("domain__name"), output_field=CharField()) + ) + .values("concatenated_info") + ) + + concatenated_domain_info = ( + domain_invitations.values("email") + .annotate(domain_info=StringAgg("concatenated_info", delimiter=", ")) + .values("domain_info") + ) + # PortfolioInvitation query invitations = PortfolioInvitation.objects.filter(portfolio=portfolio) invitations = invitations.annotate( @@ -136,7 +148,12 @@ class PortfolioMembersJson(PortfolioMembersPermission, View): # Use ArrayRemove to return an empty list when no domain invitations are found domain_info=ArrayRemoveNull( ArrayAgg( - Subquery(domain_invitations.values("domain_info")), + # We've pre-concatenated the domain infos to limit the subquery to return a single virtual 'row', + # otherwise we'll trigger a "more than one row returned by a subquery used as an expression" + # when an email matches multiple domain invitations. + # We'll take care when processing the list of one single concatenated items item + # in serialize_members. + Subquery(concatenated_domain_info), distinct=True, ) ), @@ -153,6 +170,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View): "domain_info", "type", ) + return invitations def apply_search_term(self, queryset, request): @@ -190,10 +208,19 @@ class PortfolioMembersJson(PortfolioMembersPermission, View): is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in (item.get("roles") or []) action_url = reverse(item["type"], kwargs={"pk": item["id"]}) + item_type = item.get("type", "") + + # Ensure domain_info is properly processed for invites - + # we need to un-concatenate the subquery + domain_info_list = item.get("domain_info", []) + if item_type == "invitedmember" and isinstance(domain_info_list, list) and domain_info_list: + # Split the first item in the list if it exists + domain_info_list = domain_info_list[0].split(", ") + # Serialize member data member_json = { "id": item.get("id", ""), # id is id of UserPortfolioPermission or PortfolioInvitation - "type": item.get("type", ""), # source is member or invitedmember + "type": item_type, # source is member or invitedmember "name": " ".join(filter(None, [item.get("first_name", ""), item.get("last_name", "")])), "email": item.get("email_display", ""), "member_display": item.get("member_display", ""), @@ -203,9 +230,9 @@ class PortfolioMembersJson(PortfolioMembersPermission, View): ), # split domain_info array values into ids to form urls, and names "domain_urls": [ - reverse("domain", kwargs={"pk": domain_info.split(":")[0]}) for domain_info in item.get("domain_info") + reverse("domain", kwargs={"pk": domain_info.split(":")[0]}) for domain_info in domain_info_list ], - "domain_names": [domain_info.split(":")[1] for domain_info in item.get("domain_info")], + "domain_names": [domain_info.split(":")[1] for domain_info in domain_info_list], "is_admin": is_admin, "last_active": item.get("last_active"), "action_url": action_url, diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index 8e1df48f3..751e28d85 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -1,5 +1,5 @@ +import json import logging -from django.conf import settings from django.http import Http404, JsonResponse from django.shortcuts import get_object_or_404, redirect, render @@ -8,10 +8,15 @@ from django.utils.safestring import mark_safe from django.contrib import messages from registrar.forms import portfolio as portfolioForms from registrar.models import Portfolio, User +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.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.utility.email import EmailSendingError +from registrar.utility.email_invitations import send_portfolio_invitation_email +from registrar.utility.errors import MissingEmailError +from registrar.utility.enums import DefaultUserValues from registrar.views.utility.mixins import PortfolioMemberPermission from registrar.views.utility.permission_views import ( PortfolioDomainRequestsPermissionView, @@ -26,6 +31,7 @@ from registrar.views.utility.permission_views import ( ) from django.views.generic import View from django.views.generic.edit import FormMixin +from django.db import IntegrityError logger = logging.getLogger(__name__) @@ -145,7 +151,7 @@ class PortfolioMemberDeleteView(PortfolioMemberPermission, View): class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View): template_name = "portfolio_member_permissions.html" - form_class = portfolioForms.BasePortfolioMemberForm + form_class = portfolioForms.PortfolioMemberForm def get(self, request, pk): portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk) @@ -164,13 +170,14 @@ class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View): def post(self, request, pk): portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk) + user_initially_is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_permission.roles user = portfolio_permission.user form = self.form_class(request.POST, instance=portfolio_permission) if form.is_valid(): # Check if user is removing their own admin or edit role removing_admin_role_on_self = ( request.user == user - and UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_permission.roles + and user_initially_is_admin and UserPortfolioRoleChoices.ORGANIZATION_ADMIN not in form.cleaned_data.get("role", []) ) form.save() @@ -222,6 +229,86 @@ class PortfolioMemberDomainsEditView(PortfolioMemberDomainsEditPermissionView, V }, ) + def post(self, request, pk): + """ + Handles adding and removing domains for a portfolio member. + """ + added_domains = request.POST.get("added_domains") + removed_domains = request.POST.get("removed_domains") + portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk) + member = portfolio_permission.user + + added_domain_ids = self._parse_domain_ids(added_domains, "added domains") + if added_domain_ids is None: + return redirect(reverse("member-domains", kwargs={"pk": pk})) + + removed_domain_ids = self._parse_domain_ids(removed_domains, "removed domains") + if removed_domain_ids is None: + return redirect(reverse("member-domains", kwargs={"pk": pk})) + + if added_domain_ids or removed_domain_ids: + try: + self._process_added_domains(added_domain_ids, member) + self._process_removed_domains(removed_domain_ids, member) + messages.success(request, "The domain assignment changes have been saved.") + return redirect(reverse("member-domains", kwargs={"pk": pk})) + except IntegrityError: + messages.error( + request, + "A database error occurred while saving changes. If the issue persists, " + f"please contact {DefaultUserValues.HELP_EMAIL}.", + ) + logger.error("A database error occurred while saving changes.") + return redirect(reverse("member-domains-edit", kwargs={"pk": pk})) + except Exception as e: + messages.error( + request, + "An unexpected error occurred: {str(e)}. If the issue persists, " + f"please contact {DefaultUserValues.HELP_EMAIL}.", + ) + logger.error(f"An unexpected error occurred: {str(e)}") + return redirect(reverse("member-domains-edit", kwargs={"pk": pk})) + else: + messages.info(request, "No changes detected.") + return redirect(reverse("member-domains", kwargs={"pk": pk})) + + def _parse_domain_ids(self, domain_data, domain_type): + """ + Parses the domain IDs from the request and handles JSON errors. + """ + try: + return json.loads(domain_data) if domain_data else [] + except json.JSONDecodeError: + messages.error( + self.request, + f"Invalid data for {domain_type}. If the issue persists, " + f"please contact {DefaultUserValues.HELP_EMAIL}.", + ) + logger.error(f"Invalid data for {domain_type}") + return None + + def _process_added_domains(self, added_domain_ids, member): + """ + Processes added domains by bulk creating UserDomainRole instances. + """ + if added_domain_ids: + # Bulk create UserDomainRole instances for added domains + UserDomainRole.objects.bulk_create( + [ + UserDomainRole(domain_id=domain_id, user=member, role=UserDomainRole.Roles.MANAGER) + for domain_id in added_domain_ids + ], + ignore_conflicts=True, # Avoid duplicate entries + ) + + def _process_removed_domains(self, removed_domain_ids, member): + """ + Processes removed domains by deleting corresponding UserDomainRole instances. + """ + if removed_domain_ids: + # Delete UserDomainRole instances for removed domains + UserDomainRole.objects.filter(domain_id__in=removed_domain_ids, user=member).delete() + class PortfolioInvitedMemberView(PortfolioMemberPermissionView, View): @@ -284,7 +371,7 @@ class PortfolioInvitedMemberDeleteView(PortfolioMemberPermission, View): class PortfolioInvitedMemberEditView(PortfolioMemberEditPermissionView, View): template_name = "portfolio_member_permissions.html" - form_class = portfolioForms.BasePortfolioMemberForm + form_class = portfolioForms.PortfolioInvitedMemberForm def get(self, request, pk): portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk) @@ -348,6 +435,106 @@ class PortfolioInvitedMemberDomainsEditView(PortfolioMemberDomainsEditPermission }, ) + def post(self, request, pk): + """ + Handles adding and removing domains for a portfolio invitee. + """ + added_domains = request.POST.get("added_domains") + removed_domains = request.POST.get("removed_domains") + portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk) + email = portfolio_invitation.email + + added_domain_ids = self._parse_domain_ids(added_domains, "added domains") + if added_domain_ids is None: + return redirect(reverse("invitedmember-domains", kwargs={"pk": pk})) + + removed_domain_ids = self._parse_domain_ids(removed_domains, "removed domains") + if removed_domain_ids is None: + return redirect(reverse("invitedmember-domains", kwargs={"pk": pk})) + + if added_domain_ids or removed_domain_ids: + try: + self._process_added_domains(added_domain_ids, email) + self._process_removed_domains(removed_domain_ids, email) + messages.success(request, "The domain assignment changes have been saved.") + return redirect(reverse("invitedmember-domains", kwargs={"pk": pk})) + except IntegrityError: + messages.error( + request, + "A database error occurred while saving changes. If the issue persists, " + f"please contact {DefaultUserValues.HELP_EMAIL}.", + ) + logger.error("A database error occurred while saving changes.") + return redirect(reverse("invitedmember-domains-edit", kwargs={"pk": pk})) + except Exception as e: + messages.error( + request, + "An unexpected error occurred: {str(e)}. If the issue persists, " + f"please contact {DefaultUserValues.HELP_EMAIL}.", + ) + logger.error(f"An unexpected error occurred: {str(e)}.") + return redirect(reverse("invitedmember-domains-edit", kwargs={"pk": pk})) + else: + messages.info(request, "No changes detected.") + return redirect(reverse("invitedmember-domains", kwargs={"pk": pk})) + + def _parse_domain_ids(self, domain_data, domain_type): + """ + Parses the domain IDs from the request and handles JSON errors. + """ + try: + return json.loads(domain_data) if domain_data else [] + except json.JSONDecodeError: + messages.error( + self.request, + f"Invalid data for {domain_type}. If the issue persists, " + f"please contact {DefaultUserValues.HELP_EMAIL}.", + ) + logger.error(f"Invalid data for {domain_type}.") + return None + + def _process_added_domains(self, added_domain_ids, email): + """ + Processes added domain invitations by updating existing invitations + or creating new ones. + """ + if not added_domain_ids: + return + + # Update existing invitations from CANCELED to INVITED + existing_invitations = DomainInvitation.objects.filter(domain_id__in=added_domain_ids, email=email) + existing_invitations.update(status=DomainInvitation.DomainInvitationStatus.INVITED) + + # Determine which domains need new invitations + existing_domain_ids = existing_invitations.values_list("domain_id", flat=True) + new_domain_ids = set(added_domain_ids) - set(existing_domain_ids) + + # Bulk create new invitations + DomainInvitation.objects.bulk_create( + [ + DomainInvitation( + domain_id=domain_id, + email=email, + status=DomainInvitation.DomainInvitationStatus.INVITED, + ) + for domain_id in new_domain_ids + ] + ) + + def _process_removed_domains(self, removed_domain_ids, email): + """ + Processes removed domain invitations by updating their status to CANCELED. + """ + if not removed_domain_ids: + return + + # Update invitations from INVITED to CANCELED + DomainInvitation.objects.filter( + domain_id__in=removed_domain_ids, + email=email, + status=DomainInvitation.DomainInvitationStatus.INVITED, + ).update(status=DomainInvitation.DomainInvitationStatus.CANCELED) + class PortfolioNoDomainsView(NoPortfolioDomainsPermissionView, View): """Some users have access to the underlying portfolio, but not any domains. @@ -509,34 +696,27 @@ class PortfolioMembersView(PortfolioMembersPermissionView, View): return render(request, "portfolio_members.html") -class NewMemberView(PortfolioMembersPermissionView, FormMixin): +class PortfolioAddMemberView(PortfolioMembersPermissionView, FormMixin): template_name = "portfolio_members_add_new.html" - form_class = portfolioForms.NewMemberForm - - def get_object(self, queryset=None): - """Get the portfolio object based on the session.""" - portfolio = self.request.session.get("portfolio") - if portfolio is None: - raise Http404("No organization found for this user") - return portfolio - - def get_form_kwargs(self): - """Include the instance in the form kwargs.""" - kwargs = super().get_form_kwargs() - kwargs["instance"] = self.get_object() - return kwargs + form_class = portfolioForms.PortfolioNewMemberForm def get(self, request, *args, **kwargs): """Handle GET requests to display the form.""" - self.object = self.get_object() + self.object = None # No existing PortfolioInvitation instance form = self.get_form() return self.render_to_response(self.get_context_data(form=form)) def post(self, request, *args, **kwargs): """Handle POST requests to process form submission.""" - self.object = self.get_object() - form = self.get_form() + self.object = None # For a new invitation, there's no existing model instance + + # portfolio not submitted with form, so override the value + data = request.POST.copy() + if not data.get("portfolio"): + data["portfolio"] = self.request.session.get("portfolio").id + # Pass the modified data to the form + form = portfolioForms.PortfolioNewMemberForm(data) if form.is_valid(): return self.form_valid(form) @@ -553,7 +733,7 @@ class NewMemberView(PortfolioMembersPermissionView, FormMixin): return super().form_invalid(form) # Handle non-AJAX requests normally def form_valid(self, form): - + super().form_valid(form) if self.is_ajax(): return JsonResponse({"is_valid": True}) # Return a JSON response else: @@ -563,108 +743,42 @@ class NewMemberView(PortfolioMembersPermissionView, FormMixin): """Redirect to members table.""" return reverse("members") - def _send_portfolio_invitation_email(self, email: str, requestor: User, add_success=True): - """Performs the sending of the member invitation email - email: string- email to send to - add_success: bool- default True indicates: - adding a success message to the view if the email sending succeeds - - raises EmailSendingError - """ - - # Set a default email address to send to for staff - requestor_email = settings.DEFAULT_FROM_EMAIL - - # Check if the email requestor has a valid email address - if not requestor.is_staff and requestor.email is not None and requestor.email.strip() != "": - requestor_email = requestor.email - elif not requestor.is_staff: - messages.error(self.request, "Can't send invitation email. No email is associated with your account.") - logger.error( - f"Can't send email to '{email}' on domain '{self.object}'." - f"No email exists for the requestor '{requestor.username}'.", - exc_info=True, - ) - return None - - # Check to see if an invite has already been sent - try: - invite = PortfolioInvitation.objects.get(email=email, portfolio=self.object) - if invite: # We have an existin invite - # check if the invite has already been accepted - if invite.status == PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED: - add_success = False - messages.warning( - self.request, - f"{email} is already a manager for this portfolio.", - ) - else: - add_success = False - # it has been sent but not accepted - messages.warning(self.request, f"{email} has already been invited to this portfolio") - return - except Exception as err: - logger.error(f"_send_portfolio_invitation_email() => An error occured: {err}") - - try: - logger.debug("requestor email: " + requestor_email) - - # send_templated_email( - # "emails/portfolio_invitation.txt", - # "emails/portfolio_invitation_subject.txt", - # to_address=email, - # context={ - # "portfolio": self.object, - # "requestor_email": requestor_email, - # }, - # ) - except EmailSendingError as exc: - logger.warn( - "Could not sent email invitation to %s for domain %s", - email, - self.object, - exc_info=True, - ) - raise EmailSendingError("Could not send email invitation.") from exc - else: - if add_success: - messages.success(self.request, f"{email} has been invited.") - - def _make_invitation(self, email_address: str, requestor: User, add_success=True): - """Make a Member invitation for this email and redirect with a message.""" - try: - self._send_portfolio_invitation_email(email=email_address, requestor=requestor, add_success=add_success) - except EmailSendingError: - logger.warn( - "Could not send email invitation (EmailSendingError)", - self.object, - exc_info=True, - ) - messages.warning(self.request, "Could not send email invitation.") - except Exception: - logger.warn( - "Could not send email invitation (Other Exception)", - self.object, - exc_info=True, - ) - messages.warning(self.request, "Could not send email invitation.") - else: - # (NOTE: only create a MemberInvitation if the e-mail sends correctly) - PortfolioInvitation.objects.get_or_create(email=email_address, portfolio=self.object) - return redirect(self.get_success_url()) - def submit_new_member(self, form): - """Add the specified user as a member - for this portfolio. - Throws EmailSendingError.""" + """Add the specified user as a member for this portfolio.""" requested_email = form.cleaned_data["email"] requestor = self.request.user + portfolio = form.cleaned_data["portfolio"] requested_user = User.objects.filter(email=requested_email).first() - permission_exists = UserPortfolioPermission.objects.filter(user=requested_user, portfolio=self.object).exists() - if not requested_user or not permission_exists: - return self._make_invitation(requested_email, requestor) - else: - if permission_exists: - messages.warning(self.request, "User is already a member of this portfolio.") + permission_exists = UserPortfolioPermission.objects.filter(user=requested_user, portfolio=portfolio).exists() + try: + if not requested_user or not permission_exists: + send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=portfolio) + form.save() + messages.success(self.request, f"{requested_email} has been invited.") + else: + if permission_exists: + messages.warning(self.request, "User is already a member of this portfolio.") + except Exception as e: + self._handle_exceptions(e, portfolio, requested_email) return redirect(self.get_success_url()) + + def _handle_exceptions(self, exception, portfolio, email): + """Handle exceptions raised during the process.""" + if isinstance(exception, EmailSendingError): + logger.warning( + "Could not sent email invitation to %s for portfolio %s (EmailSendingError)", + email, + portfolio, + exc_info=True, + ) + messages.warning(self.request, "Could not send email invitation.") + elif isinstance(exception, MissingEmailError): + messages.error(self.request, str(exception)) + logger.error( + f"Can't send email to '{email}' for portfolio '{portfolio}'. No email exists for the requestor.", + exc_info=True, + ) + else: + logger.warning("Could not send email invitation (Other Exception)", exc_info=True) + messages.warning(self.request, "Could not send email invitation.") diff --git a/src/registrar/views/report_views.py b/src/registrar/views/report_views.py index 1b1798d69..694d1e205 100644 --- a/src/registrar/views/report_views.py +++ b/src/registrar/views/report_views.py @@ -203,7 +203,7 @@ class ExportDataTypeRequests(View): def get(self, request, *args, **kwargs): response = HttpResponse(content_type="text/csv") response["Content-Disposition"] = 'attachment; filename="domain-requests.csv"' - csv_export.DomainRequestsDataType.exporting_dr_data_to_csv(response, request=request) + csv_export.DomainRequestDataType.export_data_to_csv(response, request=request) return response diff --git a/src/requirements.txt b/src/requirements.txt index 1a9282591..c3ed17604 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1,68 +1,68 @@ -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.41; python_version >= '3.8' -botocore==1.35.41; 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.8.30; python_version >= '3.6' +certifi==2024.12.14; python_version >= '3.6' cfenv==0.5.3 -cffi==1.17.1; platform_python_implementation != 'PyPy' -charset-normalizer==3.4.0; python_full_version >= '3.7.0' -cryptography==43.0.1; python_version >= '3.7' +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' 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==20230430; python_version >= '3.7' -dj-database-url==2.2.0 +diff-match-patch==20241021; python_version >= '3.7' +dj-database-url==2.3.0 dj-email-url==1.0.6 django==4.2.17; python_version >= '3.8' 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.5.0; python_version >= '3.9' +django-cors-headers==4.6.0; python_version >= '3.9' django-csp==3.8 django-fsm==2.8.1 -django-import-export==4.1.1; python_version >= '3.8' +django-import-export==4.3.3; python_version >= '3.9' django-login-required-middleware==0.9.0 django-phonenumber-field[phonenumberslite]==8.0.0; python_version >= '3.8' -django-waffle==4.1.0; 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.0.0; python_version >= '3.8' -faker==30.3.0; python_version >= '3.8' -fred-epplib@ git+https://github.com/cisagov/epplib.git@d56d183f1664f34c40ca9716a3a9a345f0ef561c +environs[django]==11.2.1; python_version >= '3.8' +faker==33.1.0; python_version >= '3.8' +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, 3.3' -gevent==24.10.2; python_version >= '3.9' +future==1.0.0; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2' +gevent==24.11.1; python_version >= '3.9' 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.5; python_version >= '3.8' -markupsafe==3.0.1; python_version >= '3.9' -marshmallow==3.22.0; python_version >= '3.8' +mako==1.3.8; python_version >= '3.8' +markupsafe==3.0.2; python_version >= '3.9' +marshmallow==3.23.2; python_version >= '3.9' oic==1.7.0; python_version ~= '3.8' orderedmultidict==1.0.1 -packaging==24.1; python_version >= '3.8' -phonenumberslite==8.13.47 -psycopg2-binary==2.9.9; python_version >= '3.7' +packaging==24.2; python_version >= '3.8' +phonenumberslite==8.13.52 +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.9.2; python_version >= '3.8' -pydantic-core==2.23.4; python_version >= '3.8' -pydantic-settings==2.5.2; python_version >= '3.8' +pydantic==2.10.4; 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 -python-dateutil==2.9.0.post0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' +python-dateutil==2.9.0.post0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2' 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.3; python_version >= '3.8' -setuptools==75.1.0; python_version >= '3.8' -six==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' -sqlparse==0.5.1; python_version >= '3.8' -tablib[html,ods,xls,xlsx,yaml]==3.5.0; python_version >= '3.8' +s3transfer==0.10.4; python_version >= '3.8' +setuptools==75.6.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' tblib==3.0.0; python_version >= '3.8' typing-extensions==4.12.2; python_version >= '3.8' -urllib3==2.2.3; python_version >= '3.8' -whitenoise==6.7.0; python_version >= '3.8' +urllib3==2.3.0; python_version >= '3.9' +whitenoise==6.8.2; python_version >= '3.9' zope.event==5.0; python_version >= '3.7' -zope.interface==7.1.0; python_version >= '3.8' +zope.interface==7.2; python_version >= '3.8'