diff --git a/.github/workflows/security-check.yaml b/.github/workflows/security-check.yaml index aea700613..bf0498fff 100644 --- a/.github/workflows/security-check.yaml +++ b/.github/workflows/security-check.yaml @@ -2,17 +2,9 @@ name: Security checks on: push: - paths-ignore: - - 'docs/**' - - '**.md' - - '.gitignore' branches: - main pull_request: - paths-ignore: - - 'docs/**' - - '**.md' - - '.gitignore' branches: - main diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 642e9dc30..6332956f8 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -3,10 +3,6 @@ name: Testing on: push: - paths-ignore: - - 'docs/**' - - '**.md' - - '.gitignore' branches: - main pull_request: 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/docs/developer/cloning-databases.md b/docs/developer/cloning-databases.md new file mode 100644 index 000000000..3c8a3c3fa --- /dev/null +++ b/docs/developer/cloning-databases.md @@ -0,0 +1,17 @@ +# Cloning Databases +The clone-db workflow clones a Source database to a Destination database using cloud.gov's cg-manage-rds tool. This document contains additional information needed to understand how the workflow functions. + +## Additional Roles Required +The clone-db workflow functions by temporarily sharing the Destination database with the space of the Source database. This is because cloning databases across spaces is hard. Sharing is done via the `cf share-service` command, but requires that the authenticated user (in this case this will be a user from the Source space) have the `space-developer` role in *both* the Source and Destination spaces. This must be set by someone with permission to edit space roles *before* the workflow runs. The user in question can be found using the `cf space-users [ORG] [SPACE]` command where the SPACE is the Source space, and will appear as a UAA user with a UUID as the name. There is only one such user per space by default (this is a [service account](https://cloud.gov/docs/services/cloud-gov-service-account/) set up by cloud.gov for our Github workflows). This user needs to be provided with the `space-developer` role in the Destination space, which can be accomplished using `cf set-space-role [USER] [ORG] [DESTINATION SPACE] SpaceDeveloper`. + +## Turning Off DB Cloning Fast (For Emergencies or other Scenarios) +Note: In less urgent situations it may be better to make a PR removing the scheduled workflow trigger. + +Step 1: +Get the name of the correct service using `cf spaces-users cisa-dotgov stable`. There should only be one user with a name that is a UUID, that is the one you want. + +step 2: +Remove the space developer role by doing the following command: +`cf unset-space-role [USER] cisa-dotgov staging SpaceDeveloper` + +This will cause the job to fail without requiring pushing anything to main. diff --git a/docs/developer/registry-access.md b/docs/developer/registry-access.md index c7737d5bc..50caa4823 100644 --- a/docs/developer/registry-access.md +++ b/docs/developer/registry-access.md @@ -103,3 +103,31 @@ response = registry._client.transport.receive() ``` This is helpful for debugging situations where epplib is not correctly or fully parsing the XML returned from the registry. + +### Adding in a expiring soon domain +The below scenario is if you are NOT in org model mode (`organization_feature` waffle flag is off). + +1. Go to the `staging` sandbox and to `/admin` +2. Go to Domains and find a domain that is actually expired by sorting the Expiration Date column +3. Click into the domain to check the expiration date +4. Click into Manage Domain to double check the expiration date as well +5. Now hold onto that domain name, and save it for the command below + +6. In a terminal, run these commands: +``` +cf ssh getgov- +/tmp/lifecycle/shell +./manage.py shell +from registrar.models import Domain, DomainInvitation +from registrar.models import User +user = User.objects.filter(first_name="") +domain = Domain.objects.get_or_create(name="") +``` + +7. Go back to `/admin` and create Domain Information for that domain you just added in via the terminal +8. Go to Domain to find it +9. Click Manage Domain +10. Add yourself as domain manager +11. Go to the Registrar page and you should now see the expiring domain + +If you want to be in the org model mode, turn the `organization_feature` waffle flag on, and add that domain via Django Admin to a portfolio to be able to view it. \ No newline at end of file diff --git a/docs/operations/data_migration.md b/docs/operations/data_migration.md index 0863aa0b7..cdef3dba7 100644 --- a/docs/operations/data_migration.md +++ b/docs/operations/data_migration.md @@ -907,14 +907,87 @@ Example (only requests): `./manage.py create_federal_portfolio --branch "executi ```docker-compose exec app ./manage.py create_federal_portfolio --agency_name "{federal_agency_name}" --both``` ##### Parameters -| | Parameter | Description | -|:-:|:-------------------------- |:-------------------------------------------------------------------------------------------| -| 1 | **agency_name** | Name of the FederalAgency record surrounded by quotes. For instance,"AMTRAK". | -| 2 | **branch** | Creates a portfolio for each federal agency in a branch: executive, legislative, judicial | -| 3 | **both** | If True, runs parse_requests and parse_domains. | -| 4 | **parse_requests** | If True, then the created portfolio is added to all related DomainRequests. | -| 5 | **parse_domains** | If True, then the created portfolio is added to all related Domains. | +| | Parameter | Description | +|:-:|:---------------------------- |:-------------------------------------------------------------------------------------------| +| 1 | **agency_name** | Name of the FederalAgency record surrounded by quotes. For instance,"AMTRAK". | +| 2 | **branch** | Creates a portfolio for each federal agency in a branch: executive, legislative, judicial | +| 3 | **both** | If True, runs parse_requests and parse_domains. | +| 4 | **parse_requests** | If True, then the created portfolio is added to all related DomainRequests. | +| 5 | **parse_domains** | If True, then the created portfolio is added to all related Domains. | +| 6 | **skip_existing_portfolios** | If True, then the script will only create suborganizations, modify DomainRequest, and modify DomainInformation records only when creating a new portfolio. Use this flag when you do not want to modify existing records. | - Parameters #1-#2: Either `--agency_name` or `--branch` must be specified. Not both. - Parameters #2-#3, you cannot use `--both` while using these. You must specify either `--parse_requests` or `--parse_domains` seperately. While all of these parameters are optional in that you do not need to specify all of them, you must specify at least one to run this script. + + +## Patch suborganizations +This script deletes some duplicate suborganization data that exists in our database (one-time use). +It works in two ways: +1. If the only name difference between two suborg records is extra spaces or a capitalization difference, +then we delete all duplicate records of this type. +2. If the suborg name is one we manually specify to delete via the script. + +Before it deletes records, it goes through each DomainInformation and DomainRequest object and updates the reference to "sub_organization" to match the non-duplicative record. + +### Running on sandboxes + +#### Step 1: Login to CloudFoundry +```cf login -a api.fr.cloud.gov --sso``` + +#### Step 2: SSH into your environment +```cf ssh getgov-{space}``` + +Example: `cf ssh getgov-za` + +#### Step 3: Create a shell instance +```/tmp/lifecycle/shell``` + +#### Step 4: Upload your csv to the desired sandbox +[Follow these steps](#use-scp-to-transfer-data-to-sandboxes) to upload the federal_cio csv to a sandbox of your choice. + +#### Step 5: Running the script +To create a specific portfolio: +```./manage.py patch_suborganizations``` + +### Running locally + +#### Step 1: Running the script +```docker-compose exec app ./manage.py patch_suborganizations``` + + +## Remove Non-whitelisted Portfolios +This script removes Portfolio entries from the database that are not part of a predefined list of allowed portfolios (`ALLOWED_PORTFOLIOS`). +It performs the following actions: +1. Prompts the user for confirmation before proceeding with deletions. +2. Updates related objects such as `DomainInformation`, `Domain`, and `DomainRequest` to set their `portfolio` field to `None` to prevent integrity errors. +3. Deletes associated objects such as `PortfolioInvitation`, `UserPortfolioPermission`, and `Suborganization`. +4. Logs a detailed summary of all cascading deletions and orphaned objects. + +### Running on sandboxes + +#### Step 1: Login to CloudFoundry +```cf login -a api.fr.cloud.gov --sso``` + +#### Step 2: SSH into your environment +```cf ssh getgov-{space}``` + +Example: `cf ssh getgov-nl` + +#### Step 3: Create a shell instance +```/tmp/lifecycle/shell``` + +#### Step 4: Running the script +To remove portfolios: +```./manage.py remove_unused_portfolios``` + +If you wish to enable debug mode for additional logging: +```./manage.py remove_unused_portfolios --debug``` + +### Running locally + +#### Step 1: Running the script +```docker-compose exec app ./manage.py remove_unused_portfolios``` + +To enable debug mode locally: +```docker-compose exec app ./manage.py remove_unused_portfolios --debug``` \ No newline at end of file diff --git a/src/Pipfile b/src/Pipfile index fdf127d7c..07b1db715 100644 --- a/src/Pipfile +++ b/src/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -django = "4.2.10" +django = "4.2.17" cfenv = "*" django-cors-headers = "*" pycryptodomex = "*" diff --git a/src/Pipfile.lock b/src/Pipfile.lock index 33b858314..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,35 +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:005da479b04134cdd9dd602d1ee7c49d79de0537610d653674cc6cbde222b8a1", - "sha256:2a06f242f4ba0cc563444ca3d1998959447477363a2d7e9b8b4d75d35cfd1669" + "sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f", + "sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e" ], - "markers": "python_full_version >= '3.8.0'", - "version": "==3.0.0" + "markers": "python_full_version >= '3.9.0'", + "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/djangooidc/backends.py b/src/djangooidc/backends.py index 41e442f2d..38fde0ced 100644 --- a/src/djangooidc/backends.py +++ b/src/djangooidc/backends.py @@ -21,49 +21,66 @@ class OpenIdConnectBackend(ModelBackend): """ def authenticate(self, request, **kwargs): - logger.debug("kwargs %s" % kwargs) - user = None - if not kwargs or "sub" not in kwargs.keys(): - return user + logger.debug("kwargs %s", kwargs) + + if not kwargs or "sub" not in kwargs: + return None UserModel = get_user_model() username = self.clean_username(kwargs["sub"]) + openid_data = self.extract_openid_data(kwargs) - # Some OP may actually choose to withhold some information, so we must - # test if it is present - openid_data = {"last_login": timezone.now()} - openid_data["first_name"] = kwargs.get("given_name", "") - openid_data["last_name"] = kwargs.get("family_name", "") - openid_data["email"] = kwargs.get("email", "") - openid_data["phone"] = kwargs.get("phone", "") - - # Note that this could be accomplished in one try-except clause, but - # instead we use get_or_create when creating unknown users since it has - # built-in safeguards for multiple threads. if getattr(settings, "OIDC_CREATE_UNKNOWN_USER", True): - args = { - UserModel.USERNAME_FIELD: username, - # defaults _will_ be updated, these are not fallbacks - "defaults": openid_data, - } - - user, created = UserModel.objects.get_or_create(**args) - - if not created: - # If user exists, update existing user - self.update_existing_user(user, args["defaults"]) - else: - # If user is created, configure the user - user = self.configure_user(user, **kwargs) + user = self.get_or_create_user(UserModel, username, openid_data, kwargs) else: - try: - user = UserModel.objects.get_by_natural_key(username) - except UserModel.DoesNotExist: - return None - # run this callback for a each login - user.on_each_login() + user = self.get_user_by_username(UserModel, username) + + if user: + user.on_each_login() + return user + def extract_openid_data(self, kwargs): + """Extract OpenID data from authentication kwargs.""" + return { + "last_login": timezone.now(), + "first_name": kwargs.get("given_name", ""), + "last_name": kwargs.get("family_name", ""), + "email": kwargs.get("email", ""), + "phone": kwargs.get("phone", ""), + } + + def get_or_create_user(self, UserModel, username, openid_data, kwargs): + """Retrieve user by username or email, or create a new user.""" + user = self.get_user_by_username(UserModel, username) + + if not user and openid_data["email"]: + user = self.get_user_by_email(UserModel, openid_data["email"]) + if user: + # if found by email, update the username + setattr(user, UserModel.USERNAME_FIELD, username) + + if not user: + user = UserModel.objects.create(**{UserModel.USERNAME_FIELD: username}, **openid_data) + return self.configure_user(user, **kwargs) + + self.update_existing_user(user, openid_data) + return user + + def get_user_by_username(self, UserModel, username): + """Retrieve user by username.""" + try: + return UserModel.objects.get(**{UserModel.USERNAME_FIELD: username}) + except UserModel.DoesNotExist: + return None + + def get_user_by_email(self, UserModel, email): + """Retrieve user by email.""" + try: + return UserModel.objects.get(email=email) + except UserModel.DoesNotExist: + return None + def update_existing_user(self, user, kwargs): """ Update user fields without overwriting certain fields. diff --git a/src/djangooidc/tests/test_backends.py b/src/djangooidc/tests/test_backends.py index c15106fa9..4e8f80a23 100644 --- a/src/djangooidc/tests/test_backends.py +++ b/src/djangooidc/tests/test_backends.py @@ -1,5 +1,6 @@ from django.test import TestCase from registrar.models import User +from api.tests.common import less_console_noise_decorator from ..backends import OpenIdConnectBackend # Adjust the import path based on your project structure @@ -17,6 +18,7 @@ class OpenIdConnectBackendTestCase(TestCase): def tearDown(self) -> None: User.objects.all().delete() + @less_console_noise_decorator def test_authenticate_with_create_user(self): """Test that authenticate creates a new user if it does not find existing user""" @@ -32,6 +34,7 @@ class OpenIdConnectBackendTestCase(TestCase): self.assertEqual(user.email, "john.doe@example.com") self.assertEqual(user.phone, "123456789") + @less_console_noise_decorator def test_authenticate_with_existing_user(self): """Test that authenticate updates an existing user if it finds one. For this test, given_name and family_name are supplied""" @@ -50,6 +53,30 @@ class OpenIdConnectBackendTestCase(TestCase): self.assertEqual(user.email, "john.doe@example.com") self.assertEqual(user.phone, "123456789") + @less_console_noise_decorator + def test_authenticate_with_existing_user_same_email_different_username(self): + """Test that authenticate updates an existing user if it finds one. + In this case, match is to an existing record with matching email but + a non-matching username. The existing record's username should be udpated. + For this test, given_name and family_name are supplied""" + # Create an existing user with the same username + User.objects.create_user(username="old_username", email="john.doe@example.com") + + # Ensure that the authenticate method updates the existing user + user = self.backend.authenticate(request=None, **self.kwargs) + self.assertIsNotNone(user) + self.assertIsInstance(user, User) + + # Verify that user fields are correctly updated + self.assertEqual(user.first_name, "John") + self.assertEqual(user.last_name, "Doe") + self.assertEqual(user.email, "john.doe@example.com") + self.assertEqual(user.phone, "123456789") + self.assertEqual(user.username, "test_user") + # Assert that a user no longer exists by the old username + self.assertFalse(User.objects.filter(username="old_username").exists()) + + @less_console_noise_decorator def test_authenticate_with_existing_user_with_existing_first_last_phone(self): """Test that authenticate updates an existing user if it finds one. For this test, given_name and family_name are not supplied. @@ -79,6 +106,7 @@ class OpenIdConnectBackendTestCase(TestCase): self.assertEqual(user.email, "john.doe@example.com") self.assertEqual(user.phone, "9999999999") + @less_console_noise_decorator def test_authenticate_with_existing_user_different_name_phone(self): """Test that authenticate updates an existing user if it finds one. For this test, given_name and family_name are supplied and overwrite""" @@ -100,6 +128,7 @@ class OpenIdConnectBackendTestCase(TestCase): self.assertEqual(user.email, "john.doe@example.com") self.assertEqual(user.phone, "123456789") + @less_console_noise_decorator def test_authenticate_with_unknown_user(self): """Test that authenticate returns None when no kwargs are supplied""" # Ensure that the authenticate method handles the case when the user is not found diff --git a/src/package-lock.json b/src/package-lock.json index 22fb31857..5caff976c 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -16,7 +16,7 @@ "devDependencies": { "@babel/core": "^7.26.0", "@babel/preset-env": "^7.26.0", - "@uswds/compile": "1.1.0", + "@uswds/compile": "1.2.1", "babel-loader": "^9.2.1", "sass-loader": "^12.6.0", "webpack": "^5.96.1", @@ -1565,90 +1565,30 @@ } }, "node_modules/@bufbuild/protobuf": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.0.tgz", - "integrity": "sha512-QDdVFLoN93Zjg36NoQPZfsVH9tZew7wKDKyV5qRdj8ntT4wQCOradQjRaTdwMhWUYsgKsvCINKKm87FdEk96Ag==", - "dev": true, - "license": "(Apache-2.0 AND BSD-3-Clause)" + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.2.3.tgz", + "integrity": "sha512-tFQoXHJdkEOSwj5tRIZSPNUuXK3RaR7T1nUrPgbYX1pUbvqqaaZAsfo+NXBPsz5rZMSKVFrgK1WL8Q/MSLvprg==", + "dev": true }, - "node_modules/@gulp-sourcemaps/identity-map": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@gulp-sourcemaps/identity-map/-/identity-map-2.0.1.tgz", - "integrity": "sha512-Tb+nSISZku+eQ4X1lAkevcQa+jknn/OVUgZ3XCxEKIsLsqYuPoJwJOPQeaOk75X3WPftb29GWY1eqE7GLsXb1Q==", + "node_modules/@gulpjs/messages": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@gulpjs/messages/-/messages-1.1.0.tgz", + "integrity": "sha512-Ys9sazDatyTgZVb4xPlDufLweJ/Os2uHWOv+Caxvy2O85JcnT4M3vc73bi8pdLWlv3fdWQz3pdI9tVwo8rQQSg==", "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^6.4.1", - "normalize-path": "^3.0.0", - "postcss": "^7.0.16", - "source-map": "^0.6.0", - "through2": "^3.0.1" - }, "engines": { - "node": ">= 0.10" + "node": ">=10.13.0" } }, - "node_modules/@gulp-sourcemaps/identity-map/node_modules/picocolors": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz", - "integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==", + "node_modules/@gulpjs/to-absolute-glob": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@gulpjs/to-absolute-glob/-/to-absolute-glob-4.0.0.tgz", + "integrity": "sha512-kjotm7XJrJ6v+7knhPaRgaT6q8F8K2jiafwYdNHLzmV0uGLuZY43FK6smNSHUPrhq5kX2slCUy+RGG/xGqmIKA==", "dev": true, - "license": "ISC" - }, - "node_modules/@gulp-sourcemaps/identity-map/node_modules/postcss": { - "version": "7.0.39", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz", - "integrity": "sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==", - "dev": true, - "license": "MIT", "dependencies": { - "picocolors": "^0.2.1", - "source-map": "^0.6.1" + "is-negated-glob": "^1.0.0" }, "engines": { - "node": ">=6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - } - }, - "node_modules/@gulp-sourcemaps/identity-map/node_modules/through2": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/through2/-/through2-3.0.2.tgz", - "integrity": "sha512-enaDQ4MUyP2W6ZyT6EsMzqBPZaM/avg8iuo+l2d3QCs0J+6RaqkHV/2/lOwDTueBHeJ/2LG9lrLW3d5rWPucuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.4", - "readable-stream": "2 || 3" - } - }, - "node_modules/@gulp-sourcemaps/map-sources": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@gulp-sourcemaps/map-sources/-/map-sources-1.0.0.tgz", - "integrity": "sha512-o/EatdaGt8+x2qpb0vFLC/2Gug/xYPRXb6a+ET1wGYKozKN3krDWC/zZFZAtrzxJHuDL12mwdfEFKcKMNvc55A==", - "dev": true, - "license": "MIT", - "dependencies": { - "normalize-path": "^2.0.1", - "through2": "^2.0.3" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/@gulp-sourcemaps/map-sources/node_modules/normalize-path": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", - "integrity": "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "remove-trailing-separator": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" + "node": ">=10.13.0" } }, "node_modules/@jridgewell/gen-mapping": { @@ -2124,24 +2064,22 @@ } }, "node_modules/@uswds/compile": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@uswds/compile/-/compile-1.1.0.tgz", - "integrity": "sha512-kKlszBhO13v/qa1jaNqaOWiAJjuKtASHCxjmu+OXw/wMGVdaJcNjkM8hWL9/sx4AUe07PBwlJoGPtkKRWqSxWw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@uswds/compile/-/compile-1.2.1.tgz", + "integrity": "sha512-ODMGF97l8x+eJYp/7U1cB0CnalC5nb+1xEkP0sasG2bJyNqX9U+r7te0YNEURleIfrBOyxGVHVBBAw0gqS0htQ==", "dev": true, - "license": "SEE LICENSE IN LICENSE.md", "dependencies": { - "autoprefixer": "10.4.16", + "autoprefixer": "10.4.20", "del": "6.1.1", - "gulp": "4.0.2", + "gulp": "5.0.0", "gulp-postcss": "9.0.1", "gulp-rename": "2.0.0", "gulp-replace": "1.1.4", "gulp-sass": "5.1.0", - "gulp-sourcemaps": "3.0.0", "gulp-svgstore": "9.0.0", - "postcss": "8.4.31", + "postcss": "8.4.49", "postcss-csso": "6.0.1", - "sass-embedded": "1.69.5" + "sass-embedded": "1.83.0" } }, "node_modules/@uswds/uswds": { @@ -2334,19 +2272,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/acorn": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.2.tgz", - "integrity": "sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -2457,6 +2382,21 @@ "node": ">=8" } }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/ansi-wrap": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/ansi-wrap/-/ansi-wrap-0.1.0.tgz", @@ -2468,152 +2408,18 @@ } }, "node_modules/anymatch": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz", - "integrity": "sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "dev": true, - "license": "ISC", "dependencies": { - "micromatch": "^3.1.4", - "normalize-path": "^2.1.1" - } - }, - "node_modules/anymatch/node_modules/define-property": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz", - "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-descriptor": "^1.0.2", - "isobject": "^3.0.1" + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" }, "engines": { - "node": ">=0.10.0" + "node": ">= 8" } }, - "node_modules/anymatch/node_modules/extend-shallow": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", - "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "assign-symbols": "^1.0.0", - "is-extendable": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/anymatch/node_modules/is-descriptor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.3.tgz", - "integrity": "sha512-JCNNGbwWZEVaSPtS45mdtrneRWJFp07LLmykxeFV5F6oBvNF8vHSfJuJgoT472pSfk+Mf8VnlrspaFBHWM8JAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-accessor-descriptor": "^1.0.1", - "is-data-descriptor": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/anymatch/node_modules/is-extendable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", - "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-plain-object": "^2.0.4" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/anymatch/node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/anymatch/node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/anymatch/node_modules/micromatch": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", - "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", - "dev": true, - "license": "MIT", - "dependencies": { - "arr-diff": "^4.0.0", - "array-unique": "^0.3.2", - "braces": "^2.3.1", - "define-property": "^2.0.2", - "extend-shallow": "^3.0.2", - "extglob": "^2.0.4", - "fragment-cache": "^0.2.1", - "kind-of": "^6.0.2", - "nanomatch": "^1.2.9", - "object.pick": "^1.3.0", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/anymatch/node_modules/normalize-path": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", - "integrity": "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "remove-trailing-separator": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/append-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/append-buffer/-/append-buffer-1.0.2.tgz", - "integrity": "sha512-WLbYiXzD3y/ATLZFufV/rZvWdZOs+Z/+5v1rBZ463Jn398pa6kcde27cvozYnBoxXblGZTFfoPpsaEw0orU5BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-equal": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/archy": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", - "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==", - "dev": true, - "license": "MIT" - }, "node_modules/arr-diff": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", @@ -2624,42 +2430,6 @@ "node": ">=0.10.0" } }, - "node_modules/arr-filter": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/arr-filter/-/arr-filter-1.1.2.tgz", - "integrity": "sha512-A2BETWCqhsecSvCkWAeVBFLH6sXEUGASuzkpjL3GR1SlL/PWL6M3J8EAAld2Uubmh39tvkJTqC9LeLHCUKmFXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "make-iterator": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/arr-flatten": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz", - "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/arr-map": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/arr-map/-/arr-map-2.0.2.tgz", - "integrity": "sha512-tVqVTHt+Q5Xb09qRkbu+DidW1yYzz5izWS2Xm2yFm7qJnmUfz4HPzNxbHkdRJbz2lrqI7S+z17xNYdFcBBO8Hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "make-iterator": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/arr-union": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", @@ -2675,54 +2445,6 @@ "resolved": "https://registry.npmjs.org/array-each/-/array-each-1.0.1.tgz", "integrity": "sha512-zHjL5SZa68hkKHBFBK6DJCTtr9sfTCPCaph/L7tMSLcTFgy+zX7E+6q5UArbtOtMBCtxdICpfTCspRse+ywyXA==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/array-initial": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/array-initial/-/array-initial-1.1.0.tgz", - "integrity": "sha512-BC4Yl89vneCYfpLrs5JU2aAu9/a+xWbeKhvISg9PT7eWFB9UlRvI+rKEtk6mgxWr3dSkk9gQ8hCrdqt06NXPdw==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-slice": "^1.0.0", - "is-number": "^4.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/array-initial/node_modules/is-number": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-4.0.0.tgz", - "integrity": "sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/array-last": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/array-last/-/array-last-1.3.0.tgz", - "integrity": "sha512-eOCut5rXlI6aCOS7Z7kCplKRKyiFQ6dHFBem4PwlwKeNFk2/XxTrhRh5T9PyaEWGy/NHTZWbY+nsZlNFJu9rYg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^4.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/array-last/node_modules/is-number": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-4.0.0.tgz", - "integrity": "sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ==", - "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -2732,22 +2454,6 @@ "resolved": "https://registry.npmjs.org/array-slice/-/array-slice-1.1.0.tgz", "integrity": "sha512-B1qMD3RBP7O8o0H2KbrXDyB0IccejMF15+87Lvlor12ONPRHP6gTjXMNkt/d3ZuOGbAe66hFmaCfECI24Ufp6w==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/array-sort": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-sort/-/array-sort-1.0.0.tgz", - "integrity": "sha512-ihLeJkonmdiAsD7vpgN3CRcx2J2S0TiYW+IS/5zHBI7mKUq3ySvBdzzBfD236ubDBQFiiyG3SWCPc+msQ9KoYg==", - "dev": true, - "license": "MIT", - "dependencies": { - "default-compare": "^1.0.0", - "get-value": "^2.0.6", - "kind-of": "^5.0.2" - }, "engines": { "node": ">=0.10.0" } @@ -2771,16 +2477,6 @@ "node": ">=0.10.0" } }, - "node_modules/array-unique": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", - "integrity": "sha512-SleRWjh9JUud2wH1hPs9rZBZ33H6T9HOiL0uwGnGx9FpE6wKGyfWugmbkEOIs6qWrZhg0LWeLziLrEwQJhs5mQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/assign-symbols": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", @@ -2801,64 +2497,35 @@ } }, "node_modules/async-done": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/async-done/-/async-done-1.3.2.tgz", - "integrity": "sha512-uYkTP8dw2og1tu1nmza1n1CMW0qb8gWWlwqMmLb7MhBVs4BXrFziT6HXUd+/RlRA/i4H9AkofYloUbs1fwMqlw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/async-done/-/async-done-2.0.0.tgz", + "integrity": "sha512-j0s3bzYq9yKIVLKGE/tWlCpa3PfFLcrDZLTSVdnnCTGagXuXBJO4SsY9Xdk/fQBirCkH4evW5xOeJXqlAQFdsw==", "dev": true, - "license": "MIT", "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.2", - "process-nextick-args": "^2.0.0", - "stream-exhaust": "^1.0.1" + "end-of-stream": "^1.4.4", + "once": "^1.4.0", + "stream-exhaust": "^1.0.2" }, "engines": { - "node": ">= 0.10" + "node": ">= 10.13.0" } }, - "node_modules/async-each": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.6.tgz", - "integrity": "sha512-c646jH1avxr+aVpndVMeAfYw7wAa6idufrlN3LPA4PmKS0QEGp6PIC9nwz0WQkkvBGAMEki3pFdtxaF39J9vvg==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], - "license": "MIT" - }, "node_modules/async-settle": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-settle/-/async-settle-1.0.0.tgz", - "integrity": "sha512-VPXfB4Vk49z1LHHodrEQ6Xf7W4gg1w0dAPROHngx7qgDjqmIQ+fXmwgGXTW/ITLai0YLSvWepJOP9EVpMnEAcw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/async-settle/-/async-settle-2.0.0.tgz", + "integrity": "sha512-Obu/KE8FurfQRN6ODdHN9LuXqwC+JFIM9NRyZqJJ4ZfLJmIYN9Rg0/kb+wF70VV5+fJusTMQlJ1t5rF7J/ETdg==", "dev": true, - "license": "MIT", "dependencies": { - "async-done": "^1.2.2" + "async-done": "^2.0.0" }, "engines": { - "node": ">= 0.10" - } - }, - "node_modules/atob": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", - "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", - "dev": true, - "license": "(MIT OR Apache-2.0)", - "bin": { - "atob": "bin/atob.js" - }, - "engines": { - "node": ">= 4.5.0" + "node": ">= 10.13.0" } }, "node_modules/autoprefixer": { - "version": "10.4.16", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.16.tgz", - "integrity": "sha512-7vd3UC6xKp0HLfua5IjZlcXvGAGy7cBAXTg2lyQ/8WpNhd6SiZ8Be+xm3FyBSYJx5GKcpRCzBh7RH4/0dnY+uQ==", + "version": "10.4.20", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", + "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", "dev": true, "funding": [ { @@ -2874,13 +2541,12 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { - "browserslist": "^4.21.10", - "caniuse-lite": "^1.0.30001538", - "fraction.js": "^4.3.6", + "browserslist": "^4.23.3", + "caniuse-lite": "^1.0.30001646", + "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", + "picocolors": "^1.0.1", "postcss-value-parser": "^4.2.0" }, "bin": { @@ -2902,6 +2568,12 @@ "node": ">=4" } }, + "node_modules/b4a": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", + "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==", + "dev": true + }, "node_modules/babel-loader": { "version": "9.2.1", "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.2.1.tgz", @@ -2963,24 +2635,17 @@ } }, "node_modules/bach": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/bach/-/bach-1.2.0.tgz", - "integrity": "sha512-bZOOfCb3gXBXbTFXq3OZtGR88LwGeJvzu6szttaIzymOTS4ZttBNOWSv7aLZja2EMycKtRYV0Oa8SNKH/zkxvg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bach/-/bach-2.0.1.tgz", + "integrity": "sha512-A7bvGMGiTOxGMpNupYl9HQTf0FFDNF4VCmks4PJpFyN1AX2pdKuxuwdvUz2Hu388wcgp+OvGFNsumBfFNkR7eg==", "dev": true, - "license": "MIT", "dependencies": { - "arr-filter": "^1.1.1", - "arr-flatten": "^1.0.1", - "arr-map": "^2.0.0", - "array-each": "^1.0.0", - "array-initial": "^1.0.0", - "array-last": "^1.1.1", - "async-done": "^1.2.2", - "async-settle": "^1.0.0", - "now-and-later": "^2.0.0" + "async-done": "^2.0.0", + "async-settle": "^2.0.0", + "now-and-later": "^3.0.0" }, "engines": { - "node": ">= 0.10" + "node": ">=10.13.0" } }, "node_modules/balanced-match": { @@ -2989,51 +2654,12 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, - "node_modules/base": { - "version": "0.11.2", - "resolved": "https://registry.npmjs.org/base/-/base-0.11.2.tgz", - "integrity": "sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==", + "node_modules/bare-events": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.0.tgz", + "integrity": "sha512-/E8dDe9dsbLyh2qrZ64PEPadOQ0F4gbl1sUJOrmph7xOiIxfY8vwab/4bFLh4Y88/Hk/ujKcrQKc+ps0mv873A==", "dev": true, - "license": "MIT", - "dependencies": { - "cache-base": "^1.0.1", - "class-utils": "^0.3.5", - "component-emitter": "^1.2.1", - "define-property": "^1.0.0", - "isobject": "^3.0.1", - "mixin-deep": "^1.2.0", - "pascalcase": "^0.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/base/node_modules/define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", - "integrity": "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-descriptor": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/base/node_modules/is-descriptor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.3.tgz", - "integrity": "sha512-JCNNGbwWZEVaSPtS45mdtrneRWJFp07LLmykxeFV5F6oBvNF8vHSfJuJgoT472pSfk+Mf8VnlrspaFBHWM8JAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-accessor-descriptor": "^1.0.1", - "is-data-descriptor": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - } + "optional": true }, "node_modules/base64-js": { "version": "1.5.1", @@ -3071,13 +2697,15 @@ } }, "node_modules/binary-extensions": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.13.1.tgz", - "integrity": "sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", "dev": true, - "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/binaryextensions": { @@ -3093,17 +2721,6 @@ "url": "https://bevry.me/fund" } }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -3152,25 +2769,15 @@ } }, "node_modules/braces": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", - "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", - "dev": true, - "license": "MIT", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "devOptional": true, "dependencies": { - "arr-flatten": "^1.1.0", - "array-unique": "^0.3.2", - "extend-shallow": "^2.0.1", - "fill-range": "^4.0.0", - "isobject": "^3.0.1", - "repeat-element": "^1.1.2", - "snapdragon": "^0.8.1", - "snapdragon-node": "^2.0.1", - "split-string": "^3.0.2", - "to-regex": "^3.0.1" + "fill-range": "^7.1.1" }, "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, "node_modules/browserslist": { @@ -3246,19 +2853,6 @@ "node": "*" } }, - "node_modules/buffer-equal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-1.0.1.tgz", - "integrity": "sha512-QoV3ptgEaQpvVwbXdSO39iqPQTCxSF7A5U99AxbHYqUdCizL/lH2Z0A2y6nbZucxMEOtNyZfG2s6gsVugGpKkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3266,57 +2860,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cache-base": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz", - "integrity": "sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "collection-visit": "^1.0.0", - "component-emitter": "^1.2.1", - "get-value": "^2.0.6", - "has-value": "^1.0.0", - "isobject": "^3.0.1", - "set-value": "^2.0.0", - "to-object-path": "^0.3.0", - "union-value": "^1.0.0", - "unset-value": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/camelcase": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-3.0.0.tgz", - "integrity": "sha512-4nhGqUkc4BqbBBB4Q6zLuD7lzzrHYrjKGeYaEji/3tFR5VdJu9v+LilhGIVe8wxEJPPOeWo7eg8dwY13TZ1BNg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001685", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001685.tgz", @@ -3338,6 +2881,34 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/check-types": { "version": "11.2.3", "resolved": "https://registry.npmjs.org/check-types/-/check-types-11.2.3.tgz", @@ -3387,50 +2958,27 @@ } }, "node_modules/chokidar": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.1.8.tgz", - "integrity": "sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "dev": true, - "license": "MIT", "dependencies": { - "anymatch": "^2.0.0", - "async-each": "^1.0.1", - "braces": "^2.3.2", - "glob-parent": "^3.1.0", - "inherits": "^2.0.3", - "is-binary-path": "^1.0.0", - "is-glob": "^4.0.0", - "normalize-path": "^3.0.0", - "path-is-absolute": "^1.0.0", - "readdirp": "^2.2.1", - "upath": "^1.1.1" - }, - "optionalDependencies": { - "fsevents": "^1.2.7" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz", - "integrity": "sha512-E8Ak/2+dZY6fnzlR7+ueWvhsH1SjHr4jjss4YS/h4py44jY9MhK/VFdaZJAWDz6BbL21KeteKxFSFpq8OS5gVA==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^3.1.0", - "path-dirname": "^1.0.0" - } - }, - "node_modules/chokidar/node_modules/glob-parent/node_modules/is-glob": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", - "integrity": "sha512-UFpDDrPgM6qpnFNI+rh/p3bUaq9hKLZN8bMUWzxmcnZVS3omf4IPK+BrewlnWjO1WmUsMYuSjKh4UJuV4+Lqmw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.0" + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" }, "engines": { - "node": ">=0.10.0" + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" } }, "node_modules/chownr": { @@ -3449,22 +2997,6 @@ "node": ">=6.0" } }, - "node_modules/class-utils": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", - "integrity": "sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "arr-union": "^3.1.0", - "define-property": "^0.2.5", - "isobject": "^3.0.0", - "static-extend": "^0.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/classlist-polyfill": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/classlist-polyfill/-/classlist-polyfill-1.2.0.tgz", @@ -3482,38 +3014,14 @@ } }, "node_modules/cliui": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz", - "integrity": "sha512-0yayqDxWQbqk3ojkYqUKqaAQ6AfNKeKWRNA8kR0WXzAsdHpP4BIaOmMAG87JGuO6qcobyW4GjxHd9PmhEd+T9w==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", "dev": true, - "license": "ISC", "dependencies": { - "string-width": "^1.0.1", - "strip-ansi": "^3.0.1", - "wrap-ansi": "^2.0.0" - } - }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" } }, "node_modules/clone": { @@ -3555,44 +3063,23 @@ "readable-stream": "^2.3.5" } }, - "node_modules/code-point-at": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", - "integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==", + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/collection-map": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/collection-map/-/collection-map-1.0.0.tgz", - "integrity": "sha512-5D2XXSpkOnleOI21TG7p3T0bGAsZ/XknZpKBmGYyluO8pw4zA3K8ZlrBIbC4FXg3m6z/RNFiUFfT2sQK01+UHA==", - "dev": true, - "license": "MIT", "dependencies": { - "arr-map": "^2.0.2", - "for-own": "^1.0.0", - "make-iterator": "^1.0.0" + "color-name": "~1.1.4" }, "engines": { - "node": ">=0.10.0" + "node": ">=7.0.0" } }, - "node_modules/collection-visit": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", - "integrity": "sha512-lNkKvzEeMBBjUGHZ+q6z9pSJla0KWAQPvtzhEV9+iGyQYG+pBpl7xKDhxoNSOZH2hhv0v5k0y2yAM4o4SjoSkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "map-visit": "^1.0.0", - "object-visit": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/color-support": { "version": "1.1.3", @@ -3604,6 +3091,12 @@ "color-support": "bin.js" } }, + "node_modules/colorjs.io": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", + "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==", + "dev": true + }, "node_modules/commander": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", @@ -3620,38 +3113,12 @@ "dev": true, "license": "ISC" }, - "node_modules/component-emitter": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", - "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "license": "MIT" }, - "node_modules/concat-stream": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", - "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", - "dev": true, - "engines": [ - "node >= 0.8" - ], - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^2.2.2", - "typedarray": "^0.0.6" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -3659,25 +3126,17 @@ "dev": true, "license": "MIT" }, - "node_modules/copy-descriptor": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", - "integrity": "sha512-XgZ0pFcakEUlbwQEVNg3+QAis1FyTL3Qel9FYy8pSkQqoG3PNoT0bOCQtOXcOkur21r2Eq2kI+IE+gsmAEVlYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/copy-props": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/copy-props/-/copy-props-2.0.5.tgz", - "integrity": "sha512-XBlx8HSqrT0ObQwmSzM7WE5k8FxTV75h1DX1Z3n6NhQ/UYYAvInWYmG06vFt7hQZArE2fuO62aihiWIVQwh1sw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/copy-props/-/copy-props-4.0.0.tgz", + "integrity": "sha512-bVWtw1wQLzzKiYROtvNlbJgxgBYt2bMJpkCbKmXM3xyijvcjjWXEk5nyrrT3bgJ7ODb19ZohE2T0Y3FgNPyoTw==", "dev": true, - "license": "MIT", "dependencies": { - "each-props": "^1.3.2", + "each-props": "^3.0.0", "is-plain-object": "^5.0.0" + }, + "engines": { + "node": ">= 10.13.0" } }, "node_modules/core-js-compat": { @@ -3701,18 +3160,6 @@ "dev": true, "license": "MIT" }, - "node_modules/css": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/css/-/css-3.0.0.tgz", - "integrity": "sha512-DG9pFfwOrzc+hawpmqX/dHYHJG+Bsdb0klhyi1sDneOgGOXy9wQIC8hzyVp1e4NRYDBdxcylvywPkkXCHAzTyQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.4", - "source-map": "^0.6.1", - "source-map-resolve": "^0.6.0" - } - }, "node_modules/css-select": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", @@ -3770,20 +3217,6 @@ "npm": ">=7.0.0" } }, - "node_modules/d": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", - "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", - "dev": true, - "license": "ISC", - "dependencies": { - "es5-ext": "^0.10.64", - "type": "^2.7.2" - }, - "engines": { - "node": ">=0.12" - } - }, "node_modules/debug": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", @@ -3801,120 +3234,6 @@ } } }, - "node_modules/debug-fabulous": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/debug-fabulous/-/debug-fabulous-1.1.0.tgz", - "integrity": "sha512-GZqvGIgKNlUnHUPQhepnUZFIMoi3dgZKQBzKDeL2g7oJF9SNAji/AAu36dusFUas0O+pae74lNeoIPHqXWDkLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "3.X", - "memoizee": "0.4.X", - "object-assign": "4.X" - } - }, - "node_modules/debug-fabulous/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/decode-uri-component": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", - "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10" - } - }, - "node_modules/default-compare": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/default-compare/-/default-compare-1.0.0.tgz", - "integrity": "sha512-QWfXlM0EkAbqOCbD/6HjdwT19j7WCkMyiRhWilc4H9/5h/RzTF9gv5LYh1+CmDV5d1rki6KAWLtQale0xt20eQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "kind-of": "^5.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/default-resolution": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/default-resolution/-/default-resolution-2.0.0.tgz", - "integrity": "sha512-2xaP6GiwVwOEbXCGoJ4ufgC76m8cj805jrghScewJC2ZDsb9U0b4BIrba+xt/Uytyd0HvQ6+WymSRTfnYj59GQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-property": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", - "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-descriptor": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/del": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/del/-/del-6.1.1.tgz", @@ -3943,7 +3262,6 @@ "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz", "integrity": "sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -3961,16 +3279,6 @@ "node": ">=0.10" } }, - "node_modules/detect-newline": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-2.1.0.tgz", - "integrity": "sha512-CwffZFvlJffUg9zZA0uqrjQayUTC8ob94pnr5sFwaVv3IOmkfUHcWH+jXaQK3askE51Cqe8/9Ql/0uXNwqZ8Zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/devtools-protocol": { "version": "0.0.869402", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.869402.tgz", @@ -4045,41 +3353,17 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, - "node_modules/duplexify": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", - "integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==", - "dev": true, - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.0.0", - "inherits": "^2.0.1", - "readable-stream": "^2.0.0", - "stream-shift": "^1.0.0" - } - }, "node_modules/each-props": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/each-props/-/each-props-1.3.2.tgz", - "integrity": "sha512-vV0Hem3zAGkJAyU7JSjixeU66rwdynTAa1vofCrSA5fEln+m67Az9CcnkVD776/fsN/UjIWmBDoNRS6t6G9RfA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/each-props/-/each-props-3.0.0.tgz", + "integrity": "sha512-IYf1hpuWrdzse/s/YJOrFmU15lyhSzxelNVAHTEG3DtP4QsLTWZUzcUL3HMXmKQxXpa4EIrBPpwRgj0aehdvAw==", "dev": true, - "license": "MIT", "dependencies": { - "is-plain-object": "^2.0.1", + "is-plain-object": "^5.0.0", "object.defaults": "^1.1.0" - } - }, - "node_modules/each-props/node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "isobject": "^3.0.1" }, "engines": { - "node": ">=0.10.0" + "node": ">= 10.13.0" } }, "node_modules/electron-to-chromium": { @@ -4098,6 +3382,12 @@ "node": ">=4.0.0" } }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, "node_modules/encoding-sniffer": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.0.tgz", @@ -4171,39 +3461,6 @@ "errno": "cli.js" } }, - "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.4" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-module-lexer": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz", @@ -4211,62 +3468,6 @@ "dev": true, "license": "MIT" }, - "node_modules/es5-ext": { - "version": "0.10.64", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", - "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", - "dev": true, - "hasInstallScript": true, - "license": "ISC", - "dependencies": { - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.3", - "esniff": "^2.0.1", - "next-tick": "^1.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/es6-iterator": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", - "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", - "dev": true, - "license": "MIT", - "dependencies": { - "d": "1", - "es5-ext": "^0.10.35", - "es6-symbol": "^3.1.1" - } - }, - "node_modules/es6-symbol": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", - "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", - "dev": true, - "license": "ISC", - "dependencies": { - "d": "^1.0.2", - "ext": "^1.7.0" - }, - "engines": { - "node": ">=0.12" - } - }, - "node_modules/es6-weak-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz", - "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==", - "dev": true, - "license": "ISC", - "dependencies": { - "d": "1", - "es5-ext": "^0.10.46", - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.1" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4301,22 +3502,6 @@ "node": ">=8.0.0" } }, - "node_modules/esniff": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", - "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", - "dev": true, - "license": "ISC", - "dependencies": { - "d": "^1.0.1", - "es5-ext": "^0.10.62", - "event-emitter": "^0.3.5", - "type": "^2.7.2" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/esrecurse": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", @@ -4360,17 +3545,6 @@ "node": ">=0.10.0" } }, - "node_modules/event-emitter": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", - "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "d": "1", - "es5-ext": "~0.10.14" - } - }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -4381,48 +3555,11 @@ "node": ">=0.8.x" } }, - "node_modules/expand-brackets": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", - "integrity": "sha512-w/ozOKR9Obk3qoWeY/WDi6MFta9AoMR+zud60mdnbniMcBxRuFJyDt2LdX/14A1UABeqk+Uk+LDfUpvoGKppZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^2.3.3", - "define-property": "^0.2.5", - "extend-shallow": "^2.0.1", - "posix-character-classes": "^0.1.0", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/expand-brackets/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/expand-brackets/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, "node_modules/expand-tilde": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", "integrity": "sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==", "dev": true, - "license": "MIT", "dependencies": { "homedir-polyfill": "^1.0.1" }, @@ -4430,82 +3567,11 @@ "node": ">=0.10.0" } }, - "node_modules/ext": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", - "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", - "dev": true, - "license": "ISC", - "dependencies": { - "type": "^2.7.2" - } - }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true, - "license": "MIT" - }, - "node_modules/extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extendable": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/extglob": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", - "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-unique": "^0.3.2", - "define-property": "^1.0.0", - "expand-brackets": "^2.1.4", - "extend-shallow": "^2.0.1", - "fragment-cache": "^0.2.1", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/extglob/node_modules/define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", - "integrity": "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-descriptor": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/extglob/node_modules/is-descriptor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.3.tgz", - "integrity": "sha512-JCNNGbwWZEVaSPtS45mdtrneRWJFp07LLmykxeFV5F6oBvNF8vHSfJuJgoT472pSfk+Mf8VnlrspaFBHWM8JAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-accessor-descriptor": "^1.0.1", - "is-data-descriptor": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - } + "dev": true }, "node_modules/extract-zip": { "version": "2.0.1", @@ -4550,6 +3616,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true + }, "node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -4575,11 +3647,13 @@ "license": "MIT" }, "node_modules/fast-levenshtein": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-1.1.4.tgz", - "integrity": "sha512-Ia0sQNrMPXXkqVFt6w6M1n1oKo3NfKs+mvaV811Jwir7vAk9a6PVV9VPYf6X3BU97QiLEmuW3uXH9u87zDFfdw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-3.0.0.tgz", + "integrity": "sha512-hKKNajm46uNmTlhHSyZkmToAc56uZJwYq7yrciZjqOxnlfQwERDQJmHPUp7m1m9wx8vgOe8IaCKZ5Kv2k1DdCQ==", "dev": true, - "license": "MIT" + "dependencies": { + "fastest-levenshtein": "^1.0.7" + } }, "node_modules/fast-uri": { "version": "3.0.3", @@ -4588,6 +3662,15 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "engines": { + "node": ">= 4.9.1" + } + }, "node_modules/fastq": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", @@ -4607,14 +3690,6 @@ "pend": "~1.2.0" } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/file-url": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/file-url/-/file-url-3.0.0.tgz", @@ -4625,19 +3700,15 @@ } }, "node_modules/fill-range": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", - "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==", - "dev": true, - "license": "MIT", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "devOptional": true, "dependencies": { - "extend-shallow": "^2.0.1", - "is-number": "^3.0.0", - "repeat-string": "^1.6.1", - "to-regex-range": "^2.1.0" + "to-regex-range": "^5.0.1" }, "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, "node_modules/find-cache-dir": { @@ -4675,173 +3746,43 @@ } }, "node_modules/findup-sync": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-3.0.0.tgz", - "integrity": "sha512-YbffarhcicEhOrm4CtrwdKBdCuz576RLdhJDsIfvNtxUuhdRet1qZcsMjqbePtAseKdAnDyM/IyXbu7PRPRLYg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-5.0.0.tgz", + "integrity": "sha512-MzwXju70AuyflbgeOhzvQWAvvQdo1XL0A9bVvlXsYcFEBM87WR4OakL4OfZq+QRmr+duJubio+UtNQCPsVESzQ==", "dev": true, - "license": "MIT", "dependencies": { "detect-file": "^1.0.0", - "is-glob": "^4.0.0", - "micromatch": "^3.0.4", + "is-glob": "^4.0.3", + "micromatch": "^4.0.4", "resolve-dir": "^1.0.1" }, "engines": { - "node": ">= 0.10" - } - }, - "node_modules/findup-sync/node_modules/define-property": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz", - "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-descriptor": "^1.0.2", - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/findup-sync/node_modules/extend-shallow": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", - "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "assign-symbols": "^1.0.0", - "is-extendable": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/findup-sync/node_modules/is-descriptor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.3.tgz", - "integrity": "sha512-JCNNGbwWZEVaSPtS45mdtrneRWJFp07LLmykxeFV5F6oBvNF8vHSfJuJgoT472pSfk+Mf8VnlrspaFBHWM8JAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-accessor-descriptor": "^1.0.1", - "is-data-descriptor": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/findup-sync/node_modules/is-extendable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", - "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-plain-object": "^2.0.4" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/findup-sync/node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/findup-sync/node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/findup-sync/node_modules/micromatch": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", - "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", - "dev": true, - "license": "MIT", - "dependencies": { - "arr-diff": "^4.0.0", - "array-unique": "^0.3.2", - "braces": "^2.3.1", - "define-property": "^2.0.2", - "extend-shallow": "^3.0.2", - "extglob": "^2.0.4", - "fragment-cache": "^0.2.1", - "kind-of": "^6.0.2", - "nanomatch": "^1.2.9", - "object.pick": "^1.3.0", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.2" - }, - "engines": { - "node": ">=0.10.0" + "node": ">= 10.13.0" } }, "node_modules/fined": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fined/-/fined-1.2.0.tgz", - "integrity": "sha512-ZYDqPLGxDkDhDZBjZBb+oD1+j0rA4E0pXY50eplAAOPg2N/gUBSSk5IM1/QhPfyVo19lJ+CvXpqfvk+b2p/8Ng==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fined/-/fined-2.0.0.tgz", + "integrity": "sha512-OFRzsL6ZMHz5s0JrsEr+TpdGNCtrVtnuG3x1yzGNiQHT0yaDnXAj8V/lWcpJVrnoDpcwXcASxAZYbuXda2Y82A==", "dev": true, - "license": "MIT", "dependencies": { "expand-tilde": "^2.0.2", - "is-plain-object": "^2.0.3", + "is-plain-object": "^5.0.0", "object.defaults": "^1.1.0", - "object.pick": "^1.2.0", - "parse-filepath": "^1.0.1" + "object.pick": "^1.3.0", + "parse-filepath": "^1.0.2" }, "engines": { - "node": ">= 0.10" - } - }, - "node_modules/fined/node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" + "node": ">= 10.13.0" } }, "node_modules/flagged-respawn": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-1.0.1.tgz", - "integrity": "sha512-lNaHNVymajmk0OJMBn8fVUAU1BtDeKIqKoVhk4xAALB57aALg6b4W0MfJ/cUE0g9YBXy5XhSlPIpYIJ7HaY/3Q==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-2.0.0.tgz", + "integrity": "sha512-Gq/a6YCi8zexmGHMuJwahTGzXlAZAOsbCVKduWXC6TlLCjjFRlExMJc4GC2NYPYZ0r/brw9P7CpRgQmlPVeOoA==", "dev": true, - "license": "MIT", "engines": { - "node": ">= 0.10" - } - }, - "node_modules/flush-write-stream": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/flush-write-stream/-/flush-write-stream-1.1.1.tgz", - "integrity": "sha512-3Z4XhFZ3992uIq0XOqb9AreonueSYphE6oYbpt5+3u06JWklbsPkNv3ZKkP9Bz/r+1MWCaMoSQ28P85+1Yc77w==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "readable-stream": "^2.3.6" + "node": ">= 10.13.0" } }, "node_modules/for-in": { @@ -4849,7 +3790,6 @@ "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -4859,7 +3799,6 @@ "resolved": "https://registry.npmjs.org/for-own/-/for-own-1.0.0.tgz", "integrity": "sha512-0OABksIGrxKK8K4kynWkQ7y1zounQxP+CWnyclVwj81KW3vlLlGUx57DKGcP/LH216GzqnstnPocF16Nxs0Ycg==", "dev": true, - "license": "MIT", "dependencies": { "for-in": "^1.0.1" }, @@ -4872,7 +3811,6 @@ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", "dev": true, - "license": "MIT", "engines": { "node": "*" }, @@ -4881,19 +3819,6 @@ "url": "https://github.com/sponsors/rawify" } }, - "node_modules/fragment-cache": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz", - "integrity": "sha512-GMBAbW9antB8iZRHLoGw0b3HANt57diZYFO/HL1JGIC1MjKrdmhxvrJbupnVvpys0zsz7yBApXdQyfepKly2kA==", - "dev": true, - "license": "MIT", - "dependencies": { - "map-cache": "^0.2.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -4901,17 +3826,16 @@ "license": "MIT" }, "node_modules/fs-mkdirp-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-mkdirp-stream/-/fs-mkdirp-stream-1.0.0.tgz", - "integrity": "sha512-+vSd9frUnapVC2RZYfL3FCB2p3g4TBhaUmrsWlSudsGdnxIuUvBB2QM1VZeBtc49QFwrp+wQLrDs3+xxDgI5gQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fs-mkdirp-stream/-/fs-mkdirp-stream-2.0.1.tgz", + "integrity": "sha512-UTOY+59K6IA94tec8Wjqm0FSh5OVudGNB0NL/P6fB3HiE3bYOY3VYBGijsnOHNkQSwC1FKkU77pmq7xp9CskLw==", "dev": true, - "license": "MIT", "dependencies": { - "graceful-fs": "^4.1.11", - "through2": "^2.0.3" + "graceful-fs": "^4.2.8", + "streamx": "^2.12.0" }, "engines": { - "node": ">= 0.10" + "node": ">=10.13.0" } }, "node_modules/fs.realpath": { @@ -4921,23 +3845,17 @@ "license": "ISC" }, "node_modules/fsevents": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz", - "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", - "deprecated": "Upgrade to fsevents v2 to mitigate potential security issues", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, - "license": "MIT", "optional": true, "os": [ "darwin" ], - "dependencies": { - "bindings": "^1.5.0", - "nan": "^2.12.1" - }, "engines": { - "node": ">= 4.0" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, "node_modules/function-bind": { @@ -4960,30 +3878,12 @@ } }, "node_modules/get-caller-file": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz", - "integrity": "sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true, - "license": "ISC" - }, - "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "6.* || 8.* || >= 10.*" } }, "node_modules/get-stream": { @@ -5001,16 +3901,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/get-value": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", - "integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -5046,49 +3936,34 @@ } }, "node_modules/glob-stream": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/glob-stream/-/glob-stream-6.1.0.tgz", - "integrity": "sha512-uMbLGAP3S2aDOHUDfdoYcdIePUCfysbAd0IAoWVZbeGU/oNQ8asHVSshLDJUPWxfzj8zsCG7/XeHPHTtow0nsw==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/glob-stream/-/glob-stream-8.0.2.tgz", + "integrity": "sha512-R8z6eTB55t3QeZMmU1C+Gv+t5UnNRkA55c5yo67fAVfxODxieTwsjNG7utxS/73NdP1NbDgCrhVEg2h00y4fFw==", "dev": true, - "license": "MIT", "dependencies": { - "extend": "^3.0.0", - "glob": "^7.1.1", - "glob-parent": "^3.1.0", + "@gulpjs/to-absolute-glob": "^4.0.0", + "anymatch": "^3.1.3", + "fastq": "^1.13.0", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", "is-negated-glob": "^1.0.0", - "ordered-read-streams": "^1.0.0", - "pumpify": "^1.3.5", - "readable-stream": "^2.1.5", - "remove-trailing-separator": "^1.0.1", - "to-absolute-glob": "^2.0.0", - "unique-stream": "^2.0.2" + "normalize-path": "^3.0.0", + "streamx": "^2.12.5" }, "engines": { - "node": ">= 0.10" + "node": ">=10.13.0" } }, "node_modules/glob-stream/node_modules/glob-parent": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz", - "integrity": "sha512-E8Ak/2+dZY6fnzlR7+ueWvhsH1SjHr4jjss4YS/h4py44jY9MhK/VFdaZJAWDz6BbL21KeteKxFSFpq8OS5gVA==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, - "license": "ISC", "dependencies": { - "is-glob": "^3.1.0", - "path-dirname": "^1.0.0" - } - }, - "node_modules/glob-stream/node_modules/is-glob": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", - "integrity": "sha512-UFpDDrPgM6qpnFNI+rh/p3bUaq9hKLZN8bMUWzxmcnZVS3omf4IPK+BrewlnWjO1WmUsMYuSjKh4UJuV4+Lqmw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.0" + "is-glob": "^4.0.3" }, "engines": { - "node": ">=0.10.0" + "node": ">=10.13.0" } }, "node_modules/glob-to-regexp": { @@ -5099,22 +3974,16 @@ "license": "BSD-2-Clause" }, "node_modules/glob-watcher": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/glob-watcher/-/glob-watcher-5.0.5.tgz", - "integrity": "sha512-zOZgGGEHPklZNjZQaZ9f41i7F2YwE+tS5ZHrDhbBCk3stwahn5vQxnFmBJZHoYdusR6R1bLSXeGUy/BhctwKzw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/glob-watcher/-/glob-watcher-6.0.0.tgz", + "integrity": "sha512-wGM28Ehmcnk2NqRORXFOTOR064L4imSw3EeOqU5bIwUf62eXGwg89WivH6VMahL8zlQHeodzvHpXplrqzrz3Nw==", "dev": true, - "license": "MIT", "dependencies": { - "anymatch": "^2.0.0", - "async-done": "^1.2.0", - "chokidar": "^2.0.0", - "is-negated-glob": "^1.0.0", - "just-debounce": "^1.0.0", - "normalize-path": "^3.0.0", - "object.defaults": "^1.1.0" + "async-done": "^2.0.0", + "chokidar": "^3.5.3" }, "engines": { - "node": ">= 0.10" + "node": ">= 10.13.0" } }, "node_modules/global-modules": { @@ -5122,7 +3991,6 @@ "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz", "integrity": "sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==", "dev": true, - "license": "MIT", "dependencies": { "global-prefix": "^1.0.1", "is-windows": "^1.0.1", @@ -5137,7 +4005,6 @@ "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-1.0.2.tgz", "integrity": "sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==", "dev": true, - "license": "MIT", "dependencies": { "expand-tilde": "^2.0.2", "homedir-polyfill": "^1.0.1", @@ -5181,32 +4048,15 @@ } }, "node_modules/glogg": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/glogg/-/glogg-1.0.2.tgz", - "integrity": "sha512-5mwUoSuBk44Y4EshyiqcH95ZntbDdTQqA3QYSrxmzj28Ai0vXBGMH1ApSANH14j2sIRtqCEyg6PfsuP7ElOEDA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/glogg/-/glogg-2.2.0.tgz", + "integrity": "sha512-eWv1ds/zAlz+M1ioHsyKJomfY7jbDDPpwSkv14KQj89bycx1nvK5/2Cj/T9g7kzJcX5Bc7Yv22FjfBZS/jl94A==", "dev": true, - "license": "MIT", "dependencies": { - "sparkles": "^1.0.0" + "sparkles": "^2.1.0" }, "engines": { - "node": ">= 0.10" - } - }, - "node_modules/gopd": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.1.0.tgz", - "integrity": "sha512-FQoVQnqcdk4hVM4JN1eromaun4iuS34oStkdlLENLdpULsuQcTyXj8w7ayhuUfPwEYZ1ZOooOTT6fdA9Vmx/RA==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 10.13.0" } }, "node_modules/graceful-fs": { @@ -5217,55 +4067,47 @@ "license": "ISC" }, "node_modules/gulp": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/gulp/-/gulp-4.0.2.tgz", - "integrity": "sha512-dvEs27SCZt2ibF29xYgmnwwCYZxdxhQ/+LFWlbAW8y7jt68L/65402Lz3+CKy0Ov4rOs+NERmDq7YlZaDqUIfA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/gulp/-/gulp-5.0.0.tgz", + "integrity": "sha512-S8Z8066SSileaYw1S2N1I64IUc/myI2bqe2ihOBzO6+nKpvNSg7ZcWJt/AwF8LC/NVN+/QZ560Cb/5OPsyhkhg==", "dev": true, - "license": "MIT", "dependencies": { - "glob-watcher": "^5.0.3", - "gulp-cli": "^2.2.0", - "undertaker": "^1.2.1", - "vinyl-fs": "^3.0.0" + "glob-watcher": "^6.0.0", + "gulp-cli": "^3.0.0", + "undertaker": "^2.0.0", + "vinyl-fs": "^4.0.0" }, "bin": { "gulp": "bin/gulp.js" }, "engines": { - "node": ">= 0.10" + "node": ">=10.13.0" } }, "node_modules/gulp-cli": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/gulp-cli/-/gulp-cli-2.3.0.tgz", - "integrity": "sha512-zzGBl5fHo0EKSXsHzjspp3y5CONegCm8ErO5Qh0UzFzk2y4tMvzLWhoDokADbarfZRL2pGpRp7yt6gfJX4ph7A==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/gulp-cli/-/gulp-cli-3.0.0.tgz", + "integrity": "sha512-RtMIitkT8DEMZZygHK2vEuLPqLPAFB4sntSxg4NoDta7ciwGZ18l7JuhCTiS5deOJi2IoK0btE+hs6R4sfj7AA==", "dev": true, - "license": "MIT", "dependencies": { - "ansi-colors": "^1.0.1", - "archy": "^1.0.0", - "array-sort": "^1.0.0", - "color-support": "^1.1.3", - "concat-stream": "^1.6.0", - "copy-props": "^2.0.1", - "fancy-log": "^1.3.2", - "gulplog": "^1.0.0", - "interpret": "^1.4.0", - "isobject": "^3.0.1", - "liftoff": "^3.1.0", - "matchdep": "^2.0.0", - "mute-stdout": "^1.0.0", - "pretty-hrtime": "^1.0.0", - "replace-homedir": "^1.0.0", - "semver-greatest-satisfied-range": "^1.1.0", - "v8flags": "^3.2.0", - "yargs": "^7.1.0" + "@gulpjs/messages": "^1.1.0", + "chalk": "^4.1.2", + "copy-props": "^4.0.0", + "gulplog": "^2.2.0", + "interpret": "^3.1.1", + "liftoff": "^5.0.0", + "mute-stdout": "^2.0.0", + "replace-homedir": "^2.0.0", + "semver-greatest-satisfied-range": "^2.0.0", + "string-width": "^4.2.3", + "v8flags": "^4.0.0", + "yargs": "^16.2.0" }, "bin": { "gulp": "bin/gulp.js" }, "engines": { - "node": ">= 0.10" + "node": ">=10.13.0" } }, "node_modules/gulp-postcss": { @@ -5332,36 +4174,6 @@ "node": ">=12" } }, - "node_modules/gulp-sourcemaps": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/gulp-sourcemaps/-/gulp-sourcemaps-3.0.0.tgz", - "integrity": "sha512-RqvUckJkuYqy4VaIH60RMal4ZtG0IbQ6PXMNkNsshEGJ9cldUPRb/YCgboYae+CLAs1HQNb4ADTKCx65HInquQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@gulp-sourcemaps/identity-map": "^2.0.1", - "@gulp-sourcemaps/map-sources": "^1.0.0", - "acorn": "^6.4.1", - "convert-source-map": "^1.0.0", - "css": "^3.0.0", - "debug-fabulous": "^1.0.0", - "detect-newline": "^2.0.0", - "graceful-fs": "^4.0.0", - "source-map": "^0.6.0", - "strip-bom-string": "^1.0.0", - "through2": "^2.0.0" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/gulp-sourcemaps/node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "dev": true, - "license": "MIT" - }, "node_modules/gulp-svgstore": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/gulp-svgstore/-/gulp-svgstore-9.0.0.tgz", @@ -5379,16 +4191,15 @@ } }, "node_modules/gulplog": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/gulplog/-/gulplog-1.0.0.tgz", - "integrity": "sha512-hm6N8nrm3Y08jXie48jsC55eCZz9mnb4OirAStEk2deqeyhXU3C1otDVh+ccttMuc1sBi6RX6ZJ720hs9RCvgw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/gulplog/-/gulplog-2.2.0.tgz", + "integrity": "sha512-V2FaKiOhpR3DRXZuYdRLn/qiY0yI5XmqbTKrYbdemJ+xOh2d2MOweI/XFgMzd/9+1twdvMwllnZbWZNJ+BOm4A==", "dev": true, - "license": "MIT", "dependencies": { - "glogg": "^1.0.0" + "glogg": "^2.2.0" }, "engines": { - "node": ">= 0.10" + "node": ">= 10.13.0" } }, "node_modules/has-flag": { @@ -5401,90 +4212,6 @@ "node": ">=8" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.1.0.tgz", - "integrity": "sha512-QLdzI9IIO1Jg7f9GT1gXpPpXArAn6cS31R1eEZqz08Gc+uQ8/XiqHWt17Fiw+2p6oTTIq5GXEpQkAlA88YRl/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-value": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz", - "integrity": "sha512-IBXk4GTsLYdQ7Rvt+GRBrFSVEkmuOUy4re0Xjd9kJSUQpnTrWR4/y9RpfexN9vkAPMFuQoeWKwqzPozRTlasGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-value": "^2.0.6", - "has-values": "^1.0.0", - "isobject": "^3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/has-values": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-values/-/has-values-1.0.0.tgz", - "integrity": "sha512-ODYZC64uqzmtfGMEAX/FvZiRyWLpAC3vYnNunURUnkGVTS+mI0smVsWaPydRBsE3g+ok7h960jChO8mFcWlHaQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^3.0.0", - "kind-of": "^4.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/has-values/node_modules/kind-of": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz", - "integrity": "sha512-24XsCxmEbRwEDbz/qz3stgin8TTzZ1ESR56OMCN0ujYg+vRutNSiOj9bHH9u85DKgXguraugV5sFuvbD4FW/hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-buffer": "^1.1.5" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -5502,7 +4229,6 @@ "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", "integrity": "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==", "dev": true, - "license": "MIT", "dependencies": { "parse-passwd": "^1.0.0" }, @@ -5519,13 +4245,6 @@ "node": ">= 6.0.0" } }, - "node_modules/hosted-git-info": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", - "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", - "dev": true, - "license": "ISC" - }, "node_modules/html_codesniffer": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/html_codesniffer/-/html_codesniffer-2.5.1.tgz", @@ -5646,27 +4365,15 @@ "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true, - "license": "ISC" + "dev": true }, "node_modules/interpret": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", - "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", "dev": true, - "license": "MIT", "engines": { - "node": ">= 0.10" - } - }, - "node_modules/invert-kv": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz", - "integrity": "sha512-xgs2NH9AE66ucSq4cNG1nhSFghr5l6tdL15Pk+jl46bmmBapgoaY/AacXyaDznAqmGL99TiLSQgO/XazFSKYeQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" + "node": ">=10.13.0" } }, "node_modules/is": { @@ -5683,7 +4390,6 @@ "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", "integrity": "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==", "dev": true, - "license": "MIT", "dependencies": { "is-relative": "^1.0.0", "is-windows": "^1.0.1" @@ -5692,46 +4398,18 @@ "node": ">=0.10.0" } }, - "node_modules/is-accessor-descriptor": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.1.tgz", - "integrity": "sha512-YBUanLI8Yoihw923YeFUS5fs0fF2f5TSFTNiYAAzhhDscDa3lEqYuz1pDOEP5KvX94I9ey3vsqjJcLVFVU+3QA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, - "license": "MIT" - }, "node_modules/is-binary-path": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-1.0.1.tgz", - "integrity": "sha512-9fRVlXc0uCxEDj1nQzaWONSpbTfx0FmJfzHF7pwlI8DkWGoHBBea4Pg5Ky0ojwwxQmnSifgbKkI06Qv0Ljgj+Q==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "dev": true, - "license": "MIT", "dependencies": { - "binary-extensions": "^1.0.0" + "binary-extensions": "^2.0.0" }, "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, - "node_modules/is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", - "dev": true, - "license": "MIT" - }, "node_modules/is-core-module": { "version": "2.15.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", @@ -5748,43 +4426,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-data-descriptor": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.1.tgz", - "integrity": "sha512-bc4NlCDiCr28U4aEsQ3Qs2491gVq4V8G7MQyws968ImqjKuYtTJXrl7Vq7jsN7Ly/C3xj5KWFrY7sHNeDkAzXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/is-descriptor": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.7.tgz", - "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-accessor-descriptor": "^1.0.1", - "is-data-descriptor": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -5796,16 +4437,12 @@ } }, "node_modules/is-fullwidth-code-point": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", - "integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, - "license": "MIT", - "dependencies": { - "number-is-nan": "^1.0.0" - }, "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, "node_modules/is-glob": { @@ -5826,35 +4463,17 @@ "resolved": "https://registry.npmjs.org/is-negated-glob/-/is-negated-glob-1.0.0.tgz", "integrity": "sha512-czXVVn/QEmgvej1f50BZ648vUI+em0xqMq2Sn+QncCLN4zj1UAxlT+kw/6ggQTOaZPd1HqKQGEqbpQVtJucWug==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/is-number": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", - "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "kind-of": "^3.0.2" - }, + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "devOptional": true, "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number/node_modules/kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-buffer": "^1.1.5" - }, - "engines": { - "node": ">=0.10.0" + "node": ">=0.12.0" } }, "node_modules/is-path-cwd": { @@ -5882,24 +4501,15 @@ "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } }, - "node_modules/is-promise": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", - "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", - "dev": true, - "license": "MIT" - }, "node_modules/is-relative": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", "integrity": "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==", "dev": true, - "license": "MIT", "dependencies": { "is-unc-path": "^1.0.0" }, @@ -5912,7 +4522,6 @@ "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz", "integrity": "sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==", "dev": true, - "license": "MIT", "dependencies": { "unc-path-regex": "^0.1.2" }, @@ -5920,19 +4529,11 @@ "node": ">=0.10.0" } }, - "node_modules/is-utf8": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", - "integrity": "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==", - "dev": true, - "license": "MIT" - }, "node_modules/is-valid-glob": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-valid-glob/-/is-valid-glob-1.0.0.tgz", "integrity": "sha512-AhiROmoEFDSsjx8hW+5sGwgKVIORcXnrlAx/R0ZSeaPw70Vw0CqkGBBhHGL58Uox2eXnU1AnvXJl1XlyedO5bA==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -5942,7 +4543,6 @@ "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -5958,8 +4558,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" + "dev": true }, "node_modules/isobject": { "version": "3.0.1", @@ -6037,13 +4636,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -6057,29 +4649,12 @@ "node": ">=6" } }, - "node_modules/just-debounce": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/just-debounce/-/just-debounce-1.1.0.tgz", - "integrity": "sha512-qpcRocdkUmf+UTNBYx5w6dexX5J31AKK1OmPwH630a83DdVVUIngk55RSAiIGpQyoH0dlr872VHfPjnQnK1qDQ==", - "dev": true, - "license": "MIT" - }, "node_modules/keyboardevent-key-polyfill": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/keyboardevent-key-polyfill/-/keyboardevent-key-polyfill-1.1.0.tgz", "integrity": "sha512-NTDqo7XhzL1fqmUzYroiyK2qGua7sOMzLav35BfNA/mPUSCtw8pZghHFMTYR9JdnJ23IQz695FcaM6EE6bpbFQ==", "license": "CC0-1.0" }, - "node_modules/kind-of": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", - "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -6100,89 +4675,39 @@ } }, "node_modules/last-run": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/last-run/-/last-run-1.1.1.tgz", - "integrity": "sha512-U/VxvpX4N/rFvPzr3qG5EtLKEnNI0emvIQB3/ecEwv+8GHaUKbIB8vxv1Oai5FAF0d0r7LXHhLLe5K/yChm5GQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/last-run/-/last-run-2.0.0.tgz", + "integrity": "sha512-j+y6WhTLN4Itnf9j5ZQos1BGPCS8DAwmgMroR3OzfxAsBxam0hMw7J8M3KqZl0pLQJ1jNnwIexg5DYpC/ctwEQ==", "dev": true, - "license": "MIT", - "dependencies": { - "default-resolution": "^2.0.0", - "es6-weak-map": "^2.0.1" - }, "engines": { - "node": ">= 0.10" - } - }, - "node_modules/lazystream": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", - "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "readable-stream": "^2.0.5" - }, - "engines": { - "node": ">= 0.6.3" - } - }, - "node_modules/lcid": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz", - "integrity": "sha512-YiGkH6EnGrDGqLMITnGjXtGmNtjoXw9SVUzcaos8RBi7Ps0VBylkq+vOcY9QE5poLasPCR849ucFUkl0UzUyOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "invert-kv": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" + "node": ">= 10.13.0" } }, "node_modules/lead": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/lead/-/lead-1.0.0.tgz", - "integrity": "sha512-IpSVCk9AYvLHo5ctcIXxOBpMWUe+4TKN3VPWAKUbJikkmsGp0VrSM8IttVc32D6J4WUsiPE6aEFRNmIoF/gdow==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lead/-/lead-4.0.0.tgz", + "integrity": "sha512-DpMa59o5uGUWWjruMp71e6knmwKU3jRBBn1kjuLWN9EeIOxNeSAwvHf03WIl8g/ZMR2oSQC9ej3yeLBwdDc/pg==", "dev": true, - "license": "MIT", - "dependencies": { - "flush-write-stream": "^1.0.2" - }, "engines": { - "node": ">= 0.10" + "node": ">=10.13.0" } }, "node_modules/liftoff": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/liftoff/-/liftoff-3.1.0.tgz", - "integrity": "sha512-DlIPlJUkCV0Ips2zf2pJP0unEoT1kwYhiiPUGF3s/jtxTCjziNLoiVVh+jqWOWeFi6mmwQ5fNxvAUyPad4Dfog==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/liftoff/-/liftoff-5.0.0.tgz", + "integrity": "sha512-a5BQjbCHnB+cy+gsro8lXJ4kZluzOijzJ1UVVfyJYZC+IP2pLv1h4+aysQeKuTmyO8NAqfyQAk4HWaP/HjcKTg==", "dev": true, - "license": "MIT", "dependencies": { - "extend": "^3.0.0", - "findup-sync": "^3.0.0", - "fined": "^1.0.1", - "flagged-respawn": "^1.0.0", - "is-plain-object": "^2.0.4", - "object.map": "^1.0.0", - "rechoir": "^0.6.2", - "resolve": "^1.1.7" + "extend": "^3.0.2", + "findup-sync": "^5.0.0", + "fined": "^2.0.0", + "flagged-respawn": "^2.0.0", + "is-plain-object": "^5.0.0", + "rechoir": "^0.8.0", + "resolve": "^1.20.0" }, "engines": { - "node": ">= 0.8" - } - }, - "node_modules/liftoff/node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" + "node": ">=10.13.0" } }, "node_modules/lilconfig": { @@ -6195,23 +4720,6 @@ "node": ">=10" } }, - "node_modules/load-json-file": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz", - "integrity": "sha512-cy7ZdNRXdablkXYNI049pthVeXFurRyb9+hA/dZzerZ0pGTx42z+y+ssxBaVV2l70t1muq5IdKhn4UtcoGUY9A==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.1.2", - "parse-json": "^2.2.0", - "pify": "^2.0.0", - "pinkie-promise": "^2.0.0", - "strip-bom": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", @@ -6282,206 +4790,11 @@ "yallist": "^3.0.2" } }, - "node_modules/lru-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/lru-queue/-/lru-queue-0.1.0.tgz", - "integrity": "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es5-ext": "~0.10.2" - } - }, - "node_modules/make-iterator": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/make-iterator/-/make-iterator-1.0.1.tgz", - "integrity": "sha512-pxiuXh0iVEq7VM7KMIhs5gxsfxCux2URptUQaXo4iZZJxBAzTPOLE2BumO5dbfVYq/hBJFBR/a1mFDmOx5AGmw==", - "dev": true, - "license": "MIT", - "dependencies": { - "kind-of": "^6.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/make-iterator/node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/map-cache": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", "integrity": "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/map-visit": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz", - "integrity": "sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w==", - "dev": true, - "license": "MIT", - "dependencies": { - "object-visit": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/matchdep": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/matchdep/-/matchdep-2.0.0.tgz", - "integrity": "sha512-LFgVbaHIHMqCRuCZyfCtUOq9/Lnzhi7Z0KFUE2fhD54+JN2jLh3hC02RLkqauJ3U4soU6H1J3tfj/Byk7GoEjA==", - "dev": true, - "license": "MIT", - "dependencies": { - "findup-sync": "^2.0.0", - "micromatch": "^3.0.4", - "resolve": "^1.4.0", - "stack-trace": "0.0.10" - }, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/matchdep/node_modules/define-property": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz", - "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-descriptor": "^1.0.2", - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/matchdep/node_modules/extend-shallow": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", - "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "assign-symbols": "^1.0.0", - "is-extendable": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/matchdep/node_modules/findup-sync": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-2.0.0.tgz", - "integrity": "sha512-vs+3unmJT45eczmcAZ6zMJtxN3l/QXeccaXQx5cu/MeJMhewVfoWZqibRkOxPnmoR59+Zy5hjabfQc6JLSah4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "detect-file": "^1.0.0", - "is-glob": "^3.1.0", - "micromatch": "^3.0.4", - "resolve-dir": "^1.0.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/matchdep/node_modules/is-descriptor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.3.tgz", - "integrity": "sha512-JCNNGbwWZEVaSPtS45mdtrneRWJFp07LLmykxeFV5F6oBvNF8vHSfJuJgoT472pSfk+Mf8VnlrspaFBHWM8JAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-accessor-descriptor": "^1.0.1", - "is-data-descriptor": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/matchdep/node_modules/is-extendable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", - "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-plain-object": "^2.0.4" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/matchdep/node_modules/is-glob": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", - "integrity": "sha512-UFpDDrPgM6qpnFNI+rh/p3bUaq9hKLZN8bMUWzxmcnZVS3omf4IPK+BrewlnWjO1WmUsMYuSjKh4UJuV4+Lqmw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/matchdep/node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/matchdep/node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/matchdep/node_modules/micromatch": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", - "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", - "dev": true, - "license": "MIT", - "dependencies": { - "arr-diff": "^4.0.0", - "array-unique": "^0.3.2", - "braces": "^2.3.1", - "define-property": "^2.0.2", - "extend-shallow": "^3.0.2", - "extglob": "^2.0.4", - "fragment-cache": "^0.2.1", - "kind-of": "^6.0.2", - "nanomatch": "^1.2.9", - "object.pick": "^1.3.0", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.2" - }, "engines": { "node": ">=0.10.0" } @@ -6499,26 +4812,6 @@ "dev": true, "license": "CC0-1.0" }, - "node_modules/memoizee": { - "version": "0.4.17", - "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.17.tgz", - "integrity": "sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA==", - "dev": true, - "license": "ISC", - "dependencies": { - "d": "^1.0.2", - "es5-ext": "^0.10.64", - "es6-weak-map": "^2.0.3", - "event-emitter": "^0.3.5", - "is-promise": "^2.2.2", - "lru-queue": "^0.1.0", - "next-tick": "^1.1.0", - "timers-ext": "^0.1.7" - }, - "engines": { - "node": ">=0.12" - } - }, "node_modules/memory-fs": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.5.0.tgz", @@ -6564,55 +4857,6 @@ "node": ">=8.6" } }, - "node_modules/micromatch/node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/micromatch/node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/micromatch/node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/micromatch/node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -6648,46 +4892,6 @@ "node": "*" } }, - "node_modules/mixin-deep": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", - "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "for-in": "^1.0.2", - "is-extendable": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/mixin-deep/node_modules/is-extendable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", - "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-plain-object": "^2.0.4" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/mixin-deep/node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -6710,23 +4914,14 @@ } }, "node_modules/mute-stdout": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mute-stdout/-/mute-stdout-1.0.1.tgz", - "integrity": "sha512-kDcwXR4PS7caBpuRYYBUz9iVixUk3anO3f5OYFiIPwK/20vCzKCHyKoulbiDY1S53zD2bxUpxN/IJ+TnXjfvxg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stdout/-/mute-stdout-2.0.0.tgz", + "integrity": "sha512-32GSKM3Wyc8dg/p39lWPKYu8zci9mJFzV1Np9Of0ZEpe6Fhssn/FbI7ywAMd40uX+p3ZKh3T5EeCFv81qS3HmQ==", "dev": true, - "license": "MIT", "engines": { - "node": ">= 0.10" + "node": ">= 10.13.0" } }, - "node_modules/nan": { - "version": "2.22.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.0.tgz", - "integrity": "sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/nanoid": { "version": "3.3.8", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", @@ -6746,107 +4941,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/nanomatch": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", - "integrity": "sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "arr-diff": "^4.0.0", - "array-unique": "^0.3.2", - "define-property": "^2.0.2", - "extend-shallow": "^3.0.2", - "fragment-cache": "^0.2.1", - "is-windows": "^1.0.2", - "kind-of": "^6.0.2", - "object.pick": "^1.3.0", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/nanomatch/node_modules/define-property": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz", - "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-descriptor": "^1.0.2", - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/nanomatch/node_modules/extend-shallow": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", - "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "assign-symbols": "^1.0.0", - "is-extendable": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/nanomatch/node_modules/is-descriptor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.3.tgz", - "integrity": "sha512-JCNNGbwWZEVaSPtS45mdtrneRWJFp07LLmykxeFV5F6oBvNF8vHSfJuJgoT472pSfk+Mf8VnlrspaFBHWM8JAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-accessor-descriptor": "^1.0.1", - "is-data-descriptor": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/nanomatch/node_modules/is-extendable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", - "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-plain-object": "^2.0.4" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/nanomatch/node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/nanomatch/node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", @@ -6854,13 +4948,6 @@ "dev": true, "license": "MIT" }, - "node_modules/next-tick": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", - "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", - "dev": true, - "license": "ISC" - }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", @@ -6908,35 +4995,11 @@ "node": ">=0.4.0" } }, - "node_modules/normalize-package-data": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", - "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "hosted-git-info": "^2.1.4", - "resolve": "^1.10.0", - "semver": "2 || 3 || 4 || 5", - "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", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -6946,22 +5009,20 @@ "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/now-and-later": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/now-and-later/-/now-and-later-2.0.1.tgz", - "integrity": "sha512-KGvQ0cB70AQfg107Xvs/Fbu+dGmZoTRJp2TaPwcwQm3/7PteUyN2BCgk8KBMPGBUXZdVwyWS8fDCGFygBm19UQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/now-and-later/-/now-and-later-3.0.0.tgz", + "integrity": "sha512-pGO4pzSdaxhWTGkfSfHx3hVzJVslFPwBp2Myq9MYN/ChfJZF87ochMAXnvz6/58RJSf5ik2q9tXprBBrk2cpcg==", "dev": true, - "license": "MIT", "dependencies": { - "once": "^1.3.2" + "once": "^1.4.0" }, "engines": { - "node": ">= 0.10" + "node": ">= 10.13.0" } }, "node_modules/nth-check": { @@ -6976,16 +5037,6 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, - "node_modules/number-is-nan": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", - "integrity": "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6995,82 +5046,11 @@ "node": ">=0.10.0" } }, - "node_modules/object-copy": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz", - "integrity": "sha512-79LYn6VAb63zgtmAteVOWo9Vdj71ZVBy3Pbse+VqxDpEP83XuujMrGqHIwAXJ5I/aM0zU7dIyIAhifVTPrNItQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "copy-descriptor": "^0.1.0", - "define-property": "^0.2.5", - "kind-of": "^3.0.3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-copy/node_modules/kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-buffer": "^1.1.5" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object-visit": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz", - "integrity": "sha512-GBaMwwAVK9qbQN3Scdo0OyvgPW7l3lnaVMj84uTOZlswkX0KpF6fyDBJhtTthf7pymztoN36/KEr1DyhF96zEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "isobject": "^3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object.assign": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", - "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.5", - "define-properties": "^1.2.1", - "has-symbols": "^1.0.3", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/object.defaults": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/object.defaults/-/object.defaults-1.1.0.tgz", "integrity": "sha512-c/K0mw/F11k4dEUBMW8naXUuBuhxRCfG7W+yFy8EcijU/rSmazOUd1XAEEe6bC0OuXY4HUKjTJv7xbxIMqdxrA==", "dev": true, - "license": "MIT", "dependencies": { "array-each": "^1.0.1", "array-slice": "^1.0.0", @@ -7081,26 +5061,11 @@ "node": ">=0.10.0" } }, - "node_modules/object.map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/object.map/-/object.map-1.0.1.tgz", - "integrity": "sha512-3+mAJu2PLfnSVGHwIWubpOFLscJANBKuB/6A4CxBstc4aqwQY0FWcsppuy4jU5GSB95yES5JHSI+33AWuS4k6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "for-own": "^1.0.0", - "make-iterator": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/object.pick": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", "integrity": "sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==", "dev": true, - "license": "MIT", "dependencies": { "isobject": "^3.0.1" }, @@ -7108,20 +5073,6 @@ "node": ">=0.10.0" } }, - "node_modules/object.reduce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/object.reduce/-/object.reduce-1.0.1.tgz", - "integrity": "sha512-naLhxxpUESbNkRqc35oQ2scZSJueHGQNUfMW/0U37IgN6tE2dgDWg3whf+NEliy3F/QysrO48XKUz/nGPe+AQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "for-own": "^1.0.0", - "make-iterator": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -7131,29 +5082,6 @@ "wrappy": "1" } }, - "node_modules/ordered-read-streams": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ordered-read-streams/-/ordered-read-streams-1.0.1.tgz", - "integrity": "sha512-Z87aSjx3r5c0ZB7bcJqIgIRX5bxR7A4aSzvIbaxd0oTkWBCOoKfuGHiKj60CHVUgg1Phm5yMZzBdt8XqRs73Mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "readable-stream": "^2.0.1" - } - }, - "node_modules/os-locale": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz", - "integrity": "sha512-PRT7ZORmwu2MEFt4/fv3Q+mEfN4zetKxufQrkShY2oGvUms9r8otu5HfdyIFHkYXjO7laNsoVGmM2MANfuTA8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "lcid": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/p-limit": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", @@ -7307,45 +5235,11 @@ "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", "integrity": "sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q==", "dev": true, - "license": "MIT", "dependencies": { "is-absolute": "^1.0.0", "map-cache": "^0.2.0", @@ -7355,19 +5249,6 @@ "node": ">=0.8" } }, - "node_modules/parse-json": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz", - "integrity": "sha512-QR/GGaKCkhwk1ePQNYDRKYZ3mwU9ypsKhB0XyFnLQdomyEqk3e8wpW3V5Jp88zbxK4n5ST1nqo+g9juTpownhQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "error-ex": "^1.2.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/parse-node-version": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz", @@ -7383,7 +5264,6 @@ "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz", "integrity": "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -7425,23 +5305,6 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/pascalcase": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", - "integrity": "sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-dirname": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz", - "integrity": "sha512-ALzNPpyNq9AqXMBjeymIjFDAkAFH06mHJH/cSBHAgU0s4vfpBn6b2nf8tiRLvagKD8RbTpq2FKTBg7cl9l3c7Q==", - "dev": true, - "license": "MIT" - }, "node_modules/path-exists": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", @@ -7473,7 +5336,6 @@ "resolved": "https://registry.npmjs.org/path-root/-/path-root-0.1.1.tgz", "integrity": "sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg==", "dev": true, - "license": "MIT", "dependencies": { "path-root-regex": "^0.1.0" }, @@ -7486,7 +5348,6 @@ "resolved": "https://registry.npmjs.org/path-root-regex/-/path-root-regex-0.1.2.tgz", "integrity": "sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -7629,20 +5490,10 @@ "node": ">=0.10.0" } }, - "node_modules/posix-character-classes": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", - "integrity": "sha512-xTgYBc3fuo7Yt7JbiuFxSYGToMoz8fLoE6TC9Wx1P/u+LfeThMOAqmuyECnlBaaJb+u1m9hHiXUEtwW4OzfUJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "version": "8.4.49", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", + "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", "dev": true, "funding": [ { @@ -7658,11 +5509,10 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "nanoid": "^3.3.7", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -7719,8 +5569,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/prepend-http": { "version": "3.0.1", @@ -7731,16 +5580,6 @@ "node": ">=8" } }, - "node_modules/pretty-hrtime": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz", - "integrity": "sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -7793,29 +5632,6 @@ "once": "^1.3.1" } }, - "node_modules/pumpify": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/pumpify/-/pumpify-1.5.1.tgz", - "integrity": "sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "duplexify": "^3.6.0", - "inherits": "^2.0.3", - "pump": "^2.0.0" - } - }, - "node_modules/pumpify/node_modules/pump": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pump/-/pump-2.0.1.tgz", - "integrity": "sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==", - "dev": true, - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7945,6 +5761,12 @@ ], "license": "MIT" }, + "node_modules/queue-tick": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", + "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==", + "dev": true + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -7955,77 +5777,6 @@ "safe-buffer": "^5.1.0" } }, - "node_modules/read-pkg": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz", - "integrity": "sha512-7BGwRHqt4s/uVbuyoeejRn4YmFnYZiFl4AuaeXHlgZf3sONF0SOGlxs2Pw8g6hCKupo08RafIO5YXFNOKTfwsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "load-json-file": "^1.0.0", - "normalize-package-data": "^2.3.2", - "path-type": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/read-pkg-up": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz", - "integrity": "sha512-WD9MTlNtI55IwYUS27iHh9tK3YoIVhxis8yKhLpTqWtml739uXc9NWTpxoHkfZf3+DkCCsXox94/VWZniuZm6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^1.0.0", - "read-pkg": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/read-pkg-up/node_modules/find-up": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz", - "integrity": "sha512-jvElSjyuo4EMQGoTwo1uJU5pQMwTW5lS1x05zzfJuTIyLR3zwO27LYrxNg+dlvKpGOuGy/MzBdXh80g0ve5+HA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-exists": "^2.0.0", - "pinkie-promise": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/read-pkg-up/node_modules/path-exists": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz", - "integrity": "sha512-yTltuKuhtNeFJKa1PiRzfLAU5182q1y4Eb4XCJ3PBqyzEDkAZRzBrKKBct682ls9reBVHf9udYLN5Nd+K1B9BQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "pinkie-promise": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/read-pkg/node_modules/path-type": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz", - "integrity": "sha512-S4eENJz1pkiQn9Znv33Q+deTOKmbl+jj1Fl+qiP/vYezj+S8x+J3Uo0ISrx/QoEvIlOaDWJhPaRd1flJ9HXZqg==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.1.2", - "pify": "^2.0.0", - "pinkie-promise": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -8043,121 +5794,15 @@ } }, "node_modules/readdirp": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.2.1.tgz", - "integrity": "sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", "dev": true, - "license": "MIT", "dependencies": { - "graceful-fs": "^4.1.11", - "micromatch": "^3.1.10", - "readable-stream": "^2.0.2" + "picomatch": "^2.2.1" }, "engines": { - "node": ">=0.10" - } - }, - "node_modules/readdirp/node_modules/define-property": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz", - "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-descriptor": "^1.0.2", - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/readdirp/node_modules/extend-shallow": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", - "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "assign-symbols": "^1.0.0", - "is-extendable": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/readdirp/node_modules/is-descriptor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.3.tgz", - "integrity": "sha512-JCNNGbwWZEVaSPtS45mdtrneRWJFp07LLmykxeFV5F6oBvNF8vHSfJuJgoT472pSfk+Mf8VnlrspaFBHWM8JAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-accessor-descriptor": "^1.0.1", - "is-data-descriptor": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/readdirp/node_modules/is-extendable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", - "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-plain-object": "^2.0.4" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/readdirp/node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/readdirp/node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/readdirp/node_modules/micromatch": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", - "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", - "dev": true, - "license": "MIT", - "dependencies": { - "arr-diff": "^4.0.0", - "array-unique": "^0.3.2", - "braces": "^2.3.1", - "define-property": "^2.0.2", - "extend-shallow": "^3.0.2", - "extglob": "^2.0.4", - "fragment-cache": "^0.2.1", - "kind-of": "^6.0.2", - "nanomatch": "^1.2.9", - "object.pick": "^1.3.0", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.2" - }, - "engines": { - "node": ">=0.10.0" + "node": ">=8.10.0" } }, "node_modules/receptor": { @@ -8173,15 +5818,15 @@ } }, "node_modules/rechoir": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", - "integrity": "sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==", + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", "dev": true, "dependencies": { - "resolve": "^1.1.6" + "resolve": "^1.20.0" }, "engines": { - "node": ">= 0.10" + "node": ">= 10.13.0" } }, "node_modules/regenerate": { @@ -8221,60 +5866,6 @@ "@babel/runtime": "^7.8.4" } }, - "node_modules/regex-not": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", - "integrity": "sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==", - "dev": true, - "license": "MIT", - "dependencies": { - "extend-shallow": "^3.0.2", - "safe-regex": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/regex-not/node_modules/extend-shallow": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", - "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "assign-symbols": "^1.0.0", - "is-extendable": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/regex-not/node_modules/is-extendable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", - "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-plain-object": "^2.0.4" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/regex-not/node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/regexpu-core": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.2.0.tgz", @@ -8313,35 +5904,6 @@ "regjsparser": "bin/parser" } }, - "node_modules/remove-bom-buffer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/remove-bom-buffer/-/remove-bom-buffer-3.0.0.tgz", - "integrity": "sha512-8v2rWhaakv18qcvNeli2mZ/TMTL2nEyAKRvzo1WtnZBl15SHyEhrCu2/xKlJyUFKHiHgfXIyuY6g2dObJJycXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-buffer": "^1.1.5", - "is-utf8": "^0.2.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/remove-bom-stream": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/remove-bom-stream/-/remove-bom-stream-1.2.0.tgz", - "integrity": "sha512-wigO8/O08XHb8YPzpDDT+QmRANfW6vLqxfaXm1YXhnFf3AkSLyjfG3GEFg4McZkmgL7KvCj5u2KczkvSP6NfHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "remove-bom-buffer": "^3.0.0", - "safe-buffer": "^5.1.0", - "through2": "^2.0.3" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/remove-trailing-separator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", @@ -8349,26 +5911,6 @@ "dev": true, "license": "ISC" }, - "node_modules/repeat-element": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.4.tgz", - "integrity": "sha512-LFiNfRcSu7KK3evMyYOuCzv3L10TW7yC1G2/+StMjK8Y6Vqd2MG7r/Qjw4ghtuCOjFvlnms/iMmLqpvW/ES/WQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/repeat-string": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", - "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10" - } - }, "node_modules/replace-ext": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-2.0.0.tgz", @@ -8380,18 +5922,12 @@ } }, "node_modules/replace-homedir": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/replace-homedir/-/replace-homedir-1.0.0.tgz", - "integrity": "sha512-CHPV/GAglbIB1tnQgaiysb8H2yCy8WQ7lcEwQ/eT+kLj0QHV8LnJW0zpqpE7RSkrMSRoa+EBoag86clf7WAgSg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/replace-homedir/-/replace-homedir-2.0.0.tgz", + "integrity": "sha512-bgEuQQ/BHW0XkkJtawzrfzHFSN70f/3cNOiHa2QsYxqrjaC30X1k74FJ6xswVBP0sr0SpGIdVFuPwfrYziVeyw==", "dev": true, - "license": "MIT", - "dependencies": { - "homedir-polyfill": "^1.0.1", - "is-absolute": "^1.0.0", - "remove-trailing-separator": "^1.1.0" - }, "engines": { - "node": ">= 0.10" + "node": ">= 10.13.0" } }, "node_modules/replacestream": { @@ -8411,7 +5947,6 @@ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -8426,13 +5961,6 @@ "node": ">=0.10.0" } }, - "node_modules/require-main-filename": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz", - "integrity": "sha512-IqSUtOVP4ksd1C/ej5zeEh/BIP2ajqpn8c5x+q99gvcIG/Qf0cud5raVnE/Dwd0ua9TXYDoDc0RE5hBSdz22Ug==", - "dev": true, - "license": "ISC" - }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -8456,7 +5984,6 @@ "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz", "integrity": "sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==", "dev": true, - "license": "MIT", "dependencies": { "expand-tilde": "^2.0.0", "global-modules": "^1.0.0" @@ -8472,34 +5999,15 @@ "license": "CC0-1.0" }, "node_modules/resolve-options": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/resolve-options/-/resolve-options-1.1.0.tgz", - "integrity": "sha512-NYDgziiroVeDC29xq7bp/CacZERYsA9bXYd1ZmcJlF3BcrZv5pTb4NG7SjdyKDnXZ84aC4vo2u6sNKIA1LCu/A==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/resolve-options/-/resolve-options-2.0.0.tgz", + "integrity": "sha512-/FopbmmFOQCfsCx77BRFdKOniglTiHumLgwvd6IDPihy1GKkadZbgQJBcTb2lMzSR1pndzd96b1nZrreZ7+9/A==", "dev": true, - "license": "MIT", "dependencies": { - "value-or-function": "^3.0.0" + "value-or-function": "^4.0.0" }, "engines": { - "node": ">= 0.10" - } - }, - "node_modules/resolve-url": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", - "integrity": "sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg==", - "deprecated": "https://github.com/lydell/resolve-url#deprecated", - "dev": true, - "license": "MIT" - }, - "node_modules/ret": { - "version": "0.1.15", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", - "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12" + "node": ">= 10.13.0" } }, "node_modules/reusify": { @@ -8569,16 +6077,6 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, - "node_modules/safe-regex": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", - "integrity": "sha512-aJXcif4xnaNUzvUuC5gcb46oTS7zvg4jpMTnuqtrEPlR3vFr4pxtdTwaF1Qs3Enjn9HK+ZlwQui+a7z0SywIzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ret": "~0.1.10" - } - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -8606,200 +6104,369 @@ } }, "node_modules/sass-embedded": { - "version": "1.69.5", - "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.69.5.tgz", - "integrity": "sha512-0YNcRcbSpgGd4AnE+mm3a3g4S97puFLIZ0cYJgbwdD4iGz/hiOzE+yh72XS+u1LMhE+pQfNeC9MNnRsc8n1yRg==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.83.0.tgz", + "integrity": "sha512-/8cYZeL39evUqe0o//193na51Q1VWZ61qhxioQvLJwOtWIrX+PgNhCyD8RSuTtmzc4+6+waFZf899bfp/MCUwA==", "dev": true, - "license": "MIT", "dependencies": { - "@bufbuild/protobuf": "^1.0.0", + "@bufbuild/protobuf": "^2.0.0", "buffer-builder": "^0.2.0", - "immutable": "^4.0.0", + "colorjs.io": "^0.5.0", + "immutable": "^5.0.2", "rxjs": "^7.4.0", "supports-color": "^8.1.1", + "sync-child-process": "^1.0.2", "varint": "^6.0.0" }, + "bin": { + "sass": "dist/bin/sass.js" + }, "engines": { - "node": ">=14.0.0" + "node": ">=16.0.0" }, "optionalDependencies": { - "sass-embedded-darwin-arm64": "1.69.5", - "sass-embedded-darwin-x64": "1.69.5", - "sass-embedded-linux-arm": "1.69.5", - "sass-embedded-linux-arm64": "1.69.5", - "sass-embedded-linux-ia32": "1.69.5", - "sass-embedded-linux-x64": "1.69.5", - "sass-embedded-win32-ia32": "1.69.5", - "sass-embedded-win32-x64": "1.69.5" + "sass-embedded-android-arm": "1.83.0", + "sass-embedded-android-arm64": "1.83.0", + "sass-embedded-android-ia32": "1.83.0", + "sass-embedded-android-riscv64": "1.83.0", + "sass-embedded-android-x64": "1.83.0", + "sass-embedded-darwin-arm64": "1.83.0", + "sass-embedded-darwin-x64": "1.83.0", + "sass-embedded-linux-arm": "1.83.0", + "sass-embedded-linux-arm64": "1.83.0", + "sass-embedded-linux-ia32": "1.83.0", + "sass-embedded-linux-musl-arm": "1.83.0", + "sass-embedded-linux-musl-arm64": "1.83.0", + "sass-embedded-linux-musl-ia32": "1.83.0", + "sass-embedded-linux-musl-riscv64": "1.83.0", + "sass-embedded-linux-musl-x64": "1.83.0", + "sass-embedded-linux-riscv64": "1.83.0", + "sass-embedded-linux-x64": "1.83.0", + "sass-embedded-win32-arm64": "1.83.0", + "sass-embedded-win32-ia32": "1.83.0", + "sass-embedded-win32-x64": "1.83.0" } }, - "node_modules/sass-embedded-darwin-arm64": { - "version": "1.69.5", - "resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.69.5.tgz", - "integrity": "sha512-zVuXJzgT54t24E4QPP/iteHsw/cawZE8gAXGEm20cP2DKsIQBF7bvSTk0zzY0bS01YFtJviYM13HcGUe4q7/7w==", + "node_modules/sass-embedded-android-arm": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.83.0.tgz", + "integrity": "sha512-uwFSXzJlfbd4Px189xE5l+cxN8+TQpXdQgJec7TIrb4HEY7imabtpYufpVdqUVwT1/uiis5V4+qIEC4Vl5XObQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-arm64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.83.0.tgz", + "integrity": "sha512-GBiCvM4a2rkWBLdYDxI6XYnprfk5U5c81g69RC2X6kqPuzxzx8qTArQ9M6keFK4+iDQ5N9QTwFCr0KbZTn+ZNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-ia32": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/sass-embedded-android-ia32/-/sass-embedded-android-ia32-1.83.0.tgz", + "integrity": "sha512-5ATPdGo2SICqAhiJl/Z8KQ23zH4sGgobGgux0TnrNtt83uHZ+r+To/ubVJ7xTkZxed+KJZnIpolGD8dQyQqoTg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-riscv64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.83.0.tgz", + "integrity": "sha512-aveknUOB8GZewOzVn2Uwk+DKcncTR50Q6vtzslNMGbYnxtgQNHzy8A1qVEviNUruex+pHofppeMK4iMPFAbiEQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-x64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.83.0.tgz", + "integrity": "sha512-WqIay/72ncyf9Ph4vS742J3a73wZihWmzFUwpn1OD6lme1Aj4eWzWIve5IVnlTEJgcZcDHu6ECID9IZgehJKoA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-darwin-arm64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.83.0.tgz", + "integrity": "sha512-XQl9QqgxFFIPm/CzHhmppse5o9ocxrbaAdC2/DAnlAqvYWBBtgFqPjGoYlej13h9SzfvNoogx+y9r+Ap+e+hYg==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" ], - "bin": { - "sass": "dart-sass/sass" - }, "engines": { "node": ">=14.0.0" } }, "node_modules/sass-embedded-darwin-x64": { - "version": "1.69.5", - "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.69.5.tgz", - "integrity": "sha512-HcA9YER3Ax7lMnHouxnIY462gnst5lNL56QXkZaTQmg9nH7oqR2bMfWbckEQL+mHIXGSM/QfX0aD59VOm5iKZw==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.83.0.tgz", + "integrity": "sha512-ERQ7Tvp1kFOW3ux4VDFIxb7tkYXHYc+zJpcrbs0hzcIO5ilIRU2tIOK1OrNwrFO6Qxyf7AUuBwYKLAtIU/Nz7g==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" ], - "bin": { - "sass": "dart-sass/sass" - }, "engines": { "node": ">=14.0.0" } }, "node_modules/sass-embedded-linux-arm": { - "version": "1.69.5", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.69.5.tgz", - "integrity": "sha512-m0NxVkrfcS3UsF33q0FgItMWIz/F1FZdfVZpjp+dP6qd0KLeTuoPUCh2GSize0DAU5T0Zj24b2mXeowDKv463g==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.83.0.tgz", + "integrity": "sha512-baG9RYBJxUFmqwDNC9h9ZFElgJoyO3jgHGjzEZ1wHhIS9anpG+zZQvO8bHx3dBpKEImX+DBeLX+CxsFR9n81gQ==", "cpu": [ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ], - "bin": { - "sass": "dart-sass/sass" - }, "engines": { "node": ">=14.0.0" } }, "node_modules/sass-embedded-linux-arm64": { - "version": "1.69.5", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.69.5.tgz", - "integrity": "sha512-HWCjdFSLGh0dMUNLNh+slc2j9koSZnfTEO9qQR6WEIuC+We6vYKJugGPo1V9pFbBeoW6VAJGYdlqsRPquteCZw==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.83.0.tgz", + "integrity": "sha512-syEAVTJt4qhaMLxrSwOWa46zdqHJdnqJkLUK+t9aCr8xqBZLPxSUeIGji76uOehQZ1C+KGFj6n9xstHN6wzOJw==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ], - "bin": { - "sass": "dart-sass/sass" - }, "engines": { "node": ">=14.0.0" } }, "node_modules/sass-embedded-linux-ia32": { - "version": "1.69.5", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-ia32/-/sass-embedded-linux-ia32-1.69.5.tgz", - "integrity": "sha512-0taR6AJDb+eLOBTEMc1nfX2fI1hgRF9M+Hmv+wwGrxfBu/MkASk6fmR9B8MDw9hPHIqGVUkTVizjOh50O7nIKg==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-ia32/-/sass-embedded-linux-ia32-1.83.0.tgz", + "integrity": "sha512-RRBxQxMpoxu5+XcSSc6QR/o9asEwUzR8AbCS83RaXcdTIHTa/CccQsiAoDDoPlRsMTLqnzs0LKL4CfOsf7zBbA==", "cpu": [ "ia32" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ], - "bin": { - "sass": "dart-sass/sass" - }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-arm": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.83.0.tgz", + "integrity": "sha512-Yc7u2TelCfBab+PRob9/MNJFh3EooMiz4urvhejXkihTiKSHGCv5YqDdtWzvyb9tY2Jb7YtYREVuHwfdVn3dTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-arm64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.83.0.tgz", + "integrity": "sha512-Y7juhPHClUO2H5O+u+StRy6SEAcwZ+hTEk5WJdEmo1Bb1gDtfHvJaWB/iFZJ2tW0W1e865AZeUrC4OcOFjyAQA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-ia32": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-ia32/-/sass-embedded-linux-musl-ia32-1.83.0.tgz", + "integrity": "sha512-arQeYwGmwXV8byx5G1PtSzZWW1jbkfR5qrIHMEbTFSAvAxpqjgSvCvrHMOFd73FcMxVaYh4BX9LQNbKinkbEdg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-riscv64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.83.0.tgz", + "integrity": "sha512-E6uzlIWz59rut+Z3XR6mLG915zNzv07ISvj3GUNZENdHM7dF8GQ//ANoIpl5PljMQKp89GnYdvo6kj2gnaBf/g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-x64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.83.0.tgz", + "integrity": "sha512-eAMK6tyGqvqr21r9g8BnR3fQc1rYFj85RGduSQ3xkITZ6jOAnOhuU94N5fwRS852Hpws0lXhET+7JHXgg3U18w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-riscv64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.83.0.tgz", + "integrity": "sha512-Ojpi78pTv02sy2fUYirRGXHLY3fPnV/bvwuC2i5LwPQw2LpCcFyFTtN0c5h4LJDk9P6wr+/ZB/JXU8tHIOlK+Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=14.0.0" } }, "node_modules/sass-embedded-linux-x64": { - "version": "1.69.5", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.69.5.tgz", - "integrity": "sha512-gN9yLTbKC0hUHukx4mdRs4V39WD719PM2GhWQBUA+3Z8qr9ywywi7LiU2atWrKESRF34V+eqF9lYiYVQxtTHZw==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.83.0.tgz", + "integrity": "sha512-3iLjlXdoPfgZRtX4odhRvka1BQs5mAXqfCtDIQBgh/o0JnGPzJIWWl9bYLpHxK8qb+uyVBxXYgXpI0sCzArBOw==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ], - "bin": { - "sass": "dart-sass/sass" - }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-win32-arm64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.83.0.tgz", + "integrity": "sha512-iOHw/8/t2dlTW3lOFwG5eUbiwhEyGWawivlKWJ8lkXH7fjMpVx2VO9zCFAm8RvY9xOHJ9sf1L7g5bx3EnNP9BQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], "engines": { "node": ">=14.0.0" } }, "node_modules/sass-embedded-win32-ia32": { - "version": "1.69.5", - "resolved": "https://registry.npmjs.org/sass-embedded-win32-ia32/-/sass-embedded-win32-ia32-1.69.5.tgz", - "integrity": "sha512-9OgSaufHP53b33gaH1Y5NZ/Im3druCHIQvLUEqJBCFuOzly47g/hZGrO+dBDiWgYGYKbSYI7Z4/PBtQoK5Vkxg==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-ia32/-/sass-embedded-win32-ia32-1.83.0.tgz", + "integrity": "sha512-2PxNXJ8Pad4geVcTXY4rkyTr5AwbF8nfrCTDv0ulbTvPhzX2mMKEGcBZUXWn5BeHZTBc6whNMfS7d5fQXR9dDQ==", "cpu": [ "ia32" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" ], - "bin": { - "sass": "dart-sass/sass.bat" - }, "engines": { "node": ">=14.0.0" } }, "node_modules/sass-embedded-win32-x64": { - "version": "1.69.5", - "resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.69.5.tgz", - "integrity": "sha512-p1PsOJnpwXdPfiRbX6QdRa4PnL2QXPpIRy8fkeAZpQFYZ278ZIlwemC2MukKMVLcE3iQ5lBulbC8IYm91rod6Q==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.83.0.tgz", + "integrity": "sha512-muBXkFngM6eLTNqOV0FQi7Dv9s+YRQ42Yem26mosdan/GmJQc81deto6uDTgrYn+bzFNmiXcOdfm+0MkTWK3OQ==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" ], - "bin": { - "sass": "dart-sass/sass.bat" - }, "engines": { "node": ">=14.0.0" } }, - "node_modules/sass-embedded/node_modules/immutable": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", - "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", - "dev": true, - "license": "MIT" - }, "node_modules/sass-loader": { "version": "12.6.0", "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-12.6.0.tgz", @@ -8888,26 +6555,27 @@ } }, "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": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/semver-greatest-satisfied-range/-/semver-greatest-satisfied-range-1.1.0.tgz", - "integrity": "sha512-Ny/iyOzSSa8M5ML46IAx3iXc6tfOsYU2R4AXi2UpHk60Zrgyq6eqPj/xiOfS0rRl/iiQ/rdJkVjw/5cdUyCntQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/semver-greatest-satisfied-range/-/semver-greatest-satisfied-range-2.0.0.tgz", + "integrity": "sha512-lH3f6kMbwyANB7HuOWRMlLCa2itaCrZJ+SAqqkSZrZKO/cAsk2EOyaKHUtNkVLFyFW9pct22SFesFp3Z7zpA0g==", "dev": true, - "license": "MIT", "dependencies": { - "sver-compat": "^1.5.0" + "sver": "^1.8.3" }, "engines": { - "node": ">= 0.10" + "node": ">= 10.13.0" } }, "node_modules/serialize-javascript": { @@ -8920,60 +6588,6 @@ "randombytes": "^2.1.0" } }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "dev": true, - "license": "ISC" - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-value": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", - "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "extend-shallow": "^2.0.1", - "is-extendable": "^0.1.1", - "is-plain-object": "^2.0.3", - "split-string": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/set-value/node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -8984,136 +6598,6 @@ "node": ">=8" } }, - "node_modules/snapdragon": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz", - "integrity": "sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==", - "dev": true, - "license": "MIT", - "dependencies": { - "base": "^0.11.1", - "debug": "^2.2.0", - "define-property": "^0.2.5", - "extend-shallow": "^2.0.1", - "map-cache": "^0.2.2", - "source-map": "^0.5.6", - "source-map-resolve": "^0.5.0", - "use": "^3.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/snapdragon-node": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/snapdragon-node/-/snapdragon-node-2.1.1.tgz", - "integrity": "sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-property": "^1.0.0", - "isobject": "^3.0.0", - "snapdragon-util": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/snapdragon-node/node_modules/define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", - "integrity": "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-descriptor": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/snapdragon-node/node_modules/is-descriptor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.3.tgz", - "integrity": "sha512-JCNNGbwWZEVaSPtS45mdtrneRWJFp07LLmykxeFV5F6oBvNF8vHSfJuJgoT472pSfk+Mf8VnlrspaFBHWM8JAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-accessor-descriptor": "^1.0.1", - "is-data-descriptor": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/snapdragon-util": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/snapdragon-util/-/snapdragon-util-3.0.1.tgz", - "integrity": "sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "kind-of": "^3.2.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/snapdragon-util/node_modules/kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-buffer": "^1.1.5" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/snapdragon/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/snapdragon/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/snapdragon/node_modules/source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/snapdragon/node_modules/source-map-resolve": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.3.tgz", - "integrity": "sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==", - "deprecated": "See https://github.com/lydell/source-map-resolve#deprecated", - "dev": true, - "license": "MIT", - "dependencies": { - "atob": "^2.1.2", - "decode-uri-component": "^0.2.0", - "resolve-url": "^0.2.1", - "source-map-url": "^0.4.0", - "urix": "^0.1.0" - } - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -9133,18 +6617,6 @@ "node": ">=0.10.0" } }, - "node_modules/source-map-resolve": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.6.0.tgz", - "integrity": "sha512-KXBr9d/fO/bWo97NXsPIAW1bFSBOuCnjbNTBMO7N59hsv5i9yzRDfcYwwt0l04+VqnKC+EwzvJZIP/qkuMgR/w==", - "deprecated": "See https://github.com/lydell/source-map-resolve#deprecated", - "dev": true, - "license": "MIT", - "dependencies": { - "atob": "^2.1.2", - "decode-uri-component": "^0.2.0" - } - }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", @@ -9156,150 +6628,43 @@ "source-map": "^0.6.0" } }, - "node_modules/source-map-url": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.1.tgz", - "integrity": "sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw==", - "deprecated": "See https://github.com/lydell/source-map-url#deprecated", - "dev": true, - "license": "MIT" - }, "node_modules/sparkles": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/sparkles/-/sparkles-1.0.1.tgz", - "integrity": "sha512-dSO0DDYUahUt/0/pD/Is3VIm5TGJjludZ0HVymmhYF6eNA53PVLhnUk0znSYbH8IYBuJdCE+1luR22jNLMaQdw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/sparkles/-/sparkles-2.1.0.tgz", + "integrity": "sha512-r7iW1bDw8R/cFifrD3JnQJX0K1jqT0kprL48BiBpLZLJPmAm34zsVBsK5lc7HirZYZqMW65dOXZgbAGt/I6frg==", "dev": true, - "license": "MIT", "engines": { - "node": ">= 0.10" + "node": ">= 10.13.0" } }, - "node_modules/spdx-correct": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", - "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "node_modules/stream-composer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/stream-composer/-/stream-composer-1.0.2.tgz", + "integrity": "sha512-bnBselmwfX5K10AH6L4c8+S5lgZMWI7ZYrz2rvYjCPB2DIMC4Ig8OpxGpNJSxRZ58oti7y1IcNvjBAz9vW5m4w==", "dev": true, - "license": "Apache-2.0", "dependencies": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-exceptions": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", - "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", - "dev": true, - "license": "CC-BY-3.0" - }, - "node_modules/spdx-expression-parse": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-license-ids": { - "version": "3.0.20", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.20.tgz", - "integrity": "sha512-jg25NiDV/1fLtSgEgyvVyDunvaNHbuwF9lfNV17gSmPFAlYzdfNBlLtLzXTevwkPj7DhGbmN9VnmJIgLnhvaBw==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/split-string": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", - "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "extend-shallow": "^3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/split-string/node_modules/extend-shallow": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", - "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "assign-symbols": "^1.0.0", - "is-extendable": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/split-string/node_modules/is-extendable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", - "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-plain-object": "^2.0.4" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/split-string/node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stack-trace": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", - "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/static-extend": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", - "integrity": "sha512-72E9+uLc27Mt718pMHt9VMNiAL4LMsmDbBva8mxWUCkT07fSzEGMYUCk0XWY6lp0j6RBAG4cJ3mWuZv2OE3s0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-property": "^0.2.5", - "object-copy": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" + "streamx": "^2.13.2" } }, "node_modules/stream-exhaust": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/stream-exhaust/-/stream-exhaust-1.0.2.tgz", "integrity": "sha512-b/qaq/GlBK5xaq1yrK9/zFcyRSTNxmcZwFLGSTG0mXgZl/4Z6GgiyYOXOvY7N3eEvFRAG1bkDRz5EPGSvPYQlw==", - "dev": true, - "license": "MIT" + "dev": true }, - "node_modules/stream-shift": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", - "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "node_modules/streamx": { + "version": "2.21.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.21.1.tgz", + "integrity": "sha512-PhP9wUnFLa+91CPy3N6tiQsK+gnYyUNuk15S3YG/zjYE7RuPeCjJngqnzpC31ow0lzBHQ+QGO4cNJnd0djYUsw==", "dev": true, - "license": "MIT" + "dependencies": { + "fast-fifo": "^1.3.2", + "queue-tick": "^1.0.1", + "text-decoder": "^1.1.0" + }, + "optionalDependencies": { + "bare-events": "^2.2.0" + } }, "node_modules/string_decoder": { "version": "1.1.1", @@ -9311,41 +6676,17 @@ } }, "node_modules/string-width": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", - "integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, - "license": "MIT", "dependencies": { - "code-point-at": "^1.0.0", - "is-fullwidth-code-point": "^1.0.0", - "strip-ansi": "^3.0.0" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/string-width/node_modules/ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/string-width/node_modules/strip-ansi": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, "node_modules/strip-ansi": { @@ -9361,29 +6702,6 @@ "node": ">=8" } }, - "node_modules/strip-bom": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", - "integrity": "sha512-kwrX1y7czp1E69n2ajbG65mIo9dqvJ+8aBQXOGVxqwvNbsXdFM6Lq37dLAY3mknUwru8CfcCbfOLL/gMo+fi3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-utf8": "^0.2.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/strip-bom-string": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", - "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -9413,15 +6731,34 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/sver-compat": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/sver-compat/-/sver-compat-1.5.0.tgz", - "integrity": "sha512-aFTHfmjwizMNlNE6dsGmoAM4lHjL0CyiobWaFiXWSlD7cIxshW422Nb8KbXCmR6z+0ZEPY+daXJrDyh/vuwTyg==", + "node_modules/sver": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/sver/-/sver-1.8.4.tgz", + "integrity": "sha512-71o1zfzyawLfIWBOmw8brleKyvnbn73oVHNCsu51uPMz/HWiKkkXsI31JjHW5zqXEqnPYkIiHd8ZmL7FCimLEA==", + "dev": true, + "optionalDependencies": { + "semver": "^6.3.0" + } + }, + "node_modules/sync-child-process": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz", + "integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==", "dev": true, - "license": "MIT", "dependencies": { - "es6-iterator": "^2.0.1", - "es6-symbol": "^3.1.1" + "sync-message-port": "^1.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/sync-message-port": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sync-message-port/-/sync-message-port-1.1.3.tgz", + "integrity": "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg==", + "dev": true, + "engines": { + "node": ">=16.0.0" } }, "node_modules/tapable": { @@ -9476,6 +6813,15 @@ "node": ">= 6" } }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "dev": true, + "dependencies": { + "streamx": "^2.12.5" + } + }, "node_modules/terser": { "version": "5.36.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.36.0.tgz", @@ -9603,6 +6949,15 @@ "dev": true, "license": "MIT" }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "dev": true, + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/textextensions": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/textextensions/-/textextensions-3.3.0.tgz", @@ -9622,28 +6977,6 @@ "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "license": "MIT" }, - "node_modules/through2": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", - "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "readable-stream": "~2.3.6", - "xtend": "~4.0.1" - } - }, - "node_modules/through2-filter": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/through2-filter/-/through2-filter-3.0.0.tgz", - "integrity": "sha512-jaRjI2WxN3W1V8/FMZ9HKIBXixtiqs3SQSX4/YGIiP3gL6djW48VoZq9tDqeCWs3MT8YY5wb/zli8VW8snY1CA==", - "dev": true, - "license": "MIT", - "dependencies": { - "through2": "~2.0.0", - "xtend": "~4.0.0" - } - }, "node_modules/time-stamp": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/time-stamp/-/time-stamp-1.1.0.tgz", @@ -9654,169 +6987,28 @@ "node": ">=0.10.0" } }, - "node_modules/timers-ext": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.8.tgz", - "integrity": "sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww==", - "dev": true, - "license": "ISC", - "dependencies": { - "es5-ext": "^0.10.64", - "next-tick": "^1.1.0" - }, - "engines": { - "node": ">=0.12" - } - }, - "node_modules/to-absolute-glob": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/to-absolute-glob/-/to-absolute-glob-2.0.2.tgz", - "integrity": "sha512-rtwLUQEwT8ZeKQbyFJyomBRYXyE16U5VKuy0ftxLMK/PZb2fkOsg5r9kHdauuVDbsNdIBoC/HCthpidamQFXYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-absolute": "^1.0.0", - "is-negated-glob": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/to-object-path": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", - "integrity": "sha512-9mWHdnGRuh3onocaHzukyvCZhzvr6tiflAy/JRFXcJX0TjgfWA9pk9t8CMbzmBE4Jfw58pXbkngtBtqYxzNEyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "kind-of": "^3.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/to-object-path/node_modules/kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-buffer": "^1.1.5" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/to-regex": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz", - "integrity": "sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-property": "^2.0.2", - "extend-shallow": "^3.0.2", - "regex-not": "^1.0.2", - "safe-regex": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/to-regex-range": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", - "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==", - "dev": true, - "license": "MIT", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "devOptional": true, "dependencies": { - "is-number": "^3.0.0", - "repeat-string": "^1.6.1" + "is-number": "^7.0.0" }, "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/to-regex/node_modules/define-property": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz", - "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-descriptor": "^1.0.2", - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/to-regex/node_modules/extend-shallow": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", - "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "assign-symbols": "^1.0.0", - "is-extendable": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/to-regex/node_modules/is-descriptor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.3.tgz", - "integrity": "sha512-JCNNGbwWZEVaSPtS45mdtrneRWJFp07LLmykxeFV5F6oBvNF8vHSfJuJgoT472pSfk+Mf8VnlrspaFBHWM8JAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-accessor-descriptor": "^1.0.1", - "is-data-descriptor": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/to-regex/node_modules/is-extendable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", - "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-plain-object": "^2.0.4" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/to-regex/node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" + "node": ">=8.0" } }, "node_modules/to-through": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-through/-/to-through-2.0.0.tgz", - "integrity": "sha512-+QIz37Ly7acM4EMdw2PRN389OneM5+d844tirkGp4dPKzI5OE72V9OsbFp+CIYJDahZ41ZV05hNtcPAQUAm9/Q==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/to-through/-/to-through-3.0.0.tgz", + "integrity": "sha512-y8MN937s/HVhEoBU1SxfHC+wxCHkV1a9gW8eAdTadYh/bGyesZIVcbjI+mSpFbSVwQici/XjBjuUyri1dnXwBw==", "dev": true, - "license": "MIT", "dependencies": { - "through2": "^2.0.3" + "streamx": "^2.12.5" }, "engines": { - "node": ">= 0.10" + "node": ">=10.13.0" } }, "node_modules/tr46": { @@ -9838,20 +7030,6 @@ "dev": true, "license": "0BSD" }, - "node_modules/type": { - "version": "2.7.3", - "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", - "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", - "dev": true, - "license": "MIT" - }, "node_modules/unbzip2-stream": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", @@ -9867,47 +7045,38 @@ "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", "integrity": "sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/undertaker": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/undertaker/-/undertaker-1.3.0.tgz", - "integrity": "sha512-/RXwi5m/Mu3H6IHQGww3GNt1PNXlbeCuclF2QYR14L/2CHPz3DFZkvB5hZ0N/QUkiXWCACML2jXViIQEQc2MLg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/undertaker/-/undertaker-2.0.0.tgz", + "integrity": "sha512-tO/bf30wBbTsJ7go80j0RzA2rcwX6o7XPBpeFcb+jzoeb4pfMM2zUeSDIkY1AWqeZabWxaQZ/h8N9t35QKDLPQ==", "dev": true, - "license": "MIT", "dependencies": { - "arr-flatten": "^1.0.1", - "arr-map": "^2.0.0", - "bach": "^1.0.0", - "collection-map": "^1.0.0", - "es6-weak-map": "^2.0.1", - "fast-levenshtein": "^1.0.0", - "last-run": "^1.1.0", - "object.defaults": "^1.0.0", - "object.reduce": "^1.0.0", - "undertaker-registry": "^1.0.0" + "bach": "^2.0.1", + "fast-levenshtein": "^3.0.0", + "last-run": "^2.0.0", + "undertaker-registry": "^2.0.0" }, "engines": { - "node": ">= 0.10" + "node": ">=10.13.0" } }, "node_modules/undertaker-registry": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/undertaker-registry/-/undertaker-registry-1.0.1.tgz", - "integrity": "sha512-UR1khWeAjugW3548EfQmL9Z7pGMlBgXteQpr1IZeZBtnkCJQJIJ1Scj0mb9wQaPvUZ9Q17XqW6TIaPchJkyfqw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/undertaker-registry/-/undertaker-registry-2.0.0.tgz", + "integrity": "sha512-+hhVICbnp+rlzZMgxXenpvTxpuvA67Bfgtt+O9WOE5jo7w/dyiF1VmoZVIHvP2EkUjsyKyTwYKlLhA+j47m1Ew==", "dev": true, - "license": "MIT", "engines": { - "node": ">= 0.10" + "node": ">= 10.13.0" } }, "node_modules/undici": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.0.tgz", - "integrity": "sha512-BUgJXc752Kou3oOIuU1i+yZZypyZRqNPW0vqoMPl8VaoalSfeR0D8/t4iAS3yirs79SSMTxTag+ZC86uswv+Cw==", + "version": "6.21.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.1.tgz", + "integrity": "sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ==", "license": "MIT", "engines": { "node": ">=18.17" @@ -9964,96 +7133,6 @@ "node": ">=4" } }, - "node_modules/union-value": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", - "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", - "dev": true, - "license": "MIT", - "dependencies": { - "arr-union": "^3.1.0", - "get-value": "^2.0.6", - "is-extendable": "^0.1.1", - "set-value": "^2.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/unique-stream": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/unique-stream/-/unique-stream-2.3.1.tgz", - "integrity": "sha512-2nY4TnBE70yoxHkDli7DMazpWiP7xMdCYqU2nBRO0UB+ZpEkGsSija7MvmvnZFUeC+mrgiUfcHSr3LmRFIg4+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-stable-stringify-without-jsonify": "^1.0.1", - "through2-filter": "^3.0.0" - } - }, - "node_modules/unset-value": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz", - "integrity": "sha512-PcA2tsuGSF9cnySLHTLSh2qrQiJ70mn+r+Glzxv2TWZblxsxCC52BDlZoPCsz7STd9pN7EZetkWZBAvk4cgZdQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-value": "^0.3.1", - "isobject": "^3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/unset-value/node_modules/has-value": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/has-value/-/has-value-0.3.1.tgz", - "integrity": "sha512-gpG936j8/MzaeID5Yif+577c17TxaDmhuyVgSwtnL/q8UUTySg8Mecb+8Cf1otgLoD7DDH75axp86ER7LFsf3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-value": "^2.0.3", - "has-values": "^0.1.4", - "isobject": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/unset-value/node_modules/has-value/node_modules/isobject": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", - "integrity": "sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA==", - "dev": true, - "license": "MIT", - "dependencies": { - "isarray": "1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/unset-value/node_modules/has-values": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/has-values/-/has-values-0.1.4.tgz", - "integrity": "sha512-J8S0cEdWuQbqD9//tlZxiMuMNmxB8PlEwvYwuxsTmR1G5RXUePEX/SJn7aD0GMLieuZYSwNH0cQuJGwnYunXRQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/upath": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", - "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4", - "yarn": "*" - } - }, "node_modules/update-browserslist-db": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", @@ -10095,24 +7174,6 @@ "punycode": "^2.1.0" } }, - "node_modules/urix": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz", - "integrity": "sha512-Am1ousAhSLBeB9cG/7k7r2R0zj50uDRlZHPGbazid5s9rlF1F/QKYObEKSIunSjIOkJZqwRRLpvewjEkM7pSqg==", - "deprecated": "Please see https://github.com/lydell/urix#deprecated", - "dev": true, - "license": "MIT" - }, - "node_modules/use": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", - "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -10120,37 +7181,21 @@ "license": "MIT" }, "node_modules/v8flags": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-3.2.0.tgz", - "integrity": "sha512-mH8etigqMfiGWdeXpaaqGfs6BndypxusHHcv2qSHyZkGEznCd/qAXCWWRzeowtL54147cktFOC4P5y+kl8d8Jg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-4.0.1.tgz", + "integrity": "sha512-fcRLaS4H/hrZk9hYwbdRM35D0U8IYMfEClhXxCivOojl+yTRAZH3Zy2sSy6qVCiGbV9YAtPssP6jaChqC9vPCg==", "dev": true, - "license": "MIT", - "dependencies": { - "homedir-polyfill": "^1.0.1" - }, "engines": { - "node": ">= 0.10" - } - }, - "node_modules/validate-npm-package-license": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", - "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" + "node": ">= 10.13.0" } }, "node_modules/value-or-function": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/value-or-function/-/value-or-function-3.0.0.tgz", - "integrity": "sha512-jdBB2FrWvQC/pnPtIqcLsMaQgjhdb6B7tk1MMyTKapox+tQZbdRP4uLxu/JY0t7fbfDCUMnuelzEYv5GsxHhdg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/value-or-function/-/value-or-function-4.0.0.tgz", + "integrity": "sha512-aeVK81SIuT6aMJfNo9Vte8Dw0/FZINGBV8BfCraGtqVxIeLAEhJyoWs8SmvRVmXfGss2PmmOwZCuBPbZR+IYWg==", "dev": true, - "license": "MIT", "engines": { - "node": ">= 0.10" + "node": ">= 10.13.0" } }, "node_modules/varint": { @@ -10178,72 +7223,156 @@ "node": ">= 0.10" } }, - "node_modules/vinyl-fs": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/vinyl-fs/-/vinyl-fs-3.0.3.tgz", - "integrity": "sha512-vIu34EkyNyJxmP0jscNzWBSygh7VWhqun6RmqVfXePrOwi9lhvRs//dOaGOTRUQr4tx7/zd26Tk5WeSVZitgng==", + "node_modules/vinyl-contents": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/vinyl-contents/-/vinyl-contents-2.0.0.tgz", + "integrity": "sha512-cHq6NnGyi2pZ7xwdHSW1v4Jfnho4TEGtxZHw01cmnc8+i7jgR6bRnED/LbrKan/Q7CvVLbnvA5OepnhbpjBZ5Q==", "dev": true, - "license": "MIT", "dependencies": { - "fs-mkdirp-stream": "^1.0.0", - "glob-stream": "^6.1.0", - "graceful-fs": "^4.0.0", - "is-valid-glob": "^1.0.0", - "lazystream": "^1.0.0", - "lead": "^1.0.0", - "object.assign": "^4.0.4", - "pumpify": "^1.3.5", - "readable-stream": "^2.3.3", - "remove-bom-buffer": "^3.0.0", - "remove-bom-stream": "^1.2.0", - "resolve-options": "^1.1.0", - "through2": "^2.0.0", - "to-through": "^2.0.0", - "value-or-function": "^3.0.0", - "vinyl": "^2.0.0", - "vinyl-sourcemap": "^1.1.0" + "bl": "^5.0.0", + "vinyl": "^3.0.0" }, "engines": { - "node": ">= 0.10" + "node": ">=10.13.0" + } + }, + "node_modules/vinyl-contents/node_modules/bl": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz", + "integrity": "sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==", + "dev": true, + "dependencies": { + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/vinyl-contents/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/vinyl-contents/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/vinyl-contents/node_modules/vinyl": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.0.tgz", + "integrity": "sha512-rC2VRfAVVCGEgjnxHUnpIVh3AGuk62rP3tqVrn+yab0YH7UULisC085+NYH+mnqf3Wx4SpSi1RQMwudL89N03g==", + "dev": true, + "dependencies": { + "clone": "^2.1.2", + "clone-stats": "^1.0.0", + "remove-trailing-separator": "^1.1.0", + "replace-ext": "^2.0.0", + "teex": "^1.0.1" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/vinyl-fs": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/vinyl-fs/-/vinyl-fs-4.0.0.tgz", + "integrity": "sha512-7GbgBnYfaquMk3Qu9g22x000vbYkOex32930rBnc3qByw6HfMEAoELjCjoJv4HuEQxHAurT+nvMHm6MnJllFLw==", + "dev": true, + "dependencies": { + "fs-mkdirp-stream": "^2.0.1", + "glob-stream": "^8.0.0", + "graceful-fs": "^4.2.11", + "iconv-lite": "^0.6.3", + "is-valid-glob": "^1.0.0", + "lead": "^4.0.0", + "normalize-path": "3.0.0", + "resolve-options": "^2.0.0", + "stream-composer": "^1.0.2", + "streamx": "^2.14.0", + "to-through": "^3.0.0", + "value-or-function": "^4.0.0", + "vinyl": "^3.0.0", + "vinyl-sourcemap": "^2.0.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/vinyl-fs/node_modules/vinyl": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.0.tgz", + "integrity": "sha512-rC2VRfAVVCGEgjnxHUnpIVh3AGuk62rP3tqVrn+yab0YH7UULisC085+NYH+mnqf3Wx4SpSi1RQMwudL89N03g==", + "dev": true, + "dependencies": { + "clone": "^2.1.2", + "clone-stats": "^1.0.0", + "remove-trailing-separator": "^1.1.0", + "replace-ext": "^2.0.0", + "teex": "^1.0.1" + }, + "engines": { + "node": ">=10.13.0" } }, "node_modules/vinyl-sourcemap": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/vinyl-sourcemap/-/vinyl-sourcemap-1.1.0.tgz", - "integrity": "sha512-NiibMgt6VJGJmyw7vtzhctDcfKch4e4n9TBeoWlirb7FMg9/1Ov9k+A5ZRAtywBpRPiyECvQRQllYM8dECegVA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/vinyl-sourcemap/-/vinyl-sourcemap-2.0.0.tgz", + "integrity": "sha512-BAEvWxbBUXvlNoFQVFVHpybBbjW1r03WhohJzJDSfgrrK5xVYIDTan6xN14DlyImShgDRv2gl9qhM6irVMsV0Q==", "dev": true, - "license": "MIT", "dependencies": { - "append-buffer": "^1.0.2", - "convert-source-map": "^1.5.0", - "graceful-fs": "^4.1.6", - "normalize-path": "^2.1.1", - "now-and-later": "^2.0.0", - "remove-bom-buffer": "^3.0.0", - "vinyl": "^2.0.0" + "convert-source-map": "^2.0.0", + "graceful-fs": "^4.2.10", + "now-and-later": "^3.0.0", + "streamx": "^2.12.5", + "vinyl": "^3.0.0", + "vinyl-contents": "^2.0.0" }, "engines": { - "node": ">= 0.10" + "node": ">=10.13.0" } }, - "node_modules/vinyl-sourcemap/node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "node_modules/vinyl-sourcemap/node_modules/vinyl": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.0.tgz", + "integrity": "sha512-rC2VRfAVVCGEgjnxHUnpIVh3AGuk62rP3tqVrn+yab0YH7UULisC085+NYH+mnqf3Wx4SpSi1RQMwudL89N03g==", "dev": true, - "license": "MIT" - }, - "node_modules/vinyl-sourcemap/node_modules/normalize-path": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", - "integrity": "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==", - "dev": true, - "license": "MIT", "dependencies": { - "remove-trailing-separator": "^1.0.1" + "clone": "^2.1.2", + "clone-stats": "^1.0.0", + "remove-trailing-separator": "^1.1.0", + "replace-ext": "^2.0.0", + "teex": "^1.0.1" }, "engines": { - "node": ">=0.10.0" + "node": ">=10.13.0" } }, "node_modules/vinyl-sourcemaps-apply": { @@ -10478,7 +7607,6 @@ "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", "dev": true, - "license": "ISC", "dependencies": { "isexe": "^2.0.0" }, @@ -10486,13 +7614,6 @@ "which": "bin/which" } }, - "node_modules/which-module": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-1.0.0.tgz", - "integrity": "sha512-F6+WgncZi/mJDrammbTuHe1q0R5hOXv/mBaiNA2TCNT/LTHusX0V+CJnj9XT8ki5ln2UZyyddDgHfCzyrOH7MQ==", - "dev": true, - "license": "ISC" - }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", @@ -10500,40 +7621,20 @@ "license": "MIT" }, "node_modules/wrap-ansi": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", - "integrity": "sha512-vAaEaDM946gbNpH5pLVNR+vX2ht6n0Bt3GXwVB1AuAqZosOvHNF3P7wDnh8KLkSqgUh0uh77le7Owgoz+Z9XBw==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, - "license": "MIT", "dependencies": { - "string-width": "^1.0.1", - "strip-ansi": "^3.0.1" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^2.0.0" + "node": ">=10" }, - "engines": { - "node": ">=0.10.0" + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/wrappy": { @@ -10563,22 +7664,14 @@ } } }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4" - } - }, "node_modules/y18n": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.2.tgz", - "integrity": "sha512-uGZHXkHnhF0XeeAPgnKfPv1bgKAYyVvmNL1xlKsPYZPaIHxGti2hHqvOCQv71XMsLxu1QjergkqogUnms5D3YQ==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true, - "license": "ISC" + "engines": { + "node": ">=10" + } }, "node_modules/yallist": { "version": "3.1.1", @@ -10598,25 +7691,21 @@ } }, "node_modules/yargs": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-7.1.2.tgz", - "integrity": "sha512-ZEjj/dQYQy0Zx0lgLMLR8QuaqTihnxirir7EwUHp1Axq4e3+k8jXU5K0VLbNvedv1f4EWtBonDIZm0NUr+jCcA==", + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", "dev": true, - "license": "MIT", "dependencies": { - "camelcase": "^3.0.0", - "cliui": "^3.2.0", - "decamelize": "^1.1.1", - "get-caller-file": "^1.0.1", - "os-locale": "^1.4.0", - "read-pkg-up": "^1.0.1", + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", - "require-main-filename": "^1.0.1", - "set-blocking": "^2.0.0", - "string-width": "^1.0.2", - "which-module": "^1.0.0", - "y18n": "^3.2.1", - "yargs-parser": "^5.0.1" + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" } }, "node_modules/yargs-parser": { @@ -10630,14 +7719,12 @@ } }, "node_modules/yargs/node_modules/yargs-parser": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-5.0.1.tgz", - "integrity": "sha512-wpav5XYiddjXxirPoCTUPbqM0PXvJ9hiBMvuJgInvo4/lAOTZzUprArw17q2O1P2+GHhbBr18/iQwjL5Z9BqfA==", + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", "dev": true, - "license": "ISC", - "dependencies": { - "camelcase": "^3.0.0", - "object.assign": "^4.1.0" + "engines": { + "node": ">=10" } }, "node_modules/yauzl": { diff --git a/src/package.json b/src/package.json index e433d0126..d915b2384 100644 --- a/src/package.json +++ b/src/package.json @@ -17,10 +17,13 @@ "devDependencies": { "@babel/core": "^7.26.0", "@babel/preset-env": "^7.26.0", - "@uswds/compile": "1.1.0", + "@uswds/compile": "1.2.1", "babel-loader": "^9.2.1", "sass-loader": "^12.6.0", "webpack": "^5.96.1", "webpack-stream": "^7.0.0" + }, + "overrides": { + "semver": "^7.5.3" } } diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 4465b7098..7dbe7abb0 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -14,6 +14,7 @@ from django.db.models import ( from django.db.models.functions import Concat, Coalesce from django.http import HttpResponseRedirect from registrar.models.federal_agency import FederalAgency +from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.utility.admin_helpers import ( AutocompleteSelectWithPlaceholder, get_action_needed_reason_default_email, @@ -21,10 +22,18 @@ 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_invitations import send_domain_invitation_email, send_portfolio_invitation_email +from registrar.views.utility.invitation_helper import ( + get_org_membership, + get_requested_user, + handle_invitation_exceptions, +) from waffle.decorators import flag_is_active from django.contrib import admin, messages from django.contrib.auth.admin import UserAdmin as BaseUserAdmin @@ -1213,9 +1222,9 @@ class ContactAdmin(ListHeaderAdmin, ImportExportModelAdmin): class SeniorOfficialAdmin(ListHeaderAdmin): """Custom Senior Official Admin class.""" - search_fields = ["first_name", "last_name", "email"] + search_fields = ["first_name", "last_name", "email", "federal_agency__agency"] search_help_text = "Search by first name, last name or email." - list_display = ["first_name", "last_name", "email", "federal_agency"] + list_display = ["federal_agency", "first_name", "last_name", "email"] # this ordering effects the ordering of results # in autocomplete_fields for Senior Official @@ -1312,6 +1321,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) @@ -1356,6 +1367,8 @@ class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin): autocomplete_fields = ["user", "domain"] + change_form_template = "django/admin/user_domain_role_change_form.html" + # Fixes a bug where non-superusers are redirected to the main page def delete_view(self, request, object_id, extra_context=None): """Custom delete_view implementation that specifies redirect behaviour""" @@ -1383,7 +1396,81 @@ class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin): return super().changeform_view(request, object_id, form_url, extra_context=extra_context) -class DomainInvitationAdmin(ListHeaderAdmin): +class BaseInvitationAdmin(ListHeaderAdmin): + """Base class for admin classes which will customize save_model and send email invitations + on model adds, and require custom handling of forms and form errors.""" + + 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. + """ + # store current messages from request in storage so that they are preserved throughout the + # method, as some flows remove and replace all messages, and so we store here to retrieve + # later + storage = get_messages(request) + # Check if there are any error messages in the `messages` framework + # error messages stop the workflow; other message levels allow flow to continue as normal + has_errors = any(message.level_tag in ["error"] 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, + ) + + response = super().response_add(request, obj, post_url_continue) + + # Re-add all messages from storage after `super().response_add` + # as super().response_add resets the success messages in request + for message in storage: + messages.add_message(request, message.level, message.message) + + return response + + +class DomainInvitationAdmin(BaseInvitationAdmin): """Custom domain invitation admin class.""" class Meta: @@ -1418,7 +1505,7 @@ class DomainInvitationAdmin(ListHeaderAdmin): autocomplete_fields = ["domain"] - change_form_template = "django/admin/email_clipboard_change_form.html" + change_form_template = "django/admin/domain_invitation_change_form.html" # Select domain invitations to change -> Domain invitations def changelist_view(self, request, extra_context=None): @@ -1428,8 +1515,69 @@ class DomainInvitationAdmin(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. -class PortfolioInvitationAdmin(ListHeaderAdmin): + On creation of a new domain invitation, attempt to retrieve the invitation, + which will be successful if a single User exists for that email; otherwise, will + just continue to create the invitation. + """ + if not change: + domain = obj.domain + domain_org = getattr(domain.domain_info, "portfolio", None) + requested_email = obj.email + # Look up a user with that email + requested_user = get_requested_user(requested_email) + requestor = request.user + + member_of_a_different_org, member_of_this_org = get_org_membership( + domain_org, requested_email, requested_user + ) + + try: + if ( + flag_is_active(request, "organization_feature") + and not flag_is_active(request, "multiple_portfolios") + and domain_org is not None + and not member_of_this_org + and not member_of_a_different_org + ): + send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=domain_org) + portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create( + email=requested_email, + portfolio=domain_org, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + ) + # if user exists for email, immediately retrieve portfolio invitation upon creation + if requested_user is not None: + portfolio_invitation.retrieve() + portfolio_invitation.save() + messages.success(request, f"{requested_email} has been invited to the organization: {domain_org}") + + if not send_domain_invitation_email( + email=requested_email, + requestor=requestor, + domains=domain, + is_member_of_different_org=member_of_a_different_org, + requested_user=requested_user, + ): + messages.warning(request, "Could not send email confirmation to existing domain managers.") + if requested_user is not None: + # Domain Invitation creation for an existing User + obj.retrieve() + # Call the parent save method to save the object + super().save_model(request, obj, form, change) + messages.success(request, f"{requested_email} has been invited to the domain: {domain}") + except Exception as e: + handle_invitation_exceptions(request, e, requested_email) + return + else: + # Call the parent save method to save the object + super().save_model(request, obj, form, change) + + +class PortfolioInvitationAdmin(BaseInvitationAdmin): """Custom portfolio invitation admin class.""" form = PortfolioInvitationAdminForm @@ -1452,7 +1600,7 @@ class PortfolioInvitationAdmin(ListHeaderAdmin): # Search search_fields = [ "email", - "portfolio__name", + "portfolio__organization_name", ] # Filters @@ -1468,7 +1616,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 +1626,41 @@ 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 + # Look up a user with that email + requested_user = get_requested_user(requested_email) + + 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) + # if user exists for email, immediately retrieve portfolio invitation upon creation + if requested_user is not None: + obj.retrieve() + 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 + handle_invitation_exceptions(request, e, requested_email) + return + # Call the parent save method to save the object + super().save_model(request, obj, form, change) + class DomainInformationResource(resources.ModelResource): """defines how each field in the referenced model should be mapped to the corresponding fields in the @@ -1499,22 +1682,25 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): parameter_name = "converted_generic_orgs" def lookups(self, request, model_admin): - converted_generic_orgs = set() + # Annotate the queryset to avoid Python-side iteration + queryset = ( + DomainInformation.objects.annotate( + converted_generic_org=Case( + When(portfolio__organization_type__isnull=False, then="portfolio__organization_type"), + When(portfolio__isnull=True, generic_org_type__isnull=False, then="generic_org_type"), + default=Value(""), + output_field=CharField(), + ) + ) + .values_list("converted_generic_org", flat=True) + .distinct() + ) - # Populate the set with tuples of (value, display value) - for domain_info in DomainInformation.objects.all(): - converted_generic_org = domain_info.converted_generic_org_type # Actual value - converted_generic_org_display = domain_info.converted_generic_org_type_display # Display value + # Filter out empty results and return sorted list of unique values + return sorted([(org, DomainRequest.OrganizationChoices.get_org_label(org)) for org in queryset if org]) - if converted_generic_org: - converted_generic_orgs.add((converted_generic_org, converted_generic_org_display)) # Value, Display - - # Sort the set by display value - return sorted(converted_generic_orgs, key=lambda x: x[1]) # x[1] is the display value - - # Filter queryset def queryset(self, request, queryset): - if self.value(): # Check if a generic org is selected in the filter + if self.value(): return queryset.filter( Q(portfolio__organization_type=self.value()) | Q(portfolio__isnull=True, generic_org_type=self.value()) @@ -1830,10 +2016,12 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): form = DomainRequestAdminForm change_form_template = "django/admin/domain_request_change_form.html" + # ------ Filters ------ + # Define custom filters class StatusListFilter(MultipleChoiceListFilter): """Custom status filter which is a multiple choice filter""" - title = "Status" + title = "status" parameter_name = "status__in" template = "django/admin/multiple_choice_list_filter.html" @@ -1850,22 +2038,25 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): parameter_name = "converted_generic_orgs" def lookups(self, request, model_admin): - converted_generic_orgs = set() + # Annotate the queryset to avoid Python-side iteration + queryset = ( + DomainRequest.objects.annotate( + converted_generic_org=Case( + When(portfolio__organization_type__isnull=False, then="portfolio__organization_type"), + When(portfolio__isnull=True, generic_org_type__isnull=False, then="generic_org_type"), + default=Value(""), + output_field=CharField(), + ) + ) + .values_list("converted_generic_org", flat=True) + .distinct() + ) - # Populate the set with tuples of (value, display value) - for domain_request in DomainRequest.objects.all(): - converted_generic_org = domain_request.converted_generic_org_type # Actual value - converted_generic_org_display = domain_request.converted_generic_org_type_display # Display value + # Filter out empty results and return sorted list of unique values + return sorted([(org, DomainRequest.OrganizationChoices.get_org_label(org)) for org in queryset if org]) - if converted_generic_org: - converted_generic_orgs.add((converted_generic_org, converted_generic_org_display)) # Value, Display - - # Sort the set by display value - return sorted(converted_generic_orgs, key=lambda x: x[1]) # x[1] is the display value - - # Filter queryset def queryset(self, request, queryset): - if self.value(): # Check if a generic org is selected in the filter + if self.value(): return queryset.filter( Q(portfolio__organization_type=self.value()) | Q(portfolio__isnull=True, generic_org_type=self.value()) @@ -1877,28 +2068,43 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): If we have a portfolio, use the portfolio's federal type. If not, use the organization in the Domain Request object.""" - title = "federal Type" + title = "federal type" parameter_name = "converted_federal_types" def lookups(self, request, model_admin): - converted_federal_types = set() - - # Populate the set with tuples of (value, display value) - for domain_request in DomainRequest.objects.all(): - converted_federal_type = domain_request.converted_federal_type # Actual value - converted_federal_type_display = domain_request.converted_federal_type_display # Display value - - if converted_federal_type: - converted_federal_types.add( - (converted_federal_type, converted_federal_type_display) # Value, Display + # Annotate the queryset for efficient filtering + queryset = ( + DomainRequest.objects.annotate( + converted_federal_type=Case( + When( + portfolio__isnull=False, + portfolio__federal_agency__federal_type__isnull=False, + then="portfolio__federal_agency__federal_type", + ), + When( + portfolio__isnull=True, + federal_agency__federal_type__isnull=False, + then="federal_agency__federal_type", + ), + default=Value(""), + output_field=CharField(), ) + ) + .values_list("converted_federal_type", flat=True) + .distinct() + ) - # Sort the set by display value - return sorted(converted_federal_types, key=lambda x: x[1]) # x[1] is the display value + # Filter out empty values and return sorted unique entries + return sorted( + [ + (federal_type, BranchChoices.get_branch_label(federal_type)) + for federal_type in queryset + if federal_type + ] + ) - # Filter queryset def queryset(self, request, queryset): - if self.value(): # Check if a federal type is selected in the filter + if self.value(): return queryset.filter( Q(portfolio__federal_agency__federal_type=self.value()) | Q(portfolio__isnull=True, federal_type=self.value()) @@ -1965,13 +2171,58 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): if self.value() == "0": return queryset.filter(Q(is_election_board=False) | Q(is_election_board=None)) + class PortfolioFilter(admin.SimpleListFilter): + """Define a custom filter for portfolio""" + + title = _("portfolio") + parameter_name = "portfolio__isnull" + + def lookups(self, request, model_admin): + return ( + ("1", _("Yes")), + ("0", _("No")), + ) + + def queryset(self, request, queryset): + if self.value() == "1": + return queryset.filter(Q(portfolio__isnull=False)) + if self.value() == "0": + return queryset.filter(Q(portfolio__isnull=True)) + + # ------ Custom fields ------ + def custom_election_board(self, obj): + return "Yes" if obj.is_election_board else "No" + + custom_election_board.admin_order_field = "is_election_board" # type: ignore + custom_election_board.short_description = "Election office" # type: ignore + + @admin.display(description=_("Requested Domain")) + def custom_requested_domain(self, obj): + # Example: Show different icons based on `status` + url = reverse("admin:registrar_domainrequest_changelist") + f"{obj.id}" + text = obj.requested_domain + if obj.portfolio: + return format_html(' {}', url, text) + return format_html('{}', url, text) + + custom_requested_domain.admin_order_field = "requested_domain__name" # type: ignore + + # ------ Converted fields ------ + # These fields map to @Property methods and + # require these custom definitions to work properly @admin.display(description=_("Generic Org Type")) def converted_generic_org_type(self, obj): return obj.converted_generic_org_type_display @admin.display(description=_("Organization Name")) def converted_organization_name(self, obj): - return obj.converted_organization_name + # Example: Show different icons based on `status` + if obj.portfolio: + url = reverse("admin:registrar_portfolio_change", args=[obj.portfolio.id]) + text = obj.converted_organization_name + return format_html('{}', url, text) + else: + return obj.converted_organization_name @admin.display(description=_("Federal Agency")) def converted_federal_agency(self, obj): @@ -1989,34 +2240,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): def converted_state_territory(self, obj): return obj.converted_state_territory - # Columns - list_display = [ - "requested_domain", - "first_submitted_date", - "last_submitted_date", - "last_status_update", - "status", - "custom_election_board", - "converted_generic_org_type", - "converted_organization_name", - "converted_federal_agency", - "converted_federal_type", - "converted_city", - "converted_state_territory", - "investigator", - ] - - orderable_fk_fields = [ - ("requested_domain", "name"), - ("investigator", ["first_name", "last_name"]), - ] - - def custom_election_board(self, obj): - return "Yes" if obj.is_election_board else "No" - - custom_election_board.admin_order_field = "is_election_board" # type: ignore - custom_election_board.short_description = "Election office" # type: ignore - + # ------ Portfolio fields ------ # Define methods to display fields from the related portfolio def portfolio_senior_official(self, obj) -> Optional[SeniorOfficial]: return obj.portfolio.senior_official if obj.portfolio and obj.portfolio.senior_official else None @@ -2086,10 +2310,33 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): def status_history(self, obj): return "No changelog to display." - status_history.short_description = "Status History" # type: ignore + status_history.short_description = "Status history" # type: ignore + + # Columns + list_display = [ + "custom_requested_domain", + "first_submitted_date", + "last_submitted_date", + "last_status_update", + "status", + "custom_election_board", + "converted_generic_org_type", + "converted_organization_name", + "converted_federal_agency", + "converted_federal_type", + "converted_city", + "converted_state_territory", + "investigator", + ] + + orderable_fk_fields = [ + ("requested_domain", "name"), + ("investigator", ["first_name", "last_name"]), + ] # Filters list_filter = ( + PortfolioFilter, StatusListFilter, GenericOrgFilter, FederalTypeFilter, @@ -2099,13 +2346,14 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): ) # Search + # NOTE: converted fields are included in the override for get_search_results search_fields = [ "requested_domain__name", "creator__email", "creator__first_name", "creator__last_name", ] - search_help_text = "Search by domain or creator." + search_help_text = "Search by domain, creator, or organization name." fieldsets = [ ( @@ -2271,9 +2519,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): "cisa_representative_first_name", "cisa_representative_last_name", "cisa_representative_email", - "requested_suborganization", - "suborganization_city", - "suborganization_state_territory", ] autocomplete_fields = [ @@ -2577,8 +2822,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) @@ -2587,7 +2854,9 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): try: # Retrieve and order audit log entries by timestamp in descending order - audit_log_entries = LogEntry.objects.filter(object_id=object_id).order_by("-timestamp") + audit_log_entries = LogEntry.objects.filter( + object_id=object_id, content_type__model="domainrequest" + ).order_by("-timestamp") # Process each log entry to filter based on the change criteria for log_entry in audit_log_entries: @@ -2692,6 +2961,25 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): qs = qs.filter(portfolio=portfolio_id) return qs + def get_search_results(self, request, queryset, search_term): + # Call the parent's method to apply default search logic + base_queryset, use_distinct = super().get_search_results(request, queryset, search_term) + + # Add custom search logic for the annotated field + if search_term: + annotated_queryset = queryset.filter( + # converted_organization_name + Q(portfolio__organization_name__icontains=search_term) + | Q(portfolio__isnull=True, organization_name__icontains=search_term) + ) + + # Combine the two querysets using union + combined_queryset = base_queryset | annotated_queryset + else: + combined_queryset = base_queryset + + return combined_queryset, use_distinct + class TransitionDomainAdmin(ListHeaderAdmin): """Custom transition domain admin class.""" @@ -2963,59 +3251,86 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): parameter_name = "converted_generic_orgs" def lookups(self, request, model_admin): - converted_generic_orgs = set() + # Annotate the queryset to avoid Python-side iteration + queryset = ( + Domain.objects.annotate( + converted_generic_org=Case( + When( + domain_info__isnull=False, + domain_info__portfolio__organization_type__isnull=False, + then="domain_info__portfolio__organization_type", + ), + When( + domain_info__isnull=False, + domain_info__portfolio__isnull=True, + domain_info__generic_org_type__isnull=False, + then="domain_info__generic_org_type", + ), + default=Value(""), + output_field=CharField(), + ) + ) + .values_list("converted_generic_org", flat=True) + .distinct() + ) - # Populate the set with tuples of (value, display value) - for domain_info in DomainInformation.objects.all(): - converted_generic_org = domain_info.converted_generic_org_type # Actual value - converted_generic_org_display = domain_info.converted_generic_org_type_display # Display value + # Filter out empty results and return sorted list of unique values + return sorted([(org, DomainRequest.OrganizationChoices.get_org_label(org)) for org in queryset if org]) - if converted_generic_org: - converted_generic_orgs.add((converted_generic_org, converted_generic_org_display)) # Value, Display - - # Sort the set by display value - return sorted(converted_generic_orgs, key=lambda x: x[1]) # x[1] is the display value - - # Filter queryset def queryset(self, request, queryset): - if self.value(): # Check if a generic org is selected in the filter + if self.value(): return queryset.filter( Q(domain_info__portfolio__organization_type=self.value()) | Q(domain_info__portfolio__isnull=True, domain_info__generic_org_type=self.value()) ) - return queryset class FederalTypeFilter(admin.SimpleListFilter): """Custom Federal Type filter that accomodates portfolio feature. If we have a portfolio, use the portfolio's federal type. If not, use the - federal type in the Domain Information object.""" + organization in the Domain Request object.""" title = "federal type" parameter_name = "converted_federal_types" def lookups(self, request, model_admin): - converted_federal_types = set() - - # Populate the set with tuples of (value, display value) - for domain_info in DomainInformation.objects.all(): - converted_federal_type = domain_info.converted_federal_type # Actual value - converted_federal_type_display = domain_info.converted_federal_type_display # Display value - - if converted_federal_type: - converted_federal_types.add( - (converted_federal_type, converted_federal_type_display) # Value, Display + # Annotate the queryset for efficient filtering + queryset = ( + Domain.objects.annotate( + converted_federal_type=Case( + When( + domain_info__isnull=False, + domain_info__portfolio__isnull=False, + then=F("domain_info__portfolio__federal_agency__federal_type"), + ), + When( + domain_info__isnull=False, + domain_info__portfolio__isnull=True, + domain_info__federal_type__isnull=False, + then="domain_info__federal_agency__federal_type", + ), + default=Value(""), + output_field=CharField(), ) + ) + .values_list("converted_federal_type", flat=True) + .distinct() + ) - # Sort the set by display value - return sorted(converted_federal_types, key=lambda x: x[1]) # x[1] is the display value + # Filter out empty values and return sorted unique entries + return sorted( + [ + (federal_type, BranchChoices.get_branch_label(federal_type)) + for federal_type in queryset + if federal_type + ] + ) - # Filter queryset def queryset(self, request, queryset): - if self.value(): # Check if a federal type is selected in the filter + if self.value(): return queryset.filter( - Q(domain_info__portfolio__federal_agency__federal_type=self.value()) - | Q(domain_info__portfolio__isnull=True, domain_info__federal_agency__federal_type=self.value()) + Q(domain_info__portfolio__federal_type=self.value()) + | Q(domain_info__portfolio__isnull=True, domain_info__federal_type=self.value()) ) return queryset @@ -3746,9 +4061,9 @@ class PortfolioAdmin(ListHeaderAdmin): "senior_official", ] - analyst_readonly_fields = [ - "organization_name", - ] + # Even though this is empty, I will leave it as a stub for easy changes in the future + # rather than strip it out of our logic. + analyst_readonly_fields = [] # type: ignore def get_admin_users(self, obj): # Filter UserPortfolioPermission objects related to the portfolio diff --git a/src/registrar/assets/js/uswds-edited.js b/src/registrar/assets/js/uswds-edited.js index f59417b41..9d4dd2e51 100644 --- a/src/registrar/assets/js/uswds-edited.js +++ b/src/registrar/assets/js/uswds-edited.js @@ -29,6 +29,7 @@ * - tooltip dynamic content updated to include nested element (for better sizing control) * - modal exposed to window to be accessible in other js files * - fixed bug in createHeaderButton which added newlines to header button tooltips + * - modified combobox to handle error class */ if ("document" in window.self) { @@ -1213,6 +1214,11 @@ const enhanceComboBox = _comboBoxEl => { input.setAttribute("class", INPUT_CLASS); input.setAttribute("type", "text"); input.setAttribute("role", "combobox"); + // DOTGOV - handle error class for combobox + // Check if 'usa-input--error' exists in selectEl and add it to input if true + if (selectEl.classList.contains('usa-input--error')) { + input.classList.add('usa-input--error'); + } additionalAttributes.forEach(attr => Object.keys(attr).forEach(key => { const value = Sanitizer.escapeHTML`${attr[key]}`; input.setAttribute(key, value); 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/combobox.js b/src/registrar/assets/src/js/getgov/combobox.js deleted file mode 100644 index 36b7aa0ad..000000000 --- a/src/registrar/assets/src/js/getgov/combobox.js +++ /dev/null @@ -1,113 +0,0 @@ -import { hideElement, showElement } from './helpers.js'; - -export function loadInitialValuesForComboBoxes() { - var overrideDefaultClearButton = true; - var isTyping = false; - - document.addEventListener('DOMContentLoaded', (event) => { - handleAllComboBoxElements(); - }); - - function handleAllComboBoxElements() { - const comboBoxElements = document.querySelectorAll(".usa-combo-box"); - comboBoxElements.forEach(comboBox => { - const input = comboBox.querySelector("input"); - const select = comboBox.querySelector("select"); - if (!input || !select) { - console.warn("No combobox element found"); - return; - } - // Set the initial value of the combobox - let initialValue = select.getAttribute("data-default-value"); - let clearInputButton = comboBox.querySelector(".usa-combo-box__clear-input"); - if (!clearInputButton) { - console.warn("No clear element found"); - return; - } - - // Override the default clear button behavior such that it no longer clears the input, - // it just resets to the data-initial-value. - // Due to the nature of how uswds works, this is slightly hacky. - // Use a MutationObserver to watch for changes in the dropdown list - const dropdownList = comboBox.querySelector(`#${input.id}--list`); - const observer = new MutationObserver(function(mutations) { - mutations.forEach(function(mutation) { - if (mutation.type === "childList") { - addBlankOption(clearInputButton, dropdownList, initialValue); - } - }); - }); - - // Configure the observer to watch for changes in the dropdown list - const config = { childList: true, subtree: true }; - observer.observe(dropdownList, config); - - // Input event listener to detect typing - input.addEventListener("input", () => { - isTyping = true; - }); - - // Blur event listener to reset typing state - input.addEventListener("blur", () => { - isTyping = false; - }); - - // Hide the reset button when there is nothing to reset. - // Do this once on init, then everytime a change occurs. - updateClearButtonVisibility(select, initialValue, clearInputButton) - select.addEventListener("change", () => { - updateClearButtonVisibility(select, initialValue, clearInputButton) - }); - - // Change the default input behaviour - have it reset to the data default instead - clearInputButton.addEventListener("click", (e) => { - if (overrideDefaultClearButton && initialValue) { - e.preventDefault(); - e.stopPropagation(); - input.click(); - // Find the dropdown option with the desired value - const dropdownOptions = document.querySelectorAll(".usa-combo-box__list-option"); - if (dropdownOptions) { - dropdownOptions.forEach(option => { - if (option.getAttribute("data-value") === initialValue) { - // Simulate a click event on the dropdown option - option.click(); - } - }); - } - } - }); - }); - } - - function updateClearButtonVisibility(select, initialValue, clearInputButton) { - if (select.value === initialValue) { - hideElement(clearInputButton); - }else { - showElement(clearInputButton) - } - } - - function addBlankOption(clearInputButton, dropdownList, initialValue) { - if (dropdownList && !dropdownList.querySelector('[data-value=""]') && !isTyping) { - const blankOption = document.createElement("li"); - blankOption.setAttribute("role", "option"); - blankOption.setAttribute("data-value", ""); - blankOption.classList.add("usa-combo-box__list-option"); - if (!initialValue){ - blankOption.classList.add("usa-combo-box__list-option--selected") - } - blankOption.textContent = "⎯"; - - dropdownList.insertBefore(blankOption, dropdownList.firstChild); - blankOption.addEventListener("click", (e) => { - e.preventDefault(); - e.stopPropagation(); - overrideDefaultClearButton = false; - // Trigger the default clear behavior - clearInputButton.click(); - overrideDefaultClearButton = true; - }); - } - } -} diff --git a/src/registrar/assets/src/js/getgov/domain-dnssec.js b/src/registrar/assets/src/js/getgov/domain-dnssec.js new file mode 100644 index 000000000..860359fe0 --- /dev/null +++ b/src/registrar/assets/src/js/getgov/domain-dnssec.js @@ -0,0 +1,15 @@ +import { submitForm } from './helpers.js'; + +export function initDomainDNSSEC() { + document.addEventListener('DOMContentLoaded', function() { + let domain_dnssec_page = document.getElementById("domain-dnssec"); + if (domain_dnssec_page) { + const button = document.getElementById("disable-dnssec-button"); + if (button) { + button.addEventListener("click", function () { + submitForm("disable-dnssec-form"); + }); + } + } + }); +} \ No newline at end of file diff --git a/src/registrar/assets/src/js/getgov/domain-dsdata.js b/src/registrar/assets/src/js/getgov/domain-dsdata.js new file mode 100644 index 000000000..7c0871bec --- /dev/null +++ b/src/registrar/assets/src/js/getgov/domain-dsdata.js @@ -0,0 +1,27 @@ +import { submitForm } from './helpers.js'; + +export function initDomainDSData() { + document.addEventListener('DOMContentLoaded', function() { + let domain_dsdata_page = document.getElementById("domain-dsdata"); + if (domain_dsdata_page) { + const override_button = document.getElementById("disable-override-click-button"); + const cancel_button = document.getElementById("btn-cancel-click-button"); + const cancel_close_button = document.getElementById("btn-cancel-click-close-button"); + if (override_button) { + override_button.addEventListener("click", function () { + submitForm("disable-override-click-form"); + }); + } + if (cancel_button) { + cancel_button.addEventListener("click", function () { + submitForm("btn-cancel-click-form"); + }); + } + if (cancel_close_button) { + cancel_close_button.addEventListener("click", function () { + submitForm("btn-cancel-click-form"); + }); + } + } + }); +} \ No newline at end of file diff --git a/src/registrar/assets/src/js/getgov/domain-managers.js b/src/registrar/assets/src/js/getgov/domain-managers.js new file mode 100644 index 000000000..26eccd8cd --- /dev/null +++ b/src/registrar/assets/src/js/getgov/domain-managers.js @@ -0,0 +1,20 @@ +import { submitForm } from './helpers.js'; + +export function initDomainManagersPage() { + document.addEventListener('DOMContentLoaded', function() { + let domain_managers_page = document.getElementById("domain-managers"); + if (domain_managers_page) { + // Add event listeners for all buttons matching user-delete-button-{NUMBER} + const deleteButtons = document.querySelectorAll('[id^="user-delete-button-"]'); // Select buttons with ID starting with "user-delete-button-" + deleteButtons.forEach((button) => { + const buttonId = button.id; // e.g., "user-delete-button-1" + const number = buttonId.split('-').pop(); // Extract the NUMBER part + const formId = `user-delete-form-${number}`; // Generate the corresponding form ID + + button.addEventListener("click", function () { + submitForm(formId); // Pass the form ID to submitForm + }); + }); + } + }); +} \ No newline at end of file diff --git a/src/registrar/assets/src/js/getgov/domain-request-form.js b/src/registrar/assets/src/js/getgov/domain-request-form.js new file mode 100644 index 000000000..d9b660a50 --- /dev/null +++ b/src/registrar/assets/src/js/getgov/domain-request-form.js @@ -0,0 +1,12 @@ +import { submitForm } from './helpers.js'; + +export function initDomainRequestForm() { + document.addEventListener('DOMContentLoaded', function() { + const button = document.getElementById("domain-request-form-submit-button"); + if (button) { + button.addEventListener("click", function () { + submitForm("submit-domain-request-form"); + }); + } + }); +} \ No newline at end of file diff --git a/src/registrar/assets/src/js/getgov/form-errors.js b/src/registrar/assets/src/js/getgov/form-errors.js new file mode 100644 index 000000000..ec1faaccf --- /dev/null +++ b/src/registrar/assets/src/js/getgov/form-errors.js @@ -0,0 +1,19 @@ +export function initFormErrorHandling() { + document.addEventListener('DOMContentLoaded', function() { + const errorSummary = document.getElementById('form-errors'); + const firstErrorField = document.querySelector('.usa-input--error'); + if (firstErrorField) { + // Scroll to the first field in error + firstErrorField.scrollIntoView({ behavior: 'smooth', block: 'center' }); + + // Add focus to the first field in error + setTimeout(() => { + firstErrorField.focus(); + }, 50); + } else if (errorSummary) { + // Scroll to the error summary + errorSummary.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + + }); +} \ No newline at end of file diff --git a/src/registrar/assets/src/js/getgov/helpers-uswds.js b/src/registrar/assets/src/js/getgov/helpers-uswds.js index 129d578b6..eec7b0818 100644 --- a/src/registrar/assets/src/js/getgov/helpers-uswds.js +++ b/src/registrar/assets/src/js/getgov/helpers-uswds.js @@ -4,7 +4,7 @@ * accessible directly in getgov.min.js * */ -export function initializeTooltips() { +export function uswdsInitializeTooltips() { function checkTooltip() { // Check that the tooltip library is loaded, and if not, wait and retry if (window.tooltip && typeof window.tooltip.init === 'function') { diff --git a/src/registrar/assets/src/js/getgov/helpers.js b/src/registrar/assets/src/js/getgov/helpers.js index 1afd84520..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.'); + } }; /** @@ -75,3 +83,16 @@ export function debounce(handler, cooldown=600) { export function getCsrfToken() { return document.querySelector('input[name="csrfmiddlewaretoken"]').value; } + +/** + * Helper function to submit a form + * @param {} form_id - the id of the form to be submitted + */ +export function submitForm(form_id) { + let form = document.getElementById(form_id); + if (form) { + form.submit(); + } else { + console.error("Form '" + form_id + "' not found."); + } +} diff --git a/src/registrar/assets/src/js/getgov/main.js b/src/registrar/assets/src/js/getgov/main.js index bd4bed01b..a077da929 100644 --- a/src/registrar/assets/src/js/getgov/main.js +++ b/src/registrar/assets/src/js/getgov/main.js @@ -3,15 +3,18 @@ import { initDomainValidators } from './domain-validators.js'; import { initFormsetsForms, triggerModalOnDsDataForm, nameserversFormListener } from './formset-forms.js'; import { initializeUrbanizationToggle } from './urbanization.js'; import { userProfileListener, finishUserSetupListener } from './user-profile.js'; -import { loadInitialValuesForComboBoxes } from './combobox.js'; import { handleRequestingEntityFieldset } from './requesting-entity.js'; import { initDomainsTable } from './table-domains.js'; import { initDomainRequestsTable } from './table-domain-requests.js'; import { initMembersTable } from './table-members.js'; import { initMemberDomainsTable } from './table-member-domains.js'; import { initEditMemberDomainsTable } from './table-edit-member-domains.js'; -import { initPortfolioMemberPageToggle } from './portfolio-member-page.js'; -import { initAddNewMemberPageListeners } from './portfolio-member-page.js'; +import { initPortfolioNewMemberPageToggle, initAddNewMemberPageListeners, initPortfolioMemberPageRadio } from './portfolio-member-page.js'; +import { initDomainRequestForm } from './domain-request-form.js'; +import { initDomainManagersPage } from './domain-managers.js'; +import { initDomainDSData } from './domain-dsdata.js'; +import { initDomainDNSSEC } from './domain-dnssec.js'; +import { initFormErrorHandling } from './form-errors.js'; initDomainValidators(); @@ -21,21 +24,12 @@ nameserversFormListener(); hookupYesNoListener("other_contacts-has_other_contacts",'other-employees', 'no-other-employees'); hookupYesNoListener("additional_details-has_anything_else_text",'anything-else', null); -hookupRadioTogglerListener( - 'member_access_level', - { - 'admin': 'new-member-admin-permissions', - 'basic': 'new-member-basic-permissions' - } -); hookupYesNoListener("additional_details-has_cisa_representative",'cisa-representative', null); initializeUrbanizationToggle(); userProfileListener(); finishUserSetupListener(); -loadInitialValuesForComboBoxes(); - handleRequestingEntityFieldset(); initDomainsTable(); @@ -44,5 +38,14 @@ initMembersTable(); initMemberDomainsTable(); initEditMemberDomainsTable(); -initPortfolioMemberPageToggle(); +initDomainRequestForm(); +initDomainManagersPage(); +initDomainDSData(); +initDomainDNSSEC(); + +initFormErrorHandling(); + +// Init the portfolio new member page +initPortfolioMemberPageRadio(); +initPortfolioNewMemberPageToggle(); initAddNewMemberPageListeners(); 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 ba874cfb1..c96677ebc 100644 --- a/src/registrar/assets/src/js/getgov/portfolio-member-page.js +++ b/src/registrar/assets/src/js/getgov/portfolio-member-page.js @@ -2,9 +2,10 @@ import { uswdsInitializeModals } from './helpers-uswds.js'; import { getCsrfToken } from './helpers.js'; import { generateKebabHTML } from './table-base.js'; import { MembersTable } from './table-members.js'; +import { hookupRadioTogglerListener } from './radios.js'; // This is specifically for the Member Profile (Manage Member) Page member/invitation removal -export function initPortfolioMemberPageToggle() { +export function initPortfolioNewMemberPageToggle() { document.addEventListener("DOMContentLoaded", () => { const wrapperDeleteAction = document.getElementById("wrapper-delete-action") if (wrapperDeleteAction) { @@ -17,11 +18,11 @@ export function initPortfolioMemberPageToggle() { const unique_id = `${member_type}-${member_id}`; let cancelInvitationButton = member_type === "invitedmember" ? "Cancel invitation" : "Remove member"; - wrapperDeleteAction.innerHTML = generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `for ${member_name}`); + wrapperDeleteAction.innerHTML = generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `More Options for ${member_name}`); // This easter egg is only for fixtures that dont have names as we are displaying their emails // All prod users will have emails linked to their account - MembersTable.addMemberModal(num_domains, member_email || "Samwise Gamgee", member_delete_url, unique_id, wrapperDeleteAction); + MembersTable.addMemberDeleteModal(num_domains, member_email || member_name || "Samwise Gamgee", member_delete_url, unique_id, wrapperDeleteAction); uswdsInitializeModals(); @@ -86,14 +87,6 @@ export function initAddNewMemberPageListeners() { }); }); - /* - Helper function to capitalize the first letter in a string (for display purposes) - */ - function capitalizeFirstLetter(text) { - if (!text) return ''; // Return empty string if input is falsy - return text.charAt(0).toUpperCase() + text.slice(1); - } - /* Populates contents of the "Add Member" confirmation modal */ @@ -101,10 +94,12 @@ export function initAddNewMemberPageListeners() { const permissionDetailsContainer = document.getElementById("permission_details"); permissionDetailsContainer.innerHTML = ""; // Clear previous content - // Get all permission sections (divs with h3 and radio inputs) - const permissionSections = document.querySelectorAll(`#${permission_details_div_id} > h3`); + if (permission_details_div_id == 'member-basic-permissions') { + // for basic users, display values are based on selections in the form + // Get all permission sections (divs with h3 and radio inputs) + const permissionSections = document.querySelectorAll(`#${permission_details_div_id} > h3`); - permissionSections.forEach(section => { + permissionSections.forEach(section => { // Find the

element text const sectionTitle = section.textContent; @@ -112,31 +107,46 @@ export function initAddNewMemberPageListeners() { const fieldset = section.nextElementSibling; if (fieldset && fieldset.tagName.toLowerCase() === 'fieldset') { - // Get the selected radio button within this fieldset - const selectedRadio = fieldset.querySelector('input[type="radio"]:checked'); + // Get the selected radio button within this fieldset + const selectedRadio = fieldset.querySelector('input[type="radio"]:checked'); - // If a radio button is selected, get its label text - let selectedPermission = "No permission selected"; - if (selectedRadio) { - const label = fieldset.querySelector(`label[for="${selectedRadio.id}"]`); - selectedPermission = label ? label.textContent : "No permission selected"; + // If a radio button is selected, get its label text + let selectedPermission = "No permission selected"; + if (selectedRadio) { + const label = fieldset.querySelector(`label[for="${selectedRadio.id}"]`); + if (label) { + // Get only the text node content (excluding subtext in

) + const mainText = Array.from(label.childNodes) + .filter(node => node.nodeType === Node.TEXT_NODE) + .map(node => node.textContent.trim()) + .join(""); // Combine and trim whitespace + selectedPermission = mainText || "No permission selected"; } - - // Create new elements for the modal content - const titleElement = document.createElement("h4"); - titleElement.textContent = sectionTitle; - titleElement.classList.add("text-primary"); - titleElement.classList.add("margin-bottom-0"); - - const permissionElement = document.createElement("p"); - permissionElement.textContent = selectedPermission; - permissionElement.classList.add("margin-top-0"); - - // Append to the modal content container - permissionDetailsContainer.appendChild(titleElement); - permissionDetailsContainer.appendChild(permissionElement); + } + appendPermissionInContainer(sectionTitle, selectedPermission, permissionDetailsContainer); } - }); + }); + } else { + // for admin users, the permissions are always the same + appendPermissionInContainer('Domains', 'Viewer, all', permissionDetailsContainer); + appendPermissionInContainer('Domain requests', 'Creator', permissionDetailsContainer); + appendPermissionInContainer('Members', 'Manager', permissionDetailsContainer); + } + } + + function appendPermissionInContainer(sectionTitle, permissionDisplay, permissionContainer) { + // Create new elements for the content + const titleElement = document.createElement("h4"); + titleElement.textContent = sectionTitle; + titleElement.classList.add("text-primary", "margin-bottom-0"); + + const permissionElement = document.createElement("p"); + permissionElement.textContent = permissionDisplay; + permissionElement.classList.add("margin-top-0"); + + // Append to the content container + permissionContainer.appendChild(titleElement); + permissionContainer.appendChild(permissionElement); } /* @@ -148,18 +158,25 @@ export function initAddNewMemberPageListeners() { let emailValue = document.getElementById('id_email').value; document.getElementById('modalEmail').textContent = emailValue; - // Get selected radio button for access level - let selectedAccess = document.querySelector('input[name="member_access_level"]: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"; + // Get selected radio button for member access level + let selectedAccess = document.querySelector('input[name="role"]:checked'); + // Map the access level values to user-friendly labels + const accessLevelMapping = { + organization_admin: "Admin", + organization_member: "Basic", + }; + // Determine the access text based on the selected value + let accessText = selectedAccess + ? accessLevelMapping[selectedAccess.value] || "Unknown access level" + : "No access level selected"; + // Update the modal with the appropriate member access level text document.getElementById('modalAccessLevel').textContent = accessText; // Populate permission details based on access level - if (selectedAccess && selectedAccess.value === 'admin') { - populatePermissionDetails('new-member-admin-permissions'); + if (selectedAccess && selectedAccess.value === 'organization_admin') { + populatePermissionDetails('admin'); } else { - populatePermissionDetails('new-member-basic-permissions'); + populatePermissionDetails('member-basic-permissions'); } //------- Show the modal @@ -169,4 +186,21 @@ export function initAddNewMemberPageListeners() { } } -} \ No newline at end of file +} + +// Initalize the radio for the member pages +export function initPortfolioMemberPageRadio() { + document.addEventListener("DOMContentLoaded", () => { + let memberForm = document.getElementById("member_form"); + let newMemberForm = document.getElementById("add_member_form") + if (memberForm || newMemberForm) { + hookupRadioTogglerListener( + 'role', + { + 'organization_admin': '', + 'organization_member': 'member-basic-permissions' + } + ); + } + }); +} diff --git a/src/registrar/assets/src/js/getgov/radios.js b/src/registrar/assets/src/js/getgov/radios.js index 248865e8b..055bdf621 100644 --- a/src/registrar/assets/src/js/getgov/radios.js +++ b/src/registrar/assets/src/js/getgov/radios.js @@ -38,21 +38,21 @@ export function hookupYesNoListener(radioButtonName, elementIdToShowIfYes, eleme **/ export function hookupRadioTogglerListener(radioButtonName, valueToElementMap) { // Get the radio buttons - let radioButtons = document.querySelectorAll('input[name="'+radioButtonName+'"]'); + let radioButtons = document.querySelectorAll(`input[name="${radioButtonName}"]`); // Extract the list of all element IDs from the valueToElementMap let allElementIds = Object.values(valueToElementMap); - + function handleRadioButtonChange() { // Find the checked radio button - let radioButtonChecked = document.querySelector('input[name="'+radioButtonName+'"]:checked'); + let radioButtonChecked = document.querySelector(`input[name="${radioButtonName}"]:checked`); let selectedValue = radioButtonChecked ? radioButtonChecked.value : null; // Hide all elements by default allElementIds.forEach(function (elementId) { let element = document.getElementById(elementId); if (element) { - hideElement(element); + hideElement(element); } }); @@ -64,8 +64,8 @@ export function hookupRadioTogglerListener(radioButtonName, valueToElementMap) { } } } - - if (radioButtons.length) { + + if (radioButtons && radioButtons.length) { // Add event listener to each radio button radioButtons.forEach(function (radioButton) { radioButton.addEventListener('change', handleRadioButtonChange); diff --git a/src/registrar/assets/src/js/getgov/requesting-entity.js b/src/registrar/assets/src/js/getgov/requesting-entity.js index 4e7cf8276..833eab2f8 100644 --- a/src/registrar/assets/src/js/getgov/requesting-entity.js +++ b/src/registrar/assets/src/js/getgov/requesting-entity.js @@ -9,14 +9,15 @@ export function handleRequestingEntityFieldset() { const formPrefix = "portfolio_requesting_entity"; const radioFieldset = document.getElementById(`id_${formPrefix}-requesting_entity_is_suborganization__fieldset`); const radios = radioFieldset?.querySelectorAll(`input[name="${formPrefix}-requesting_entity_is_suborganization"]`); - const select = document.getElementById(`id_${formPrefix}-sub_organization`); - const selectParent = select?.parentElement; + const input = document.getElementById(`id_${formPrefix}-sub_organization`); + const inputGrandParent = input?.parentElement?.parentElement; + const select = input?.previousElementSibling; const suborgContainer = document.getElementById("suborganization-container"); const suborgDetailsContainer = document.getElementById("suborganization-container__details"); - const subOrgCreateNewOption = document.getElementById("option-to-add-suborg")?.value; + const suborgAddtlInstruction = document.getElementById("suborganization-addtl-instruction"); // 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. - if (!radios || !select || !selectParent || !suborgContainer || !suborgDetailsContainer) return; + if (!radios || !input || !select || !inputGrandParent || !suborgContainer || !suborgDetailsContainer) return; // requestingSuborganization: This just broadly determines if they're requesting a suborg at all // requestingNewSuborganization: This variable determines if the user is trying to *create* a new suborganization or not. @@ -26,15 +27,16 @@ 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 == 1) { // other is the only option + hideElement(inputGrandParent); // Hide the combo box 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); } - // Add fake "other" option to sub_organization select - if (select && !Array.from(select.options).some(option => option.value === "other")) { - select.add(new Option(subOrgCreateNewOption, "other")); - } - if (requestingNewSuborganization.value === "True") { select.value = "other"; } diff --git a/src/registrar/assets/src/js/getgov/table-base.js b/src/registrar/assets/src/js/getgov/table-base.js index e526c6b5f..ce4397887 100644 --- a/src/registrar/assets/src/js/getgov/table-base.js +++ b/src/registrar/assets/src/js/getgov/table-base.js @@ -93,7 +93,6 @@ export function generateKebabHTML(action, unique_id, modal_button_text, screen_r ` : ''} ${modal_button_text} - ${screen_reader_text} `; @@ -107,6 +106,7 @@ export function generateKebabHTML(action, unique_id, modal_button_text, screen_r class="usa-button usa-button--unstyled usa-button--with-icon usa-accordion__button usa-button--more-actions" aria-expanded="false" aria-controls="more-actions-${unique_id}" + aria-label="${screen_reader_text}" >

Loading table.

'; let url = `${baseUrlValue}?${searchParams.toString()}` fetch(url) .then(response => response.json()) @@ -451,7 +467,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 = ''; @@ -462,7 +478,6 @@ export class BaseTable { let dataObjects = this.getDataObjects(data); let customTableOptions = this.customizeTable(data); - dataObjects.forEach(dataObject => { this.addRow(dataObject, tbody, customTableOptions); }); @@ -471,6 +486,7 @@ export class BaseTable { this.initCheckboxListeners(); this.loadModals(data.page, data.total, data.unfiltered_total); + this.initializeTooltips(); // Do not scroll on first page load if (scroll) @@ -494,17 +510,22 @@ export class BaseTable { // Add event listeners to table headers for sorting initializeTableHeaders() { - this.tableHeaders.forEach(header => { - header.addEventListener('click', () => { - const sortBy = header.getAttribute('data-sortable'); - let order = 'asc'; - // sort order will be ascending, unless the currently sorted column is ascending, and the user - // is selecting the same column to sort in descending order - if (sortBy === this.currentSortBy) { - order = this.currentOrder === 'asc' ? 'desc' : 'asc'; + this.tableHeaderSortButtons.forEach(tableHeader => { + tableHeader.addEventListener('click', event => { + let header = tableHeader.closest('th'); + if (header) { + const sortBy = header.getAttribute('data-sortable'); + let order = 'asc'; + // sort order will be ascending, unless the currently sorted column is ascending, and the user + // is selecting the same column to sort in descending order + if (sortBy === this.currentSortBy) { + order = this.currentOrder === 'asc' ? 'desc' : 'asc'; + } + // load the results with the updated sort + this.loadTable(1, sortBy, order); + } else { + console.warn('Issue with DOM'); } - // load the results with the updated sort - this.loadTable(1, sortBy, order); }); }); } @@ -568,9 +589,9 @@ export class BaseTable { // Reset UI and accessibility resetHeaders() { - this.tableHeaders.forEach(header => { + this.tableHeaderSortButtons.forEach(headerSortButton => { // Unset sort UI in headers - this.unsetHeader(header); + this.unsetHeader(headerSortButton); }); // Reset the announcement region this.tableAnnouncementRegion.innerHTML = ''; diff --git a/src/registrar/assets/src/js/getgov/table-domain-requests.js b/src/registrar/assets/src/js/getgov/table-domain-requests.js index 51e4ea12b..f667a96b5 100644 --- a/src/registrar/assets/src/js/getgov/table-domain-requests.js +++ b/src/registrar/assets/src/js/getgov/table-domain-requests.js @@ -52,26 +52,8 @@ export class DomainRequestsTable extends BaseTable { // Manage "export as CSV" visibility for domain requests this.toggleExportButton(data.domain_requests); - let needsDeleteColumn = data.domain_requests.some(request => request.is_deletable); - - // Remove existing delete th and td if they exist - let existingDeleteTh = document.querySelector('.delete-header'); - if (!needsDeleteColumn) { - if (existingDeleteTh) - existingDeleteTh.remove(); - } else { - if (!existingDeleteTh) { - const delheader = document.createElement('th'); - delheader.setAttribute('scope', 'col'); - delheader.setAttribute('role', 'columnheader'); - delheader.setAttribute('class', 'delete-header width-5'); - delheader.innerHTML = ` - Delete Action`; - let tableHeaderRow = this.tableWrapper.querySelector('thead tr'); - tableHeaderRow.appendChild(delheader); - } - } - return { 'needsAdditionalColumn': needsDeleteColumn }; + let isDeletable = data.domain_requests.some(request => request.is_deletable); + return { 'hasAdditionalActions': isDeletable }; } addRow(dataObject, tbody, customTableOptions) { @@ -88,6 +70,7 @@ export class DomainRequestsTable extends BaseTable { Domain request cannot be deleted now. Edit the request for more information.`; let markupCreatorRow = ''; + if (this.portfolioValue) { markupCreatorRow = ` @@ -98,7 +81,7 @@ export class DomainRequestsTable extends BaseTable { } if (request.is_deletable) { - // 1st path: Just a modal trigger in any screen size for non-org users + // 1st path (non-org): Just a modal trigger in any screen size for non-org users modalTrigger = ` ${request.status} - - - - ${actionLabel} ${request.requested_domain ? request.requested_domain : 'New domain request'} - + + - ${customTableOptions.needsAdditionalColumn ? ''+modalTrigger+'' : ''} `; tbody.appendChild(row); if (request.is_deletable) DomainRequestsTable.addDomainRequestsModal(request.requested_domain, request.id, request.created_at, tbody); diff --git a/src/registrar/assets/src/js/getgov/table-domains.js b/src/registrar/assets/src/js/getgov/table-domains.js index 20d9ef7de..3102484cf 100644 --- a/src/registrar/assets/src/js/getgov/table-domains.js +++ b/src/registrar/assets/src/js/getgov/table-domains.js @@ -1,4 +1,5 @@ import { BaseTable } from './table-base.js'; +import { uswdsInitializeTooltips } from './helpers-uswds.js'; export class DomainsTable extends BaseTable { @@ -31,6 +32,9 @@ export class DomainsTable extends BaseTable { ` } + const isExpiring = domain.state_display === "Expiring soon" + const iconType = isExpiring ? "error_outline" : "info_outline"; + const iconColor = isExpiring ? "text-secondary-vivid" : "text-accent-cool" row.innerHTML = ` ${domain.name} @@ -41,18 +45,18 @@ export class DomainsTable extends BaseTable { ${domain.state_display} - + ${markupForSuborganizationRow} - +
@@ -108,10 +121,10 @@ export class EditMemberDomainsTable extends BaseTable { ${domain.id}
- + ${domain.name} - ${disabled ? 'Domains must have one domain manager. To unassign this member, the domain needs another domain manager.' : ''} + ${disabled ? 'Domains must have one domain manager. To unassign this member, the domain needs another domain manager.' : ''} `; tbody.appendChild(row); @@ -217,7 +230,128 @@ 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'); + let removedDomainsCopy = [...this.removedDomains].sort((a, b) => a.name.localeCompare(b.name)); + removedDomainsCopy.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'); + let addedDomainsCopy = [...this.addedDomains].sort((a, b) => a.name.localeCompare(b.name)); + addedDomainsCopy.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('margin-bottom-05', 'h4'); + unassignedHeader.textContent = 'Unassigned domains'; + domainAssignmentSummary.appendChild(unassignedHeader); + domainAssignmentSummary.appendChild(unassignedDomainsList); + } + + // Append assigned domains section + if (this.addedDomains.length) { + const assignedHeader = document.createElement('h3'); + // Make this h3 look like a h4 + assignedHeader.classList.add('margin-bottom-05', 'h4'); + assignedHeader.textContent = 'Assigned domains'; + domainAssignmentSummary.appendChild(assignedHeader); + domainAssignmentSummary.appendChild(assignedDomainsList); + } + + // Append total assigned domains section + const totalHeader = document.createElement('h3'); + // Make this h3 look like a h4 + totalHeader.classList.add('margin-bottom-05', 'h4'); + 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); + window.scrollTo(0, 0); + } + + 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..d1455c4dc 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 { @@ -18,13 +19,37 @@ export class MemberDomainsTable extends BaseTable { const domain = dataObject; const row = document.createElement('tr'); row.innerHTML = ` - + ${domain.name} - + `; 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); + this.tableAnnouncementRegion.innerHTML = ''; + } else { + hideElement(dataWrapper); + showElement(noSearchResultsWrapper); + hideElement(noDataWrapper); + this.tableAnnouncementRegion.innerHTML = this.noSearchResultsWrapper.innerHTML; + } + } else { + hideElement(searchSection); + hideElement(dataWrapper); + hideElement(noSearchResultsWrapper); + showElement(noDataWrapper); + this.tableAnnouncementRegion.innerHTML = this.noDataWrapper.innerHTML; + } + }; } export function initMemberDomainsTable() { diff --git a/src/registrar/assets/src/js/getgov/table-members.js b/src/registrar/assets/src/js/getgov/table-members.js index 0b311751d..75a7c29ac 100644 --- a/src/registrar/assets/src/js/getgov/table-members.js +++ b/src/registrar/assets/src/js/getgov/table-members.js @@ -61,7 +61,7 @@ export class MembersTable extends BaseTable { tableHeaderRow.appendChild(extraActionsHeader); } return { - 'needsAdditionalColumn': hasEditPermission, + 'hasAdditionalActions': hasEditPermission, 'UserPortfolioPermissionChoices' : data.UserPortfolioPermissionChoices }; } @@ -78,13 +78,12 @@ export class MembersTable extends BaseTable { const num_domains = member.domain_urls.length; const last_active = this.handleLastActive(member.last_active); let cancelInvitationButton = member.type === "invitedmember" ? "Cancel invitation" : "Remove member"; - const kebabHTML = customTableOptions.needsAdditionalColumn ? generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `for ${member.name}`): ''; + const kebabHTML = customTableOptions.hasAdditionalActions ? generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `Expand for more options for ${member.name}`): ''; const row = document.createElement('tr'); - let admin_tagHTML = ``; if (member.is_admin) - admin_tagHTML = `Admin` + admin_tagHTML = `Admin` // generate html blocks for domains and permissions for the member let domainsHTML = this.generateDomainsHTML(num_domains, member.domain_names, member.domain_urls, member.action_url); @@ -99,7 +98,8 @@ export class MembersTable extends BaseTable { type="button" class="usa-button--show-more-button usa-button usa-button--unstyled display-block margin-top-1" data-for=${unique_id} - aria-label="Expand for additional information" + aria-label="Expand for additional information for ${member.member_display}" + aria-label-placeholder="${member.member_display}" > Expand ${member.name}
- ${customTableOptions.needsAdditionalColumn ? ''+kebabHTML+'' : ''} + ${customTableOptions.hasAdditionalActions ? ''+kebabHTML+'' : ''} `; tbody.appendChild(row); if (domainsHTML || permissionsHTML) { @@ -137,7 +137,7 @@ export class MembersTable extends BaseTable { } // This easter egg is only for fixtures that dont have names as we are displaying their emails // All prod users will have emails linked to their account - if (customTableOptions.needsAdditionalColumn) MembersTable.addMemberModal(num_domains, member.email || "Samwise Gamgee", member_delete_url, unique_id, row); + if (customTableOptions.hasAdditionalActions) MembersTable.addMemberDeleteModal(num_domains, member.email || member.name || "Samwise Gamgee", member_delete_url, unique_id, row); } /** @@ -166,13 +166,27 @@ export class MembersTable extends BaseTable { spanElement.textContent = 'Close'; useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_less'); buttonParentRow.classList.add('hide-td-borders'); - toggleButton.setAttribute('aria-label', 'Close additional information'); + + let ariaLabelText = "Close additional information"; + let ariaLabelPlaceholder = toggleButton.getAttribute("aria-label-placeholder"); + if (ariaLabelPlaceholder) { + ariaLabelText = `Close additional information for ${ariaLabelPlaceholder}`; + } + toggleButton.setAttribute('aria-label', ariaLabelText); + + // Set tabindex for focusable elements in expanded content } else { hideElement(contentDiv); spanElement.textContent = 'Expand'; useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_more'); buttonParentRow.classList.remove('hide-td-borders'); - toggleButton.setAttribute('aria-label', 'Expand for additional information'); + + let ariaLabelText = "Expand for additional information"; + let ariaLabelPlaceholder = toggleButton.getAttribute("aria-label-placeholder"); + if (ariaLabelPlaceholder) { + ariaLabelText = `Expand for additional information for ${ariaLabelPlaceholder}`; + } + toggleButton.setAttribute('aria-label', ariaLabelText); } } @@ -245,21 +259,19 @@ export class MembersTable extends BaseTable { // Only generate HTML if the member has one or more assigned domains if (num_domains > 0) { domainsHTML += "
"; - domainsHTML += "

Domains assigned

"; - domainsHTML += `

This member is assigned to ${num_domains} domains:

`; + domainsHTML += "

Domains assigned

"; + domainsHTML += `

This member is assigned to ${num_domains} domain${num_domains > 1 ? 's' : ''}:

`; domainsHTML += "
    "; // Display up to 6 domains with their URLs for (let i = 0; i < num_domains && i < 6; i++) { - domainsHTML += `
  • ${domain_names[i]}
  • `; + domainsHTML += `
  • ${domain_names[i]}
  • `; } domainsHTML += "
"; // If there are more than 6 domains, display a "View assigned domains" link - if (num_domains >= 6) { - domainsHTML += `

View assigned domains

`; - } + domainsHTML += `

View assigned domains

`; domainsHTML += "
"; } @@ -378,34 +390,37 @@ export class MembersTable extends BaseTable { generatePermissionsHTML(member_permissions, UserPortfolioPermissionChoices) { let permissionsHTML = ''; + // Define shared classes across elements for easier refactoring + let sharedParagraphClasses = "font-body-xs text-base-dark margin-top-1 p--blockquote"; + // Check domain-related permissions if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)) { - permissionsHTML += "

Domains: Can view all organization domains. Can manage domains they are assigned to and edit information about the domain (including DNS settings).

"; + permissionsHTML += `

Domains: Can view all organization domains. Can manage domains they are assigned to and edit information about the domain (including DNS settings).

`; } else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS)) { - permissionsHTML += "

Domains: Can manage domains they are assigned to and edit information about the domain (including DNS settings).

"; + permissionsHTML += `

Domains: Can manage domains they are assigned to and edit information about the domain (including DNS settings).

`; } // Check request-related permissions if (member_permissions.includes(UserPortfolioPermissionChoices.EDIT_REQUESTS)) { - permissionsHTML += "

Domain requests: Can view all organization domain requests. Can create domain requests and modify their own requests.

"; + permissionsHTML += `

Domain requests: Can view all organization domain requests. Can create domain requests and modify their own requests.

`; } else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)) { - permissionsHTML += "

Domain requests (view-only): Can view all organization domain requests. Can't create or modify any domain requests.

"; + permissionsHTML += `

Domain requests (view-only): Can view all organization domain requests. Can't create or modify any domain requests.

`; } // Check member-related permissions if (member_permissions.includes(UserPortfolioPermissionChoices.EDIT_MEMBERS)) { - permissionsHTML += "

Members: Can manage members including inviting new members, removing current members, and assigning domains to members.

"; + permissionsHTML += `

Members: Can manage members including inviting new members, removing current members, and assigning domains to members.

`; } else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_MEMBERS)) { - permissionsHTML += "

Members (view-only): Can view all organizational members. Can't manage any members.

"; + permissionsHTML += `

Members (view-only): Can view all organizational members. Can't manage any members.

`; } // If no specific permissions are assigned, display a message indicating no additional permissions if (!permissionsHTML) { - permissionsHTML += "

No additional permissions: There are no additional permissions for this member.

"; + permissionsHTML += `

No additional permissions: There are no additional permissions for this member.

`; } // Add a permissions header and wrap the entire output in a container - permissionsHTML = "

Additional permissions for this member

" + permissionsHTML + "
"; + permissionsHTML = `

Additional permissions for this member

${permissionsHTML}
`; return permissionsHTML; } @@ -417,24 +432,21 @@ export class MembersTable extends BaseTable { * @param {string} submit_delete_url - `${member_type}-${member_id}/delete` * @param {HTMLElement} wrapper_element - The element to which the modal is appended */ - static addMemberModal(num_domains, member_email, submit_delete_url, id, wrapper_element) { - let modalHeading = ''; - let modalDescription = ''; + static addMemberDeleteModal(num_domains, member_email, submit_delete_url, id, wrapper_element) { - if (num_domains == 0){ - modalHeading = `Are you sure you want to delete ${member_email}?`; + let modalHeading = ``; + let modalDescription = ``; + + if (num_domains >= 0){ + modalHeading = `Are you sure you want to remove ${member_email} from the organization?`; modalDescription = `They will no longer be able to access this organization. This action cannot be undone.`; - } else if (num_domains == 1) { - modalHeading = `Are you sure you want to delete ${member_email}?`; - modalDescription = `${member_email} currently manages ${num_domains} domain in the organization. - Removing them from the organization will remove all of their domains. They will no longer be able to - access this organization. This action cannot be undone.`; - } else if (num_domains > 1) { - modalHeading = `Are you sure you want to delete ${member_email}?`; - modalDescription = `${member_email} currently manages ${num_domains} domains in the organization. - Removing them from the organization will remove all of their domains. They will no longer be able to - access this organization. This action cannot be undone.`; + if (num_domains >= 1) + { + modalDescription = `${member_email} currently manages ${num_domains} domain${num_domains > 1 ? "s": ""} in the organization. + Removing them from the organization will remove them from all of their domains. They will no longer be able to + access this organization. This action cannot be undone.`; + } } const modalSubmit = ` diff --git a/src/registrar/assets/src/sass/_theme/_accordions.scss b/src/registrar/assets/src/sass/_theme/_accordions.scss index df4f686d8..ca9990ca9 100644 --- a/src/registrar/assets/src/sass/_theme/_accordions.scss +++ b/src/registrar/assets/src/sass/_theme/_accordions.scss @@ -40,8 +40,39 @@ 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; } + +// A CSS only show-more/show-less based on usa-accordion +.usa-accordion--show-more { + width: auto; + .usa-accordion__button[aria-expanded=false], + .usa-accordion__button[aria-expanded=false]:hover, + .usa-accordion__button[aria-expanded=true], + .usa-accordion__button[aria-expanded=true]:hover { + background-image: none; + background-color: transparent; + padding-right: 0; + padding-left: 0; + font-weight: normal; + } + .usa-accordion__button[aria-expanded=true] .expand-more { + display: inline-block; + } + .usa-accordion__button[aria-expanded=true] .expand-less { + display: none; + } + .usa-accordion__button[aria-expanded=false] .expand-more { + display: none; + } + .usa-accordion__button[aria-expanded=false] .expand-less { + display: inline-block; + } +} diff --git a/src/registrar/assets/src/sass/_theme/_admin.scss b/src/registrar/assets/src/sass/_theme/_admin.scss index 58ce1e4df..4f75fd2fb 100644 --- a/src/registrar/assets/src/sass/_theme/_admin.scss +++ b/src/registrar/assets/src/sass/_theme/_admin.scss @@ -176,10 +176,19 @@ 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, +.dashboard h1, .dashboard h2, .dashboard h3, .module h2 { font-weight: font-weight('bold'); } @@ -342,6 +351,40 @@ div#content > h2 { } } +.module { + .margin-left-0 { + margin-left: 0; + } + .margin-top-0 { + margin-top: 0; + } + .padding-left-0 { + padding-left: 0; + } +} + +.admin-list-inline { + li { + float: left; + padding-top: 0; + margin-right: 4px; + } + li:not(:last-child)::after { + content: ","; + } +} + +.form-row { + .margin-y-0 { + margin-top: 0; + margin-bottom: 0; + } + .padding-y-0 { + padding-top: 0; + padding-bottom: 0; + } +} + // Fixes a display issue where the list was entirely white, or had too much whitespace .select2-dropdown { display: inline-grid !important; @@ -473,10 +516,6 @@ input[type=submit].button--dja-toolbar:focus, input[type=submit].button--dja-too max-width: 68ex; } -.usa-summary-box__dhs-color { - color: $dhs-blue-70; -} - details.dja-detail-table { display: inline-table; background-color: var(--body-bg); @@ -769,18 +808,6 @@ div.dja__model-description{ text-decoration: underline !important; } -//-- Override some styling for the USWDS summary box (per design quidance for ticket #2055 -.usa-summary-box { - background: #{$dhs-blue-10}; - border-color: #{$dhs-blue-30}; - max-width: 72ex; - word-wrap: break-word; -} - -.usa-summary-box h3 { - color: #{$dhs-blue-60}; -} - .module caption, .inline-group h2 { text-transform: capitalize; } @@ -886,14 +913,6 @@ ul.add-list-reset { font-size: 14px; } -.domain-name-wrap { - white-space: normal; - word-wrap: break-word; - overflow: visible; - word-break: break-all; - max-width: 100%; -} - .organization-admin-label { font-weight: 600; font-size: .8125rem; 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 8d475270b..be3b89baf 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'); } @@ -56,7 +59,6 @@ body { } h2 { - color: color('primary-dark'); margin-top: units(2); margin-bottom: units(2); } @@ -127,16 +129,6 @@ grid column to the max-width of the searchbar, which was calculated to be 33rem. word-break: break-word; } -.dotgov-status-box { - background-color: color('primary-lightest'); - border-color: color('accent-cool-lighter'); -} - -.dotgov-status-box--action-need { - background-color: color('warning-lighter'); - border-color: color('warning'); -} - footer { border-top: 1px solid color('primary-darker'); } @@ -149,6 +141,11 @@ footer { color: color('primary'); } +.usa-radio { + margin-top: 1rem; + font-size: 1.06rem; +} + abbr[title] { // workaround for underlining abbr element border-bottom: none; @@ -220,14 +217,6 @@ abbr[title] { max-width: 23ch; } -.ellipsis--30 { - max-width: 30ch; -} - -.ellipsis--50 { - max-width: 50ch; -} - .vertical-align-middle { vertical-align: middle; } @@ -247,6 +236,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; } @@ -255,9 +253,25 @@ abbr[title] { word-break: break-word; } +.string-wrap { + white-space: normal; + word-wrap: break-word; + overflow: visible; + word-break: break-all; + max-width: 100%; +} + //Icon size adjustment used by buttons and form errors .usa-icon.usa-icon--large { margin: 0; height: 1.5em; width: 1.5em; -} \ No newline at end of file +} + +.maxw-fit-content { + max-width: fit-content; +} + +.width-quarter { + width: 25%; +} diff --git a/src/registrar/assets/src/sass/_theme/_buttons.scss b/src/registrar/assets/src/sass/_theme/_buttons.scss index 3342f5f7d..13bc163a8 100644 --- a/src/registrar/assets/src/sass/_theme/_buttons.scss +++ b/src/registrar/assets/src/sass/_theme/_buttons.scss @@ -236,13 +236,6 @@ a.withdraw_outline:active { align-items: center; } -.dotgov-table a -a .usa-icon, -.usa-button--with-icon .usa-icon { - height: 1.3em; - width: 1.3em; -} - // Red, for delete buttons // Used on: All delete buttons // Note: Can be simplified by adding text-secondary to delete anchors in tables @@ -253,6 +246,10 @@ a.text-secondary:hover { color: $theme-color-error; } +.usa-button.usa-button--secondary { + background-color: $theme-color-error; +} + .usa-button--show-more-button { font-size: size('ui', 'xs'); text-decoration: none; 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..670a69136 100644 --- a/src/registrar/assets/src/sass/_theme/_forms.scss +++ b/src/registrar/assets/src/sass/_theme/_forms.scss @@ -1,7 +1,14 @@ @use "uswds-core" as *; @use "cisa_colors" as *; -@use "typography" as *; +// Normalize typography in forms +.usa-form, +.usa-form fieldset { + font-size: 1rem; + .usa-legend { + font-size: 1rem; + } +} .usa-form .usa-button { margin-top: units(3); } @@ -69,12 +76,6 @@ legend.float-left-tablet + button.float-right-tablet { } } -.read-only-label { - @extend .h4--sm-05; - font-weight: bold; - color: color('primary-dark'); -} - -.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/_modals.scss b/src/registrar/assets/src/sass/_theme/_modals.scss new file mode 100644 index 000000000..44107790d --- /dev/null +++ b/src/registrar/assets/src/sass/_theme/_modals.scss @@ -0,0 +1,5 @@ +@use "uswds-core" as *; + +.usa-modal__main { + padding: 0 2rem 2rem; +} diff --git a/src/registrar/assets/src/sass/_theme/_register-form.scss b/src/registrar/assets/src/sass/_theme/_register-form.scss index 41d2980e3..19de29a48 100644 --- a/src/registrar/assets/src/sass/_theme/_register-form.scss +++ b/src/registrar/assets/src/sass/_theme/_register-form.scss @@ -1,5 +1,4 @@ @use "uswds-core" as *; -@use "typography" as *; .register-form-step > h1 { //align to top of sidebar on first page of the form @@ -12,11 +11,7 @@ margin-top: units(1); } -// register-form-review-header is used on the summary page and -// should not be styled like the register form headers -.register-form-step h3 { - color: color('primary-dark'); - letter-spacing: $letter-space--xs; +.register-form-step h3:not(.margin-top-05) { margin-top: units(3); margin-bottom: 0; @@ -25,15 +20,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; @@ -73,26 +59,10 @@ margin-top: units(3); } - .summary-item hr, +.summary-item hr, .review__step hr { border: none; //reset border-top: 1px solid color('primary-dark'); margin-top: 0; margin-bottom: units(0.5); } - -.review__step__title a:visited { - color: color('primary'); -} - -.review__step__name { - color: color('primary-dark'); - font-weight: font-weight('semibold'); - margin-bottom: units(0.5); -} - -.review__step__subheading { - color: color('primary-dark'); - font-weight: font-weight('semibold'); - margin-bottom: units(0.5); -} diff --git a/src/registrar/assets/src/sass/_theme/_summary-box.scss b/src/registrar/assets/src/sass/_theme/_summary-box.scss new file mode 100644 index 000000000..45f41d5d3 --- /dev/null +++ b/src/registrar/assets/src/sass/_theme/_summary-box.scss @@ -0,0 +1,15 @@ +@use "uswds-core" as *; + +.usa-summary-box { + background-color: color('primary-lightest'); + border-color: color('accent-cool-lighter'); +} + +.usa-summary-box--action-needed { + background-color: color('warning-lighter'); + border-color: color('warning'); +} + +.usa-summary-box__heading { + font-weight: bold; +} diff --git a/src/registrar/assets/src/sass/_theme/_tables.scss b/src/registrar/assets/src/sass/_theme/_tables.scss index 45f0b5245..37ae22b1b 100644 --- a/src/registrar/assets/src/sass/_theme/_tables.scss +++ b/src/registrar/assets/src/sass/_theme/_tables.scss @@ -41,6 +41,13 @@ th { } } +// The member table has an extra "expand" row, which looks like a single row. +// But the DOM disagrees - so we basically need to hide the border on both rows. +#members__table-wrapper .dotgov-table tr:nth-last-child(2) td, +#members__table-wrapper .dotgov-table tr:nth-last-child(2) th { + border-bottom: none; +} + .dotgov-table { width: 100%; @@ -56,10 +63,9 @@ th { border: none; } - tr:not(.hide-td-borders) { - td, th { - border-bottom: 1px solid color('base-lighter'); - } + tr:not(.hide-td-borders):not(:last-child) td, + tr:not(.hide-td-borders):not(:last-child) th { + border-bottom: 1px solid color('base-lighter'); } thead th { @@ -88,8 +94,36 @@ th { } @include at-media(tablet-lg) { - th[data-sortable]:not([aria-sort]) .usa-table__header__button { + th[data-sortable] .usa-table__header__button { right: auto; + + &[aria-sort=ascending], + &[aria-sort=descending], + &:not([aria-sort]) { + right: auto; + } } } } + +.dotgov-table--cell-padding-2 { + td, th { + padding: units(2); + } +} + +.usa-table--striped tbody tr:nth-child(odd) th, +.usa-table--striped tbody tr:nth-child(odd) td { + background-color: color('primary-lightest'); +} + +.usa-table--bg-transparent { + td, thead th { + background-color: transparent; + } +} + +.usa-table--full-borderless td, +.usa-table--full-borderless th { + border: none !important; +} diff --git a/src/registrar/assets/src/sass/_theme/_tags.scss b/src/registrar/assets/src/sass/_theme/_tags.scss new file mode 100644 index 000000000..495bb93a8 --- /dev/null +++ b/src/registrar/assets/src/sass/_theme/_tags.scss @@ -0,0 +1,3 @@ +.usa-tag { + text-transform: none; +} diff --git a/src/registrar/assets/src/sass/_theme/_tooltips.scss b/src/registrar/assets/src/sass/_theme/_tooltips.scss index 58beb8ae6..22b5cf534 100644 --- a/src/registrar/assets/src/sass/_theme/_tooltips.scss +++ b/src/registrar/assets/src/sass/_theme/_tooltips.scss @@ -66,9 +66,9 @@ text-align: center; font-size: inherit; //inherit tooltip fontsize of .93rem max-width: fit-content; + display: block; @include at-media('desktop') { width: 70vw; } - display: block; } -} \ No newline at end of file +} diff --git a/src/registrar/assets/src/sass/_theme/_typography.scss b/src/registrar/assets/src/sass/_theme/_typography.scss index 466b6f975..22069f726 100644 --- a/src/registrar/assets/src/sass/_theme/_typography.scss +++ b/src/registrar/assets/src/sass/_theme/_typography.scss @@ -10,33 +10,43 @@ address, max-width: measure(5); } -h1 { +h1:not(.usa-alert__heading), +// .module h2 excludes headers in DJA +h2:not(.usa-alert__heading, .module h2), +h3:not(.usa-alert__heading), +h4:not(.usa-alert__heading), +h5:not(.usa-alert__heading), +h6:not(.usa-alert__heading) { + color: color('primary-darker'); +} + +h1, .h1 { + font-size: 2.125rem; @include typeset('sans', '2xl', 2); margin: 0 0 units(2); - color: color('primary-darker'); } -h2 { - font-weight: font-weight('semibold'); - line-height: line-height('heading', 3); +h2, .h2 { + line-height: 1.3; margin: units(4) 0 units(1); - color: color('primary-darker'); } -.h4--sm-05 { - font-size: size('body', 'sm'); - font-weight: normal; - color: color('primary'); - margin-bottom: units(0.5); +h3, .h3 { + font-size: 1.25rem; + font-weight: font-weight('semibold'); } -// Normalize typography in forms -.usa-form, -.usa-form fieldset { - font-size: 1rem; +h4, .h4 { + font-size: 1.125rem; + line-height: 1.25; + font-weight: font-weight('semibold'); } .p--blockquote { padding-left: units(1); border-left: 2px solid color('base-lighter'); } + +.font-body-1 { + font-size: size('body', 1); +} diff --git a/src/registrar/assets/src/sass/_theme/_uswds-theme.scss b/src/registrar/assets/src/sass/_theme/_uswds-theme.scss index 1661a6388..21bb48e96 100644 --- a/src/registrar/assets/src/sass/_theme/_uswds-theme.scss +++ b/src/registrar/assets/src/sass/_theme/_uswds-theme.scss @@ -68,6 +68,7 @@ in the form $setting: value, /*--------------------------- ## Font weights ----------------------------*/ + $theme-font-weight-medium: 400, $theme-font-weight-semibold: 600, /*--------------------------- diff --git a/src/registrar/assets/src/sass/_theme/styles.scss b/src/registrar/assets/src/sass/_theme/styles.scss index 78d27b2e0..4962bf184 100644 --- a/src/registrar/assets/src/sass/_theme/styles.scss +++ b/src/registrar/assets/src/sass/_theme/styles.scss @@ -17,6 +17,7 @@ @forward "forms"; @forward "search"; @forward "tooltips"; +@forward "summary-box"; @forward "fieldsets"; @forward "alerts"; @forward "tables"; @@ -25,6 +26,8 @@ @forward "header"; @forward "register-form"; @forward "containers"; +@forward "modals"; +@forward "tags"; /*-------------------------------------------------- --- Admin ---------------------------------*/ diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index 2bf9ac70a..4a80c528e 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -25,6 +25,7 @@ from typing import Final from botocore.config import Config import json import logging +import traceback from django.utils.log import ServerFormatter # # # ### @@ -252,7 +253,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", ], }, }, @@ -472,7 +473,11 @@ class JsonFormatter(logging.Formatter): "lineno": record.lineno, "message": record.getMessage(), } - return json.dumps(log_record) + # Capture exception info if it exists + if record.exc_info: + log_record["exception"] = "".join(traceback.format_exception(*record.exc_info)) + + return json.dumps(log_record, ensure_ascii=False) class JsonServerFormatter(ServerFormatter): @@ -526,7 +531,7 @@ LOGGING = { "()": JsonFormatter, }, }, - # define where log messages will be sent; + # define where log messages will be sent # each logger can have one or more handlers "handlers": { "console": { diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 66708c571..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( @@ -345,6 +345,11 @@ urlpatterns = [ views.DomainSecurityEmailView.as_view(), name="domain-security-email", ), + path( + "domain//renewal", + views.DomainRenewalView.as_view(), + name="domain-renewal", + ), path( "domain//users/add", views.DomainAddUserView.as_view(), diff --git a/src/registrar/context_processors.py b/src/registrar/context_processors.py index 9f5d0162f..b3d9c3727 100644 --- a/src/registrar/context_processors.py +++ b/src/registrar/context_processors.py @@ -69,9 +69,19 @@ def portfolio_permissions(request): "has_organization_requests_flag": False, "has_organization_members_flag": False, "is_portfolio_admin": False, + "has_domain_renewal_flag": False, } try: portfolio = request.session.get("portfolio") + + # These feature flags will display and doesn't depend on portfolio + portfolio_context.update( + { + "has_organization_feature_flag": True, + "has_domain_renewal_flag": request.user.has_domain_renewal_flag(), + } + ) + # Linting: line too long view_suborg = request.user.has_view_suborganization_portfolio_permission(portfolio) edit_suborg = request.user.has_edit_suborganization_portfolio_permission(portfolio) @@ -90,6 +100,7 @@ def portfolio_permissions(request): "has_organization_requests_flag": request.user.has_organization_requests_flag(), "has_organization_members_flag": request.user.has_organization_members_flag(), "is_portfolio_admin": request.user.is_portfolio_admin(portfolio), + "has_domain_renewal_flag": request.user.has_domain_renewal_flag(), } return portfolio_context @@ -98,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_requests.py b/src/registrar/fixtures/fixtures_requests.py index 93167ec61..c4d824b37 100644 --- a/src/registrar/fixtures/fixtures_requests.py +++ b/src/registrar/fixtures/fixtures_requests.py @@ -1,4 +1,4 @@ -from datetime import timedelta +from datetime import datetime, timedelta from django.utils import timezone import logging import random @@ -126,7 +126,22 @@ class DomainRequestFixture: # TODO for a future ticket: Allow for more than just "federal" here request.generic_org_type = request_dict["generic_org_type"] if "generic_org_type" in request_dict else "federal" if request.status != "started": - request.last_submitted_date = fake.date() + # Generate fake data for first_submitted_date and last_submitted_date + # First generate a random date set to be later than 2020 (or something) + # (if we just use fake.date() we might get years like 1970 or earlier) + earliest_date_allowed = datetime(2020, 1, 1).date() + end_date = datetime.today().date() # Today's date (latest allowed date) + days_range = (end_date - earliest_date_allowed).days + first_submitted_date = earliest_date_allowed + timedelta(days=random.randint(0, days_range)) # nosec + + # Generate a random positive offset to ensure last_submitted_date is later + # (Start with 1 to ensure at least 1 day difference) + offset_days = random.randint(1, 30) # nosec + last_submitted_date = first_submitted_date + timedelta(days=offset_days) + + # Convert back to strings before assigning + request.first_submitted_date = first_submitted_date.strftime("%Y-%m-%d") + request.last_submitted_date = last_submitted_date.strftime("%Y-%m-%d") request.federal_type = ( request_dict["federal_type"] if "federal_type" in request_dict @@ -308,22 +323,50 @@ class DomainRequestFixture: cls._create_domain_requests(users) @classmethod - def _create_domain_requests(cls, users): + def _create_domain_requests(cls, users): # noqa: C901 """Creates DomainRequests given a list of users.""" + total_domain_requests_to_make = len(users) # 100000 + + # Check if the database is already populated with the desired + # number of entries. + # (Prevents re-adding more entries to an already populated database, + # which happens when restarting Docker src) + domain_requests_already_made = DomainRequest.objects.count() + domain_requests_to_create = [] - for user in users: - for request_data in cls.DOMAINREQUESTS: - # Prepare DomainRequest objects - try: - domain_request = DomainRequest( - creator=user, - organization_name=request_data["organization_name"], - ) - cls._set_non_foreign_key_fields(domain_request, request_data) - cls._set_foreign_key_fields(domain_request, request_data, user) - domain_requests_to_create.append(domain_request) - except Exception as e: - logger.warning(e) + if domain_requests_already_made < total_domain_requests_to_make: + for user in users: + for request_data in cls.DOMAINREQUESTS: + # Prepare DomainRequest objects + try: + domain_request = DomainRequest( + creator=user, + organization_name=request_data["organization_name"], + ) + cls._set_non_foreign_key_fields(domain_request, request_data) + cls._set_foreign_key_fields(domain_request, request_data, user) + domain_requests_to_create.append(domain_request) + except Exception as e: + logger.warning(e) + + num_additional_requests_to_make = ( + total_domain_requests_to_make - domain_requests_already_made - len(domain_requests_to_create) + ) + if num_additional_requests_to_make > 0: + for _ in range(num_additional_requests_to_make): + random_user = random.choice(users) # nosec + try: + random_request_type = random.choice(cls.DOMAINREQUESTS) # nosec + # Prepare DomainRequest objects + domain_request = DomainRequest( + creator=random_user, + organization_name=random_request_type["organization_name"], + ) + cls._set_non_foreign_key_fields(domain_request, random_request_type) + cls._set_foreign_key_fields(domain_request, random_request_type, random_user) + domain_requests_to_create.append(domain_request) + except Exception as e: + logger.warning(f"Error creating random domain request: {e}") # Bulk create domain requests cls._bulk_create_requests(domain_requests_to_create) 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/fixtures/fixtures_users.py b/src/registrar/fixtures/fixtures_users.py index a8cdb5b9a..977bf0858 100644 --- a/src/registrar/fixtures/fixtures_users.py +++ b/src/registrar/fixtures/fixtures_users.py @@ -151,6 +151,27 @@ class UserFixture: "email": "skey@truss.works", "title": "Designer", }, + { + "username": "f20b7a53-f40d-48f8-8c12-f42f35eede92", + "first_name": "Kimberly", + "last_name": "Aralar", + "email": "kimberly.aralar@gsa.gov", + "title": "Designer", + }, + { + "username": "4aa78480-6272-42f9-ac29-a034ebdd9231", + "first_name": "Kaitlin", + "last_name": "Abbitt", + "email": "kaitlin.abbitt@cisa.dhs.gov", + "title": "Product Manager", + }, + { + "username": "5e54fd98-6c11-4cb3-82b6-93ed8be50a61", + "first_name": "Gina", + "last_name": "Summers", + "email": "gina.summers@ecstech.com", + "title": "Scrum Master", + }, ] STAFF = [ @@ -175,6 +196,7 @@ class UserFixture: "username": "b6a15987-5c88-4e26-8de2-ca71a0bdb2cd", "first_name": "Alysia-Analyst", "last_name": "Alysia-Analyst", + "email": "abroddrick+1@truss.works", }, { "username": "91a9b97c-bd0a-458d-9823-babfde7ebf44", @@ -257,6 +279,18 @@ class UserFixture: "last_name": "Key-Analyst", "email": "skey+1@truss.works", }, + { + "username": "cf2b32fe-280d-4bc0-96c2-99eec09ba4da", + "first_name": "Kimberly-Analyst", + "last_name": "Aralar-Analyst", + "email": "kimberly.aralar+1@gsa.gov", + }, + { + "username": "80db923e-ac64-4128-9b6f-e54b2174a09b", + "first_name": "Kaitlin-Analyst", + "last_name": "Abbitt-Analyst", + "email": "kaitlin.abbitt@gwe.cisa.dhs.gov", + }, ] # Additional emails to add to the AllowedEmail whitelist. @@ -318,32 +352,65 @@ class UserFixture: @staticmethod def _get_existing_users(users): + # if users match existing users in db by email address, update the users with the username + # from the db. this will prevent duplicate users (with same email) from being added to db. + # it is ok to keep the old username in the db because the username will be updated by oidc process during login + + # Extract email addresses from users + emails = [user.get("email") for user in users] + + # Fetch existing users by email + existing_users_by_email = User.objects.filter(email__in=emails).values_list("email", "username", "id") + + # Create a dictionary to map emails to existing usernames + email_to_existing_user = {user[0]: user[1] for user in existing_users_by_email} + + # Update the users list with the usernames from existing users by email + for user in users: + email = user.get("email") + if email and email in email_to_existing_user: + user["username"] = email_to_existing_user[email] # Update username with the existing one + + # Get the user identifiers (username, id) for the existing users to query the database user_identifiers = [(user.get("username"), user.get("id")) for user in users] + + # Fetch existing users by username or id existing_users = User.objects.filter( username__in=[user[0] for user in user_identifiers] + [user[1] for user in user_identifiers] ).values_list("username", "id") + + # Create sets for usernames and ids that exist existing_usernames = set(user[0] for user in existing_users) existing_user_ids = set(user[1] for user in existing_users) + return existing_usernames, existing_user_ids @staticmethod def _prepare_new_users(users, existing_usernames, existing_user_ids, are_superusers): - return [ - User( - id=user_data.get("id"), - first_name=user_data.get("first_name"), - last_name=user_data.get("last_name"), - username=user_data.get("username"), - email=user_data.get("email", ""), - title=user_data.get("title", "Peon"), - phone=user_data.get("phone", "2022222222"), - is_active=user_data.get("is_active", True), - is_staff=True, - is_superuser=are_superusers, - ) - for user_data in users - if user_data.get("username") not in existing_usernames and user_data.get("id") not in existing_user_ids - ] + new_users = [] + for i, user_data in enumerate(users): + username = user_data.get("username") + id = user_data.get("id") + first_name = user_data.get("first_name", "Bob") + last_name = user_data.get("last_name", "Builder") + + default_email = f"placeholder.{first_name.lower()}.{last_name.lower()}+{i}@igorville.gov" + email = user_data.get("email", default_email) + if username not in existing_usernames and id not in existing_user_ids: + user = User( + id=id, + first_name=first_name, + last_name=last_name, + username=username, + email=email, + title=user_data.get("title", "Peon"), + phone=user_data.get("phone", "2022222222"), + is_active=user_data.get("is_active", True), + is_staff=True, + is_superuser=are_superusers, + ) + new_users.append(user) + return new_users @staticmethod def _create_new_users(new_users): diff --git a/src/registrar/forms/__init__.py b/src/registrar/forms/__init__.py index 121e2b3f7..13725f109 100644 --- a/src/registrar/forms/__init__.py +++ b/src/registrar/forms/__init__.py @@ -10,6 +10,7 @@ from .domain import ( DomainDsdataFormset, DomainDsdataForm, DomainSuborganizationForm, + DomainRenewalForm, ) from .portfolio import ( PortfolioOrgAddressForm, diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py index b43d91a58..05eb90db3 100644 --- a/src/registrar/forms/domain.py +++ b/src/registrar/forms/domain.py @@ -4,6 +4,7 @@ import logging from django import forms from django.core.validators import MinValueValidator, MaxValueValidator, RegexValidator, MaxLengthValidator from django.forms import formset_factory +from registrar.forms.utility.combobox import ComboboxWidget from registrar.models import DomainRequest, FederalAgency from phonenumber_field.widgets import RegionalPhoneNumberWidget from registrar.models.suborganization import Suborganization @@ -161,9 +162,10 @@ class DomainSuborganizationForm(forms.ModelForm): """Form for updating the suborganization""" sub_organization = forms.ModelChoiceField( + label="Suborganization name", queryset=Suborganization.objects.none(), required=False, - widget=forms.Select(), + widget=ComboboxWidget, ) class Meta: @@ -178,20 +180,6 @@ class DomainSuborganizationForm(forms.ModelForm): portfolio = self.instance.portfolio if self.instance else None self.fields["sub_organization"].queryset = Suborganization.objects.filter(portfolio=portfolio) - # Set initial value - if self.instance and self.instance.sub_organization: - self.fields["sub_organization"].initial = self.instance.sub_organization - - # Set custom form label - self.fields["sub_organization"].label = "Suborganization name" - - # Use the combobox rather than the regular select widget - self.fields["sub_organization"].widget.template_name = "django/forms/widgets/combobox.html" - - # Set data-default-value attribute - if self.instance and self.instance.sub_organization: - self.fields["sub_organization"].widget.attrs["data-default-value"] = self.instance.sub_organization.pk - class BaseNameserverFormset(forms.BaseFormSet): def clean(self): @@ -456,6 +444,13 @@ class DomainSecurityEmailForm(forms.Form): class DomainOrgNameAddressForm(forms.ModelForm): """Form for updating the organization name and mailing address.""" + # for federal agencies we also want to know the top-level agency. + federal_agency = forms.ModelChoiceField( + label="Federal agency", + required=False, + queryset=FederalAgency.objects.all(), + widget=ComboboxWidget, + ) zipcode = forms.CharField( label="Zip code", validators=[ @@ -469,6 +464,16 @@ class DomainOrgNameAddressForm(forms.ModelForm): }, ) + state_territory = forms.ChoiceField( + label="State, territory, or military post", + required=True, + choices=DomainInformation.StateTerritoryChoices.choices, + error_messages={ + "required": ("Select the state, territory, or military post where your organization is located.") + }, + widget=ComboboxWidget(attrs={"required": True}), + ) + class Meta: model = DomainInformation fields = [ @@ -486,25 +491,12 @@ class DomainOrgNameAddressForm(forms.ModelForm): "organization_name": {"required": "Enter the name of your organization."}, "address_line1": {"required": "Enter the street address of your organization."}, "city": {"required": "Enter the city where your organization is located."}, - "state_territory": { - "required": "Select the state, territory, or military post where your organization is located." - }, } widgets = { - # We need to set the required attributed for State/territory - # because for this fields we are creating an individual - # instance of the Select. For the other fields we use the for loop to set - # the class's required attribute to true. "organization_name": forms.TextInput, "address_line1": forms.TextInput, "address_line2": forms.TextInput, "city": forms.TextInput, - "state_territory": forms.Select( - attrs={ - "required": True, - }, - choices=DomainInformation.StateTerritoryChoices.choices, - ), "urbanization": forms.TextInput, } @@ -661,3 +653,15 @@ DomainDsdataFormset = formset_factory( extra=0, can_delete=True, ) + + +class DomainRenewalForm(forms.Form): + """Form making sure domain renewal ack is checked""" + + is_policy_acknowledged = forms.BooleanField( + required=True, + label="I have read and agree to the requirements for operating a .gov domain.", + error_messages={ + "required": "Check the box if you read and agree to the requirements for operating a .gov domain." + }, + ) diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index 572ef6399..89f7522b3 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -7,6 +7,7 @@ from django import forms from django.core.validators import RegexValidator, MaxLengthValidator from django.utils.safestring import mark_safe +from registrar.forms.utility.combobox import ComboboxWidget from registrar.forms.utility.wizard_form_helper import ( RegistrarForm, RegistrarFormSet, @@ -17,6 +18,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__) @@ -42,7 +44,7 @@ class RequestingEntityForm(RegistrarForm): label="Suborganization name", required=False, queryset=Suborganization.objects.none(), - empty_label="--Select--", + widget=ComboboxWidget, ) requested_suborganization = forms.CharField( label="Requested suborganization", @@ -55,22 +57,44 @@ class RequestingEntityForm(RegistrarForm): suborganization_state_territory = forms.ChoiceField( label="State, territory, or military post", required=False, - choices=[("", "--Select--")] + DomainRequest.StateTerritoryChoices.choices, + choices=DomainRequest.StateTerritoryChoices.choices, + widget=ComboboxWidget, ) def __init__(self, *args, **kwargs): - """Override of init to add the suborganization queryset""" + """Override of init to add the suborganization queryset and 'other' option""" super().__init__(*args, **kwargs) if self.domain_request.portfolio: - self.fields["sub_organization"].queryset = Suborganization.objects.filter( - portfolio=self.domain_request.portfolio - ) + # Fetch the queryset for the portfolio + queryset = Suborganization.objects.filter(portfolio=self.domain_request.portfolio) + # set the queryset appropriately so that post can validate against queryset + self.fields["sub_organization"].queryset = queryset + + # Modify the choices to include "other" so that form can display options properly + self.fields["sub_organization"].choices = [(obj.id, str(obj)) for obj in queryset] + [ + ("other", "Other (enter your suborganization manually)") + ] + + @classmethod + def from_database(cls, obj: DomainRequest | Contact | None): + """Returns a dict of form field values gotten from `obj`. + Overrides RegistrarForm method in order to set sub_organization to 'other' + on GETs of the RequestingEntityForm.""" + if obj is None: + return {} + # get the domain request as a dict, per usual method + domain_request_dict = {name: getattr(obj, name) for name in cls.declared_fields.keys()} # type: ignore + + # set sub_organization to 'other' if is_requesting_new_suborganization is True + if isinstance(obj, DomainRequest) and obj.is_requesting_new_suborganization(): + domain_request_dict["sub_organization"] = "other" + + return domain_request_dict def clean_sub_organization(self): """On suborganization clean, set the suborganization value to None if the user is requesting a custom suborganization (as it doesn't exist yet)""" - # If it's a new suborganization, return None (equivalent to selecting nothing) if self.cleaned_data.get("is_requesting_new_suborganization"): return None @@ -78,43 +102,76 @@ 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. + """Validation logic to temporarily remove the custom suborganization value before clean is triggered. Without this override, the form will throw an 'invalid option' error.""" - # Remove the custom other field before cleaning - data = self.data.copy() if self.data else None + # Ensure self.data is not None before proceeding + if self.data: + # handle case where form has been submitted + # Create a copy of the data for manipulation + data = self.data.copy() - # Remove the 'other' value from suborganization if it exists. - # This is a special value that tracks if the user is requesting a new suborg. - suborganization = self.data.get("portfolio_requesting_entity-sub_organization") - if suborganization and "other" in suborganization: - data["portfolio_requesting_entity-sub_organization"] = "" + # Retrieve sub_organization and store in _original_suborganization + suborganization = data.get("portfolio_requesting_entity-sub_organization") + self._original_suborganization = suborganization + # If the original value was "other", clear it for validation + if self._original_suborganization == "other": + data["portfolio_requesting_entity-sub_organization"] = "" - # Set the modified data back to the form - self.data = data + # Set the modified data back to the form + self.data = data + else: + # handle case of a GET + suborganization = None + if self.initial and "sub_organization" in self.initial: + suborganization = self.initial["sub_organization"] + + # Check if is_requesting_new_suborganization is True + is_requesting_new_suborganization = False + if self.initial and "is_requesting_new_suborganization" in self.initial: + # Call the method if it exists + is_requesting_new_suborganization = self.initial["is_requesting_new_suborganization"]() + + # Determine if "other" should be set + if is_requesting_new_suborganization and suborganization is None: + self._original_suborganization = "other" + else: + self._original_suborganization = suborganization # Call the parent's full_clean method super().full_clean() + # Restore "other" if there are errors + if self.errors: + self.data["portfolio_requesting_entity-sub_organization"] = self._original_suborganization + def clean(self): - """Custom clean implementation to handle our desired logic flow for suborganization. - Given that these fields often rely on eachother, we need to do this in the parent function.""" + """Custom clean implementation to handle our desired logic flow for suborganization.""" cleaned_data = super().clean() - # Do some custom error validation if the requesting entity is a suborg. - # Otherwise, just validate as normal. - suborganization = self.cleaned_data.get("sub_organization") - is_requesting_new_suborganization = self.cleaned_data.get("is_requesting_new_suborganization") - - # Get the value of the yes/no checkbox from RequestingEntityYesNoForm. - # Since self.data stores this as a string, we need to convert "True" => True. + # Get the cleaned data + suborganization = cleaned_data.get("sub_organization") + is_requesting_new_suborganization = cleaned_data.get("is_requesting_new_suborganization") requesting_entity_is_suborganization = self.data.get( "portfolio_requesting_entity-requesting_entity_is_suborganization" ) 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.") @@ -126,6 +183,12 @@ class RequestingEntityForm(RegistrarForm): elif not suborganization: self.add_error("sub_organization", "Suborganization is required.") + # If there are errors, restore the "other" value for rendering + if self.errors and getattr(self, "_original_suborganization", None) == "other": + self.cleaned_data["sub_organization"] = self._original_suborganization + elif not self.data and getattr(self, "_original_suborganization", None) == "other": + self.cleaned_data["sub_organization"] = self._original_suborganization + return cleaned_data @@ -144,9 +207,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() @@ -256,7 +322,7 @@ class OrganizationContactForm(RegistrarForm): # uncomment to see if modelChoiceField can be an arg later required=False, queryset=FederalAgency.objects.exclude(agency__in=excluded_agencies), - empty_label="--Select--", + widget=ComboboxWidget, ) organization_name = forms.CharField( label="Organization name", @@ -276,10 +342,11 @@ class OrganizationContactForm(RegistrarForm): ) state_territory = forms.ChoiceField( label="State, territory, or military post", - choices=[("", "--Select--")] + DomainRequest.StateTerritoryChoices.choices, + choices=DomainRequest.StateTerritoryChoices.choices, error_messages={ "required": ("Select the state, territory, or military post where your organization is located.") }, + widget=ComboboxWidget, ) zipcode = forms.CharField( label="Zip code", @@ -395,6 +462,7 @@ class CurrentSitesForm(RegistrarForm): error_messages={ "invalid": ("Enter your organization's current website in the required format, like example.com.") }, + widget=forms.URLInput(attrs={"aria-labelledby": "id_current_sites_header id_current_sites_body"}), ) @@ -530,7 +598,7 @@ class PurposeForm(RegistrarForm): widget=forms.Textarea( attrs={ "aria-label": "What is the purpose of your requested domain? Describe how you’ll use your .gov domain. \ - Will it be used for a website, email, or something else? You can enter up to 2000 characters." + Will it be used for a website, email, or something else?" } ), validators=[ @@ -736,7 +804,13 @@ class NoOtherContactsForm(BaseDeletableRegistrarForm): required=True, # label has to end in a space to get the label_suffix to show label=("No other employees rationale"), - widget=forms.Textarea(), + widget=forms.Textarea( + attrs={ + "aria-label": "You don’t need to provide names of other employees now, \ + but it may slow down our assessment of your eligibility. Describe \ + why there are no other employees who can help verify your request." + } + ), validators=[ MaxLengthValidator( 1000, @@ -784,7 +858,12 @@ class AnythingElseForm(BaseDeletableRegistrarForm): anything_else = forms.CharField( required=True, label="Anything else?", - widget=forms.Textarea(), + widget=forms.Textarea( + attrs={ + "aria-label": "Is there anything else you’d like us to know about your domain request? \ + Provide details below. You can enter up to 2000 characters" + } + ), validators=[ MaxLengthValidator( 2000, diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 5309f7263..c9ef280b0 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -5,13 +5,13 @@ from django import forms from django.core.validators import RegexValidator from django.core.validators import MaxLengthValidator +from registrar.forms.utility.combobox import ComboboxWidget from registrar.models import ( PortfolioInvitation, UserPortfolioPermission, DomainInformation, Portfolio, SeniorOfficial, - User, ) from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices @@ -33,6 +33,15 @@ class PortfolioOrgAddressForm(forms.ModelForm): "required": "Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.", }, ) + state_territory = forms.ChoiceField( + label="State, territory, or military post", + required=True, + choices=DomainInformation.StateTerritoryChoices.choices, + error_messages={ + "required": ("Select the state, territory, or military post where your organization is located.") + }, + widget=ComboboxWidget(attrs={"required": True}), + ) class Meta: model = Portfolio @@ -47,25 +56,12 @@ class PortfolioOrgAddressForm(forms.ModelForm): error_messages = { "address_line1": {"required": "Enter the street address of your organization."}, "city": {"required": "Enter the city where your organization is located."}, - "state_territory": { - "required": "Select the state, territory, or military post where your organization is located." - }, "zipcode": {"required": "Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789."}, } widgets = { - # We need to set the required attributed for State/territory - # because for this fields we are creating an individual - # instance of the Select. For the other fields we use the for loop to set - # the class's required attribute to true. "address_line1": forms.TextInput, "address_line2": forms.TextInput, "city": forms.TextInput, - "state_territory": forms.Select( - attrs={ - "required": True, - }, - choices=DomainInformation.StateTerritoryChoices.choices, - ), # "urbanization": forms.TextInput, } @@ -110,104 +106,240 @@ class PortfolioSeniorOfficialForm(forms.ModelForm): return cleaned_data -class PortfolioMemberForm(forms.ModelForm): - """ - Form for updating a portfolio member. - """ +class BasePortfolioMemberForm(forms.ModelForm): + """Base form for the PortfolioMemberForm and PortfolioInvitedMemberForm""" - 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", + # The label for each of these has a red "required" star. We can just embed that here for simplicity. + required_star = '*' + role = forms.ChoiceField( choices=[ - ("view_only", "View all requests"), - ("view_and_create", "View all requests plus create requests"), - ("no_access", "No access"), + # Uses .value because the choice has a different label (on /admin) + (UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value, "Admin access"), + (UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, "Basic access"), ], widget=forms.RadioSelect, required=True, error_messages={ - "required": "Basic member permission is required", + "required": "Select the level of access you would like to grant this member.", }, ) + domain_permissions = forms.ChoiceField( + choices=[ + (UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value, "Viewer, limited"), + (UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value, "Viewer, all"), + ], + widget=forms.RadioSelect, + required=False, + initial=UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value, + error_messages={ + "required": "Domain permission is required.", + }, + ) + + domain_request_permissions = forms.ChoiceField( + choices=[ + ("no_access", "No access"), + (UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "Viewer"), + (UserPortfolioPermissionChoices.EDIT_REQUESTS.value, "Creator"), + ], + widget=forms.RadioSelect, + required=False, + initial="no_access", + error_messages={ + "required": "Domain request permission is required.", + }, + ) + + member_permissions = forms.ChoiceField( + choices=[ + ("no_access", "No access"), + (UserPortfolioPermissionChoices.VIEW_MEMBERS.value, "Viewer"), + ], + widget=forms.RadioSelect, + required=False, + initial="no_access", + error_messages={ + "required": "Member permission is required.", + }, + ) + + # Tracks what form elements are required for a given role choice. + # All of the fields included here have "required=False" by default as they are conditionally required. + # see def clean() for more details. + ROLE_REQUIRED_FIELDS = { + UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [], + UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [ + "domain_permissions", + "member_permissions", + "domain_request_permissions", + ], + } + + 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) + + # Adds a

description beneath each option + self.fields["domain_permissions"].descriptions = { + UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value: "Can view only the domains they manage", + UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value: "Can view all domains for the organization", + } + self.fields["domain_request_permissions"].descriptions = { + UserPortfolioPermissionChoices.EDIT_REQUESTS.value: ( + "Can view all domain requests for the organization and create requests" + ), + UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value: "Can view all domain requests for the organization", + "no_access": "Cannot view or create domain requests", + } + self.fields["member_permissions"].descriptions = { + UserPortfolioPermissionChoices.VIEW_MEMBERS.value: "Can view all members permissions", + "no_access": "Cannot view member permissions", + } + + # 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. + 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") + + # Get required fields for the selected role. Then validate all required fields for the role. + required_fields = self.ROLE_REQUIRED_FIELDS.get(role, []) + for field_name in required_fields: + # Helpful error for if this breaks + if field_name not in self.fields: + raise ValueError(f"ROLE_REQUIRED_FIELDS referenced a non-existent field: {field_name}.") + + if not cleaned_data.get(field_name): + self.add_error(field_name, self.fields.get(field_name).error_messages.get("required")) + + # Edgecase: Member uses a special form value for None called "no_access". + if cleaned_data.get("domain_request_permissions") == "no_access": + cleaned_data["domain_request_permissions"] = None + + # Edgecase: Member uses a special form value for None called "no_access". + if cleaned_data.get("member_permissions") == "no_access": + cleaned_data["member_permissions"] = 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 + + def map_instance_to_initial(self): + """ + Maps self.instance to self.initial, handling roles and permissions. + 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, + "domain_request_permission_admin": permission level if admin, + "domain_request_permissions": permission level if member + } + """ + if self.initial is None: + self.initial = {} + # Function variables + perms = UserPortfolioPermission.get_portfolio_permissions( + self.instance.roles, self.instance.additional_permissions, get_list=False + ) + # Get the available options for roles, domains, and member. + roles = [ + UserPortfolioRoleChoices.ORGANIZATION_ADMIN, + UserPortfolioRoleChoices.ORGANIZATION_MEMBER, + ] + domain_request_perms = [ + UserPortfolioPermissionChoices.EDIT_REQUESTS, + UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, + ] + domain_perms = [ + UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS, + UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, + ] + member_perms = [ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + ] + + # 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 = self.instance.roles or [] + selected_role = next((role for role in roles if role in roles), None) + self.initial["role"] = selected_role + is_member = selected_role == UserPortfolioRoleChoices.ORGANIZATION_MEMBER + if is_member: + # Edgecase: Member and domain request use 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), + UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value, + ) + selected_domain_request_permission = next( + (perm for perm in domain_request_perms if perm in perms), "no_access" + ) + selected_member_permission = next((perm for perm in member_perms if perm in perms), "no_access") + self.initial["domain_request_permissions"] = selected_domain_request_permission + self.initial["domain_permissions"] = selected_domain_permission + self.initial["member_permissions"] = selected_member_permission + + +class PortfolioMemberForm(BasePortfolioMemberForm): + """ + Form for updating a portfolio member. + """ + + class Meta: + model = UserPortfolioPermission + fields = ["roles", "additional_permissions"] + + +class PortfolioInvitedMemberForm(BasePortfolioMemberForm): + """ + Form for updating a portfolio invited member. + """ + + class Meta: + model = PortfolioInvitation + fields = ["roles", "additional_permissions"] + + +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", + label="Email", max_length=None, error_messages={ "invalid": ("Enter an email address in the required format, like name@example.com."), @@ -223,51 +355,5 @@ class NewMemberForm(forms.ModelForm): ) 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 + model = PortfolioInvitation + fields = ["portfolio", "email", "roles", "additional_permissions"] diff --git a/src/registrar/forms/utility/combobox.py b/src/registrar/forms/utility/combobox.py new file mode 100644 index 000000000..277aec4f3 --- /dev/null +++ b/src/registrar/forms/utility/combobox.py @@ -0,0 +1,5 @@ +from django.forms import Select + + +class ComboboxWidget(Select): + template_name = "django/forms/widgets/combobox.html" diff --git a/src/registrar/management/commands/create_federal_portfolio.py b/src/registrar/management/commands/create_federal_portfolio.py index 9cf4d36ea..4bc8f6715 100644 --- a/src/registrar/management/commands/create_federal_portfolio.py +++ b/src/registrar/management/commands/create_federal_portfolio.py @@ -5,6 +5,8 @@ import logging from django.core.management import BaseCommand, CommandError from registrar.management.commands.utility.terminal_helper import TerminalColors, TerminalHelper from registrar.models import DomainInformation, DomainRequest, FederalAgency, Suborganization, Portfolio, User +from registrar.models.utility.generic_helper import normalize_string +from django.db.models import F, Q logger = logging.getLogger(__name__) @@ -21,10 +23,21 @@ class Command(BaseCommand): self.failed_portfolios = set() def add_arguments(self, parser): - """Add three arguments: - 1. agency_name => the value of FederalAgency.agency - 2. --parse_requests => if true, adds the given portfolio to each related DomainRequest - 3. --parse_domains => if true, adds the given portfolio to each related DomainInformation + """Add command line arguments to create federal portfolios. + + Required (mutually exclusive) arguments: + --agency_name: Name of a specific FederalAgency to create a portfolio for + --branch: Federal branch to process ("executive", "legislative", or "judicial"). + Creates portfolios for all FederalAgencies in that branch. + + Required (at least one): + --parse_requests: Add the created portfolio(s) to related DomainRequest records + --parse_domains: Add the created portfolio(s) to related DomainInformation records + Note: You can use both --parse_requests and --parse_domains together + + Optional (mutually exclusive with parse options): + --both: Shorthand for using both --parse_requests and --parse_domains + Cannot be used with --parse_requests or --parse_domains """ group = parser.add_mutually_exclusive_group(required=True) group.add_argument( @@ -51,6 +64,11 @@ class Command(BaseCommand): action=argparse.BooleanOptionalAction, help="Adds portfolio to both requests and domains", ) + parser.add_argument( + "--skip_existing_portfolios", + action=argparse.BooleanOptionalAction, + help="Only add suborganizations to newly created portfolios, skip existing ones.", + ) def handle(self, **options): agency_name = options.get("agency_name") @@ -58,6 +76,7 @@ class Command(BaseCommand): parse_requests = options.get("parse_requests") parse_domains = options.get("parse_domains") both = options.get("both") + skip_existing_portfolios = options.get("skip_existing_portfolios") if not both: if not parse_requests and not parse_domains: @@ -78,39 +97,124 @@ class Command(BaseCommand): else: raise CommandError(f"Cannot find '{branch}' federal agencies in our database.") + portfolios = [] for federal_agency in agencies: message = f"Processing federal agency '{federal_agency.agency}'..." TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message) try: # C901 'Command.handle' is too complex (12) - self.handle_populate_portfolio(federal_agency, parse_domains, parse_requests, both) + portfolio = self.handle_populate_portfolio( + federal_agency, parse_domains, parse_requests, both, skip_existing_portfolios + ) + portfolios.append(portfolio) except Exception as exec: self.failed_portfolios.add(federal_agency) logger.error(exec) message = f"Failed to create portfolio '{federal_agency.agency}'" TerminalHelper.colorful_logger(logger.info, TerminalColors.FAIL, message) + # POST PROCESS STEP: Add additional suborg info where applicable. + updated_suborg_count = self.post_process_all_suborganization_fields(agencies) + message = f"Added city and state_territory information to {updated_suborg_count} suborgs." + TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message) TerminalHelper.log_script_run_summary( self.updated_portfolios, self.failed_portfolios, self.skipped_portfolios, debug=False, - skipped_header="----- SOME PORTFOLIOS WERE SKIPPED -----", + log_header="============= FINISHED HANDLE PORTFOLIO STEP ===============", + skipped_header="----- SOME PORTFOLIOS WERENT CREATED (BUT OTHER RECORDS ARE STILL PROCESSED) -----", display_as_str=True, ) - def handle_populate_portfolio(self, federal_agency, parse_domains, parse_requests, both): + # POST PROCESSING STEP: Remove the federal agency if it matches the portfolio name. + # We only do this for started domain requests. + if parse_requests or both: + prompt_message = ( + "This action will update domain requests even if they aren't on a portfolio." + "\nNOTE: This will modify domain requests, even if no portfolios were created." + "\nIn the event no portfolios *are* created, then this step will target " + "the existing portfolios with your given params." + "\nThis step is entirely optional, and is just for extra data cleanup." + ) + TerminalHelper.prompt_for_execution( + system_exit_on_terminate=True, + prompt_message=prompt_message, + prompt_title=( + "POST PROCESS STEP: Do you want to clear federal agency on (related) started domain requests?" + ), + verify_message="*** THIS STEP IS OPTIONAL ***", + ) + self.post_process_started_domain_requests(agencies, portfolios) + + def post_process_started_domain_requests(self, agencies, portfolios): + """ + Removes duplicate organization data by clearing federal_agency when it matches the portfolio name. + Only processes domain requests in STARTED status. + """ + message = "Removing duplicate portfolio and federal_agency values from domain requests..." + TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message) + + # For each request, clear the federal agency under these conditions: + # 1. A portfolio *already exists* with the same name as the federal agency. + # 2. Said portfolio (or portfolios) are only the ones specified at the start of the script. + # 3. The domain request is in status "started". + # Note: Both names are normalized so excess spaces are stripped and the string is lowercased. + domain_requests_to_update = DomainRequest.objects.filter( + federal_agency__in=agencies, + federal_agency__agency__isnull=False, + status=DomainRequest.DomainRequestStatus.STARTED, + organization_name__isnull=False, + ) + + if domain_requests_to_update.count() == 0: + TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, "No domain requests to update.") + return + + portfolio_set = {normalize_string(portfolio.organization_name) for portfolio in portfolios if portfolio} + + # Update the request, assuming the given agency name matches the portfolio name + updated_requests = [] + for req in domain_requests_to_update: + agency_name = normalize_string(req.federal_agency.agency) + if agency_name in portfolio_set: + req.federal_agency = None + updated_requests.append(req) + + # Execute the update and Log the results + if TerminalHelper.prompt_for_execution( + system_exit_on_terminate=False, + prompt_message=( + f"{len(domain_requests_to_update)} domain requests will be updated. " + f"These records will be changed: {[str(req) for req in updated_requests]}" + ), + prompt_title="Do you wish to commit this update to the database?", + ): + DomainRequest.objects.bulk_update(updated_requests, ["federal_agency"]) + TerminalHelper.colorful_logger(logger.info, TerminalColors.OKBLUE, "Action completed successfully.") + + def handle_populate_portfolio(self, federal_agency, parse_domains, parse_requests, both, skip_existing_portfolios): """Attempts to create a portfolio. If successful, this function will also create new suborganizations""" portfolio, created = self.create_portfolio(federal_agency) - if created: - self.create_suborganizations(portfolio, federal_agency) - if parse_domains or both: - self.handle_portfolio_domains(portfolio, federal_agency) + if skip_existing_portfolios and not created: + TerminalHelper.colorful_logger( + logger.warning, + TerminalColors.YELLOW, + "Skipping modifications to suborgs, domain requests, and " + "domains due to the --skip_existing_portfolios flag. Portfolio already exists.", + ) + return portfolio + + self.create_suborganizations(portfolio, federal_agency) + if parse_domains or both: + self.handle_portfolio_domains(portfolio, federal_agency) if parse_requests or both: self.handle_portfolio_requests(portfolio, federal_agency) + return portfolio + def create_portfolio(self, federal_agency): """Creates a portfolio if it doesn't presently exist. Returns portfolio, created.""" @@ -161,7 +265,6 @@ class Command(BaseCommand): federal_agency=federal_agency, organization_name__isnull=False ) org_names = set(valid_agencies.values_list("organization_name", flat=True)) - if not org_names: message = ( "Could not add any suborganizations." @@ -172,7 +275,7 @@ class Command(BaseCommand): return # Check for existing suborgs on the current portfolio - existing_suborgs = Suborganization.objects.filter(name__in=org_names) + existing_suborgs = Suborganization.objects.filter(name__in=org_names, name__isnull=False) if existing_suborgs.exists(): message = f"Some suborganizations already exist for portfolio '{portfolio}'." TerminalHelper.colorful_logger(logger.info, TerminalColors.OKBLUE, message) @@ -180,9 +283,7 @@ class Command(BaseCommand): # Create new suborgs, as long as they don't exist in the db already new_suborgs = [] for name in org_names - set(existing_suborgs.values_list("name", flat=True)): - # Stored in variables due to linter wanting type information here. - portfolio_name: str = portfolio.organization_name if portfolio.organization_name is not None else "" - if name is not None and name.lower() == portfolio_name.lower(): + if normalize_string(name) == normalize_string(portfolio.organization_name): # You can use this to populate location information, when this occurs. # However, this isn't needed for now so we can skip it. message = ( @@ -211,15 +312,13 @@ class Command(BaseCommand): DomainRequest.DomainRequestStatus.INELIGIBLE, DomainRequest.DomainRequestStatus.REJECTED, ] - domain_requests = DomainRequest.objects.filter(federal_agency=federal_agency, portfolio__isnull=True).exclude( - status__in=invalid_states - ) + domain_requests = DomainRequest.objects.filter(federal_agency=federal_agency).exclude(status__in=invalid_states) if not domain_requests.exists(): message = f""" Portfolio '{portfolio}' not added to domain requests: no valid records found. This means that a filter on DomainInformation for the federal_agency '{federal_agency}' returned no results. Excluded statuses: STARTED, INELIGIBLE, REJECTED. - Filter info: DomainRequest.objects.filter(federal_agency=federal_agency, portfolio__isnull=True).exclude( + Filter info: DomainRequest.objects.filter(federal_agency=federal_agency).exclude( status__in=invalid_states ) """ @@ -229,12 +328,30 @@ class Command(BaseCommand): # Get all suborg information and store it in a dict to avoid doing a db call suborgs = Suborganization.objects.filter(portfolio=portfolio).in_bulk(field_name="name") for domain_request in domain_requests: + # Set the portfolio domain_request.portfolio = portfolio - if domain_request.organization_name in suborgs: - domain_request.sub_organization = suborgs.get(domain_request.organization_name) + + # Set suborg info + domain_request.sub_organization = suborgs.get(domain_request.organization_name, None) + if domain_request.sub_organization is None: + domain_request.requested_suborganization = normalize_string( + domain_request.organization_name, lowercase=False + ) + domain_request.suborganization_city = normalize_string(domain_request.city, lowercase=False) + domain_request.suborganization_state_territory = domain_request.state_territory + self.updated_portfolios.add(portfolio) - DomainRequest.objects.bulk_update(domain_requests, ["portfolio", "sub_organization"]) + DomainRequest.objects.bulk_update( + domain_requests, + [ + "portfolio", + "sub_organization", + "requested_suborganization", + "suborganization_city", + "suborganization_state_territory", + ], + ) message = f"Added portfolio '{portfolio}' to {len(domain_requests)} domain requests." TerminalHelper.colorful_logger(logger.info, TerminalColors.OKGREEN, message) @@ -242,13 +359,15 @@ class Command(BaseCommand): """ Associate portfolio with domains for a federal agency. Updates all relevant domain information records. + + Returns a queryset of DomainInformation objects, or None if nothing changed. """ - domain_infos = DomainInformation.objects.filter(federal_agency=federal_agency, portfolio__isnull=True) + domain_infos = DomainInformation.objects.filter(federal_agency=federal_agency) if not domain_infos.exists(): message = f""" Portfolio '{portfolio}' not added to domains: no valid records found. The filter on DomainInformation for the federal_agency '{federal_agency}' returned no results. - Filter info: DomainInformation.objects.filter(federal_agency=federal_agency, portfolio__isnull=True) + Filter info: DomainInformation.objects.filter(federal_agency=federal_agency) """ TerminalHelper.colorful_logger(logger.info, TerminalColors.YELLOW, message) return None @@ -257,9 +376,146 @@ class Command(BaseCommand): suborgs = Suborganization.objects.filter(portfolio=portfolio).in_bulk(field_name="name") for domain_info in domain_infos: domain_info.portfolio = portfolio - if domain_info.organization_name in suborgs: - domain_info.sub_organization = suborgs.get(domain_info.organization_name) + domain_info.sub_organization = suborgs.get(domain_info.organization_name, None) DomainInformation.objects.bulk_update(domain_infos, ["portfolio", "sub_organization"]) message = f"Added portfolio '{portfolio}' to {len(domain_infos)} domains." TerminalHelper.colorful_logger(logger.info, TerminalColors.OKGREEN, message) + + def post_process_all_suborganization_fields(self, agencies): + """Batch updates suborganization locations from domain and request data. + + Args: + agencies: List of FederalAgency objects to process + + Returns: + int: Number of suborganizations updated + + Priority for location data: + 1. Domain information + 2. Domain request suborganization fields + 3. Domain request standard fields + """ + # Common filter between domaininformation / domain request. + # Filter by only the agencies we've updated thus far. + # Then, only process records without null portfolio, org name, or suborg name. + base_filter = Q( + federal_agency__in=agencies, + portfolio__isnull=False, + organization_name__isnull=False, + sub_organization__isnull=False, + ) & ~Q(organization_name__iexact=F("portfolio__organization_name")) + + # First: Remove null city / state_territory values on domain info / domain requests. + # We want to add city data if there is data to add to begin with! + domains = DomainInformation.objects.filter( + base_filter, + Q(city__isnull=False, state_territory__isnull=False), + ) + requests = DomainRequest.objects.filter( + base_filter, + ( + Q(city__isnull=False, state_territory__isnull=False) + | Q(suborganization_city__isnull=False, suborganization_state_territory__isnull=False) + ), + ) + + # Second: Group domains and requests by normalized organization name. + # This means that later down the line we have to account for "duplicate" org names. + domains_dict = {} + requests_dict = {} + for domain in domains: + normalized_name = normalize_string(domain.organization_name) + domains_dict.setdefault(normalized_name, []).append(domain) + + for request in requests: + normalized_name = normalize_string(request.organization_name) + requests_dict.setdefault(normalized_name, []).append(request) + + # Third: Get suborganizations to update + suborgs_to_edit = Suborganization.objects.filter( + Q(id__in=domains.values_list("sub_organization", flat=True)) + | Q(id__in=requests.values_list("sub_organization", flat=True)) + ) + + # Fourth: Process each suborg to add city / state territory info + for suborg in suborgs_to_edit: + self.post_process_suborganization_fields(suborg, domains_dict, requests_dict) + + # Fifth: Perform a bulk update + return Suborganization.objects.bulk_update(suborgs_to_edit, ["city", "state_territory"]) + + def post_process_suborganization_fields(self, suborg, domains_dict, requests_dict): + """Updates a single suborganization's location data if valid. + + Args: + suborg: Suborganization to update + domains_dict: Dict of domain info records grouped by org name + requests_dict: Dict of domain requests grouped by org name + + Priority matches parent method. Updates are skipped if location data conflicts + between multiple records of the same type. + """ + normalized_suborg_name = normalize_string(suborg.name) + domains = domains_dict.get(normalized_suborg_name, []) + requests = requests_dict.get(normalized_suborg_name, []) + + # Try to get matching domain info + domain = None + if domains: + reference = domains[0] + use_location_for_domain = all( + d.city == reference.city and d.state_territory == reference.state_territory for d in domains + ) + if use_location_for_domain: + domain = reference + + # Try to get matching request info + # Uses consensus: if all city / state_territory info matches, then we can assume the data is "good". + # If not, take the safe route and just skip updating this particular record. + request = None + use_suborg_location_for_request = True + use_location_for_request = True + if requests: + reference = requests[0] + use_suborg_location_for_request = all( + r.suborganization_city + and r.suborganization_state_territory + and r.suborganization_city == reference.suborganization_city + and r.suborganization_state_territory == reference.suborganization_state_territory + for r in requests + ) + use_location_for_request = all( + r.city + and r.state_territory + and r.city == reference.city + and r.state_territory == reference.state_territory + for r in requests + ) + if use_suborg_location_for_request or use_location_for_request: + request = reference + + if not domain and not request: + message = f"Skipping adding city / state_territory information to suborg: {suborg}. Bad data." + TerminalHelper.colorful_logger(logger.warning, TerminalColors.YELLOW, message) + return + + # PRIORITY: + # 1. Domain info + # 2. Domain request requested suborg fields + # 3. Domain request normal fields + if domain: + suborg.city = normalize_string(domain.city, lowercase=False) + suborg.state_territory = domain.state_territory + elif request and use_suborg_location_for_request: + suborg.city = normalize_string(request.suborganization_city, lowercase=False) + suborg.state_territory = request.suborganization_state_territory + elif request and use_location_for_request: + suborg.city = normalize_string(request.city, lowercase=False) + suborg.state_territory = request.state_territory + + message = ( + f"Added city/state_territory to suborg: {suborg}. " + f"city - {suborg.city}, state - {suborg.state_territory}" + ) + TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message) diff --git a/src/registrar/management/commands/patch_suborganizations.py b/src/registrar/management/commands/patch_suborganizations.py new file mode 100644 index 000000000..98ff1e36f --- /dev/null +++ b/src/registrar/management/commands/patch_suborganizations.py @@ -0,0 +1,133 @@ +import logging +from django.core.management import BaseCommand +from registrar.models import Suborganization, DomainRequest, DomainInformation +from registrar.management.commands.utility.terminal_helper import TerminalColors, TerminalHelper +from registrar.models.utility.generic_helper import count_capitals, normalize_string + + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Clean up duplicate suborganizations that differ only by spaces and capitalization" + + def handle(self, **kwargs): + """Process manual deletions and find/remove duplicates. Shows preview + and updates DomainInformation / DomainRequest sub_organization references before deletion.""" + + # First: get a preset list of records we want to delete. + # For extra_records_to_prune: the key gets deleted, the value gets kept. + extra_records_to_prune = { + normalize_string("Assistant Secretary for Preparedness and Response Office of the Secretary"): { + "replace_with": "Assistant Secretary for Preparedness and Response, Office of the Secretary" + }, + normalize_string("US Geological Survey"): {"replace_with": "U.S. Geological Survey"}, + normalize_string("USDA/OC"): {"replace_with": "USDA, Office of Communications"}, + normalize_string("GSA, IC, OGP WebPortfolio"): {"replace_with": "GSA, IC, OGP Web Portfolio"}, + normalize_string("USDA/ARS/NAL"): {"replace_with": "USDA, ARS, NAL"}, + } + + # Second: loop through every Suborganization and return a dict of what to keep, and what to delete + # for each duplicate or "incorrect" record. We do this by pruning records with extra spaces or bad caps + # Note that "extra_records_to_prune" is just a manual mapping. + records_to_prune = self.get_records_to_prune(extra_records_to_prune) + if len(records_to_prune) == 0: + TerminalHelper.colorful_logger(logger.error, TerminalColors.FAIL, "No suborganizations to delete.") + return + + # Third: Build a preview of the changes + total_records_to_remove = 0 + preview_lines = ["The following records will be removed:"] + for data in records_to_prune.values(): + keep = data.get("keep") + delete = data.get("delete") + if keep: + preview_lines.append(f"Keeping: '{keep.name}' (id: {keep.id})") + + for duplicate in delete: + preview_lines.append(f"Removing: '{duplicate.name}' (id: {duplicate.id})") + total_records_to_remove += 1 + preview_lines.append("") + preview = "\n".join(preview_lines) + + # Fourth: Get user confirmation and delete + if TerminalHelper.prompt_for_execution( + system_exit_on_terminate=True, + prompt_message=preview, + prompt_title=f"Remove {total_records_to_remove} suborganizations?", + verify_message="*** WARNING: This will replace the record on DomainInformation and DomainRequest! ***", + ): + try: + # Update all references to point to the right suborg before deletion + all_suborgs_to_remove = set() + for record in records_to_prune.values(): + best_record = record["keep"] + suborgs_to_remove = {dupe.id for dupe in record["delete"]} + DomainRequest.objects.filter(sub_organization_id__in=suborgs_to_remove).update( + sub_organization=best_record + ) + DomainInformation.objects.filter(sub_organization_id__in=suborgs_to_remove).update( + sub_organization=best_record + ) + all_suborgs_to_remove.update(suborgs_to_remove) + # Delete the suborgs + delete_count, _ = Suborganization.objects.filter(id__in=all_suborgs_to_remove).delete() + TerminalHelper.colorful_logger( + logger.info, TerminalColors.MAGENTA, f"Successfully deleted {delete_count} suborganizations." + ) + except Exception as e: + TerminalHelper.colorful_logger( + logger.error, TerminalColors.FAIL, f"Failed to delete suborganizations: {str(e)}" + ) + + def get_records_to_prune(self, extra_records_to_prune): + """Maps all suborgs into a dictionary with a record to keep, and an array of records to delete.""" + # First: Group all suborganization names by their "normalized" names (finding duplicates). + # Returns a dict that looks like this: + # { + # "amtrak": [, , ], + # "usda/oc": [], + # ...etc + # } + # + name_groups = {} + for suborg in Suborganization.objects.all(): + normalized_name = normalize_string(suborg.name) + name_groups.setdefault(normalized_name, []).append(suborg) + + # Second: find the record we should keep, and the records we should delete + # Returns a dict that looks like this: + # { + # "amtrak": { + # "keep": + # "delete": [, ] + # }, + # "usda/oc": { + # "keep": , + # "delete": [] + # }, + # ...etc + # } + records_to_prune = {} + for normalized_name, duplicate_suborgs in name_groups.items(): + # Delete data from our preset list + if normalized_name in extra_records_to_prune: + # The 'keep' field expects a Suborganization but we just pass in a string, so this is just a workaround. + # This assumes that there is only one item in the name_group array (see usda/oc example). + # But this should be fine, given our data. + hardcoded_record_name = extra_records_to_prune[normalized_name]["replace_with"] + name_group = name_groups.get(normalize_string(hardcoded_record_name)) + keep = name_group[0] if name_group else None + records_to_prune[normalized_name] = {"keep": keep, "delete": duplicate_suborgs} + # Delete duplicates (extra spaces or casing differences) + elif len(duplicate_suborgs) > 1: + # Pick the best record (fewest spaces, most leading capitals) + best_record = max( + duplicate_suborgs, + key=lambda suborg: (-suborg.name.count(" "), count_capitals(suborg.name, leading_only=True)), + ) + records_to_prune[normalized_name] = { + "keep": best_record, + "delete": [s for s in duplicate_suborgs if s != best_record], + } + return records_to_prune diff --git a/src/registrar/management/commands/remove_unused_portfolios.py b/src/registrar/management/commands/remove_unused_portfolios.py new file mode 100644 index 000000000..4940cc16f --- /dev/null +++ b/src/registrar/management/commands/remove_unused_portfolios.py @@ -0,0 +1,238 @@ +import argparse +import logging + +from django.core.management.base import BaseCommand +from django.db import IntegrityError +from django.db import transaction +from registrar.management.commands.utility.terminal_helper import ( + TerminalColors, + TerminalHelper, +) +from registrar.models import ( + Portfolio, + DomainGroup, + DomainInformation, + DomainRequest, + PortfolioInvitation, + Suborganization, + UserPortfolioPermission, +) + +logger = logging.getLogger(__name__) + +ALLOWED_PORTFOLIOS = [ + "Department of Veterans Affairs", + "Department of the Treasury", + "National Archives and Records Administration", + "Department of Defense", + "Office of Personnel Management", + "National Aeronautics and Space Administration", + "City and County of San Francisco", + "State of Arizona, Executive Branch", + "Department of the Interior", + "Department of State", + "Department of Justice", + "Capitol Police", + "Administrative Office of the Courts", + "Supreme Court of the United States", +] + + +class Command(BaseCommand): + help = "Remove all Portfolio entries with names not in the allowed list." + + def add_arguments(self, parser): + """ + OPTIONAL ARGUMENTS: + --debug + A boolean (default to true), which activates additional print statements + """ + parser.add_argument("--debug", action=argparse.BooleanOptionalAction) + + def prompt_delete_entries(self, portfolios_to_delete, debug_on): + """Brings up a prompt in the terminal asking + if the user wishes to delete data in the + Portfolio table. If the user confirms, + deletes the data in the Portfolio table""" + + entries_to_remove_by_name = list(portfolios_to_delete.values_list("organization_name", flat=True)) + formatted_entries = "\n\t\t".join(entries_to_remove_by_name) + confirm_delete = TerminalHelper.query_yes_no( + f""" + {TerminalColors.FAIL} + WARNING: You are about to delete the following portfolios: + + {formatted_entries} + + Are you sure you want to continue?{TerminalColors.ENDC}""" + ) + if confirm_delete: + logger.info( + f"""{TerminalColors.YELLOW} + ----------Deleting entries---------- + (please wait) + {TerminalColors.ENDC}""" + ) + self.delete_entries(portfolios_to_delete, debug_on) + else: + logger.info( + f"""{TerminalColors.OKCYAN} + ----------No entries deleted---------- + (exiting script) + {TerminalColors.ENDC}""" + ) + + def delete_entries(self, portfolios_to_delete, debug_on): # noqa: C901 + # Log the number of entries being removed + count = portfolios_to_delete.count() + if count == 0: + logger.info( + f"""{TerminalColors.OKCYAN} + No entries to remove. + {TerminalColors.ENDC} + """ + ) + return + + # If debug mode is on, print out entries being removed + if debug_on: + entries_to_remove_by_name = list(portfolios_to_delete.values_list("organization_name", flat=True)) + formatted_entries = ", ".join(entries_to_remove_by_name) + logger.info( + f"""{TerminalColors.YELLOW} + Entries to be removed: {formatted_entries} + {TerminalColors.ENDC} + """ + ) + + # Check for portfolios with non-empty related objects + # (These will throw integrity errors if they are not updated) + portfolios_with_assignments = [] + for portfolio in portfolios_to_delete: + has_assignments = any( + [ + DomainGroup.objects.filter(portfolio=portfolio).exists(), + DomainInformation.objects.filter(portfolio=portfolio).exists(), + DomainRequest.objects.filter(portfolio=portfolio).exists(), + PortfolioInvitation.objects.filter(portfolio=portfolio).exists(), + Suborganization.objects.filter(portfolio=portfolio).exists(), + UserPortfolioPermission.objects.filter(portfolio=portfolio).exists(), + ] + ) + if has_assignments: + portfolios_with_assignments.append(portfolio) + + if portfolios_with_assignments: + formatted_entries = "\n\t\t".join( + f"{portfolio.organization_name}" for portfolio in portfolios_with_assignments + ) + confirm_cascade_delete = TerminalHelper.query_yes_no( + f""" + {TerminalColors.FAIL} + WARNING: these entries have related objects. + + {formatted_entries} + + Deleting them will update any associated domains / domain requests to have no portfolio + and will cascade delete any associated portfolio invitations, portfolio permissions, domain groups, + and suborganizations. Any suborganizations that get deleted will also orphan (not delete) their + associated domains / domain requests. + + Are you sure you want to continue?{TerminalColors.ENDC}""" + ) + if not confirm_cascade_delete: + logger.info( + f"""{TerminalColors.OKCYAN} + Operation canceled by the user. + {TerminalColors.ENDC} + """ + ) + return + + with transaction.atomic(): + # Try to delete the portfolios + try: + summary = [] + for portfolio in portfolios_to_delete: + portfolio_summary = [f"---- CASCADE SUMMARY for {portfolio.organization_name} -----"] + if portfolio in portfolios_with_assignments: + domain_groups = DomainGroup.objects.filter(portfolio=portfolio) + domain_informations = DomainInformation.objects.filter(portfolio=portfolio) + domain_requests = DomainRequest.objects.filter(portfolio=portfolio) + portfolio_invitations = PortfolioInvitation.objects.filter(portfolio=portfolio) + suborganizations = Suborganization.objects.filter(portfolio=portfolio) + user_permissions = UserPortfolioPermission.objects.filter(portfolio=portfolio) + + if domain_groups.exists(): + formatted_groups = "\n".join([str(group) for group in domain_groups]) + portfolio_summary.append(f"{len(domain_groups)} Deleted DomainGroups:\n{formatted_groups}") + domain_groups.delete() + + if domain_informations.exists(): + formatted_domain_infos = "\n".join([str(info) for info in domain_informations]) + portfolio_summary.append( + f"{len(domain_informations)} Orphaned DomainInformations:\n{formatted_domain_infos}" + ) + domain_informations.update(portfolio=None) + + if domain_requests.exists(): + formatted_domain_reqs = "\n".join([str(req) for req in domain_requests]) + portfolio_summary.append( + f"{len(domain_requests)} Orphaned DomainRequests:\n{formatted_domain_reqs}" + ) + domain_requests.update(portfolio=None) + + if portfolio_invitations.exists(): + formatted_portfolio_invitations = "\n".join([str(inv) for inv in portfolio_invitations]) + portfolio_summary.append( + f"{len(portfolio_invitations)} Deleted PortfolioInvitations:\n{formatted_portfolio_invitations}" # noqa + ) + portfolio_invitations.delete() + + if user_permissions.exists(): + formatted_user_list = "\n".join( + [perm.user.get_formatted_name() for perm in user_permissions] + ) + portfolio_summary.append( + f"Deleted UserPortfolioPermissions for the following users:\n{formatted_user_list}" + ) + user_permissions.delete() + + if suborganizations.exists(): + portfolio_summary.append("Cascade Deleted Suborganizations:") + for suborg in suborganizations: + DomainInformation.objects.filter(sub_organization=suborg).update(sub_organization=None) + DomainRequest.objects.filter(sub_organization=suborg).update(sub_organization=None) + portfolio_summary.append(f"{suborg.name}") + suborg.delete() + + portfolio.delete() + summary.append("\n\n".join(portfolio_summary)) + summary_string = "\n\n".join(summary) + + # Output a success message with detailed summary + logger.info( + f"""{TerminalColors.OKCYAN} + Successfully removed {count} portfolios. + + The following portfolio deletions had cascading effects; + + {summary_string} + {TerminalColors.ENDC} + """ + ) + + except IntegrityError as e: + logger.info( + f"""{TerminalColors.FAIL} + Could not delete some portfolios due to integrity constraints: + {e} + {TerminalColors.ENDC} + """ + ) + + def handle(self, *args, **options): + # Get all Portfolio entries not in the allowed portfolios list + portfolios_to_delete = Portfolio.objects.exclude(organization_name__in=ALLOWED_PORTFOLIOS) + + self.prompt_delete_entries(portfolios_to_delete, options.get("debug")) diff --git a/src/registrar/management/commands/utility/terminal_helper.py b/src/registrar/management/commands/utility/terminal_helper.py index eed1027f7..87d9f12e5 100644 --- a/src/registrar/management/commands/utility/terminal_helper.py +++ b/src/registrar/management/commands/utility/terminal_helper.py @@ -401,16 +401,15 @@ class TerminalHelper: # Allow the user to inspect the command string # and ask if they wish to proceed proceed_execution = TerminalHelper.query_yes_no_exit( - f"""{TerminalColors.OKCYAN} - ===================================================== - {prompt_title} - ===================================================== - {verify_message} - - {prompt_message} - {TerminalColors.FAIL} - Proceed? (Y = proceed, N = {action_description_for_selecting_no}) - {TerminalColors.ENDC}""" + f"\n{TerminalColors.OKCYAN}" + "=====================================================" + f"\n{prompt_title}\n" + "=====================================================" + f"\n{verify_message}\n" + f"\n{prompt_message}\n" + f"{TerminalColors.FAIL}" + f"Proceed? (Y = proceed, N = {action_description_for_selecting_no})" + f"{TerminalColors.ENDC}" ) # If the user decided to proceed return true. @@ -443,13 +442,14 @@ class TerminalHelper: f.write(file_contents) @staticmethod - def colorful_logger(log_level, color, message): + def colorful_logger(log_level, color, message, exc_info=True): """Adds some color to your log output. Args: log_level: str | Logger.method -> Desired log level. ex: logger.info or "INFO" color: str | TerminalColors -> Output color. ex: TerminalColors.YELLOW or "YELLOW" message: str -> Message to display. + exc_info: bool -> Whether the log should print exc_info or not """ if isinstance(log_level, str) and hasattr(logger, log_level.lower()): @@ -463,4 +463,4 @@ class TerminalHelper: terminal_color = color colored_message = f"{terminal_color}{message}{TerminalColors.ENDC}" - log_method(colored_message) + log_method(colored_message, exc_info=exc_info) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 19e96719f..0f0b3f112 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -2,11 +2,11 @@ from itertools import zip_longest import logging import ipaddress import re -from datetime import date +from datetime import date, timedelta from typing import Optional +from django.db import transaction from django_fsm import FSMField, transition, TransitionNotAllowed # type: ignore - -from django.db import models +from django.db import models, IntegrityError from django.utils import timezone from typing import Any from registrar.models.host import Host @@ -40,6 +40,7 @@ from .utility.time_stamped_model import TimeStampedModel from .public_contact import PublicContact from .user_domain_role import UserDomainRole +from waffle.decorators import flag_is_active logger = logging.getLogger(__name__) @@ -325,9 +326,8 @@ class Domain(TimeStampedModel, DomainHelper): exp_date = self.registry_expiration_date except KeyError: # if no expiration date from registry, set it to today - logger.warning("current expiration date not set; setting to today") + logger.warning("current expiration date not set; setting to today", exc_info=True) exp_date = date.today() - # create RenewDomain request request = commands.RenewDomain(name=self.name, cur_exp_date=exp_date, period=epp.Period(length, unit)) @@ -337,13 +337,14 @@ class Domain(TimeStampedModel, DomainHelper): self._cache["ex_date"] = registry.send(request, cleaned=True).res_data[0].ex_date self.expiration_date = self._cache["ex_date"] self.save() + except RegistryError as err: # if registry error occurs, log the error, and raise it as well - logger.error(f"registry error renewing domain: {err}") + logger.error(f"Registry error renewing domain '{self.name}': {err}") raise (err) except Exception as e: # exception raised during the save to registrar - logger.error(f"error updating expiration date in registrar: {e}") + logger.error(f"Error updating expiration date for domain '{self.name}' in registrar: {e}") raise (e) @Cache @@ -1152,14 +1153,29 @@ class Domain(TimeStampedModel, DomainHelper): now = timezone.now().date() return self.expiration_date < now - def state_display(self): + def is_expiring(self): + """ + Check if the domain's expiration date is within 60 days. + Return True if domain expiration date exists and within 60 days + and otherwise False bc there's no expiration date meaning so not expiring + """ + if self.expiration_date is None: + return False + + now = timezone.now().date() + + threshold_date = now + timedelta(days=60) + return now < self.expiration_date <= threshold_date + + def state_display(self, request=None): """Return the display status of the domain.""" - if self.is_expired() and self.state != self.State.UNKNOWN: + if self.is_expired() and (self.state != self.State.UNKNOWN): return "Expired" + elif flag_is_active(request, "domain_renewal") and self.is_expiring(): + return "Expiring soon" elif self.state == self.State.UNKNOWN or self.state == self.State.DNS_NEEDED: return "DNS needed" - else: - return self.state.capitalize() + return self.state.capitalize() def map_epp_contact_to_public_contact(self, contact: eppInfo.InfoContactResultData, contact_id, contact_type): """Maps the Epp contact representation to a PublicContact object. @@ -1313,14 +1329,14 @@ class Domain(TimeStampedModel, DomainHelper): def get_default_administrative_contact(self): """Gets the default administrative contact.""" - logger.info("get_default_security_contact() -> Adding administrative security contact") + logger.info("get_default_administrative_contact() -> Adding default administrative contact") contact = PublicContact.get_default_administrative() contact.domain = self return contact def get_default_technical_contact(self): """Gets the default technical contact.""" - logger.info("get_default_security_contact() -> Adding technical security contact") + logger.info("get_default_security_contact() -> Adding default technical contact") contact = PublicContact.get_default_technical() contact.domain = self return contact @@ -1559,16 +1575,16 @@ class Domain(TimeStampedModel, DomainHelper): logger.info("Changing to DNS_NEEDED state") logger.info("able to transition to DNS_NEEDED state") - def get_state_help_text(self) -> str: + def get_state_help_text(self, request=None) -> str: """Returns a str containing additional information about a given state. Returns custom content for when the domain itself is expired.""" if self.is_expired() and self.state != self.State.UNKNOWN: # Given expired is not a physical state, but it is displayed as such, # We need custom logic to determine this message. - help_text = ( - "This domain has expired, but it is still online. " "To renew this domain, contact help@get.gov." - ) + help_text = "This domain has expired. Complete the online renewal process to maintain access." + elif flag_is_active(request, "domain_renewal") and self.is_expiring(): + help_text = "This domain is expiring soon. Complete the online renewal process to maintain access." else: help_text = Domain.State.get_help_text(self.state) @@ -1660,9 +1676,11 @@ class Domain(TimeStampedModel, DomainHelper): for domainContact in contact_data: req = commands.InfoContact(id=domainContact.contact) data = registry.send(req, cleaned=True).res_data[0] + logger.info(f"_fetch_contacts => this is the data: {data}") # Map the object we recieved from EPP to a PublicContact mapped_object = self.map_epp_contact_to_public_contact(data, domainContact.contact, domainContact.type) + logger.info(f"_fetch_contacts => mapped_object: {mapped_object}") # Find/create it in the DB in_db = self._get_or_create_public_contact(mapped_object) @@ -1853,8 +1871,9 @@ class Domain(TimeStampedModel, DomainHelper): missingSecurity = True missingTech = True - if len(cleaned.get("_contacts")) < 3: - for contact in cleaned.get("_contacts"): + contacts = cleaned.get("_contacts", []) + if len(contacts) < 3: + for contact in contacts: if contact.type == PublicContact.ContactTypeChoices.ADMINISTRATIVE: missingAdmin = False if contact.type == PublicContact.ContactTypeChoices.SECURITY: @@ -1873,6 +1892,11 @@ class Domain(TimeStampedModel, DomainHelper): technical_contact = self.get_default_technical_contact() technical_contact.save() + logger.info( + "_add_missing_contacts_if_unknown => Adding contacts. Values are " + f"missingAdmin: {missingAdmin}, missingSecurity: {missingSecurity}, missingTech: {missingTech}" + ) + def _fetch_cache(self, fetch_hosts=False, fetch_contacts=False): """Contact registry for info about a domain.""" try: @@ -2086,8 +2110,21 @@ class Domain(TimeStampedModel, DomainHelper): # Save to DB if it doesn't exist already. if db_contact.count() == 0: # Doesn't run custom save logic, just saves to DB - public_contact.save(skip_epp_save=True) - logger.info(f"Created a new PublicContact: {public_contact}") + try: + with transaction.atomic(): + public_contact.save(skip_epp_save=True) + logger.info(f"Created a new PublicContact: {public_contact}") + except IntegrityError as err: + logger.error( + f"_get_or_create_public_contact() => tried to create a duplicate public contact: {err}", + exc_info=True, + ) + return PublicContact.objects.get( + registry_id=public_contact.registry_id, + contact_type=public_contact.contact_type, + domain=self, + ) + # Append the item we just created return public_contact @@ -2097,7 +2134,7 @@ class Domain(TimeStampedModel, DomainHelper): if existing_contact.email != public_contact.email or existing_contact.registry_id != public_contact.registry_id: existing_contact.delete() public_contact.save() - logger.warning("Requested PublicContact is out of sync " "with DB.") + logger.warning("Requested PublicContact is out of sync with DB.") return public_contact # If it already exists, we can assume that the DB instance was updated during set, so we should just use that. diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py index b1c4fd806..aa933e282 100644 --- a/src/registrar/models/domain_information.py +++ b/src/registrar/models/domain_information.py @@ -101,7 +101,6 @@ class DomainInformation(TimeStampedModel): verbose_name="election office", ) - # TODO - Ticket #1911: stub this data from DomainRequest organization_type = models.CharField( max_length=255, choices=DomainRequest.OrgChoicesElectionOffice.choices, diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index 44d8511b0..c5a0926ad 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -9,9 +9,11 @@ from django.utils import timezone from registrar.models.domain import Domain from registrar.models.federal_agency import FederalAgency from registrar.models.utility.generic_helper import CreateOrUpdateOrganizationTypeHelper +from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices from registrar.utility.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 +673,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 +745,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 +851,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,12 +865,46 @@ 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, email_template, email_template_subject, bcc_address="", + cc_addresses: list[str] = [], context=None, send_email=True, wrap_email=False, @@ -854,12 +957,20 @@ class DomainRequest(TimeStampedModel): if custom_email_content: context["custom_email_content"] = custom_email_content + + if self.requesting_entity_is_portfolio() or self.requesting_entity_is_suborganization(): + portfolio_view_requests_users = self.portfolio.portfolio_users_with_permissions( # type: ignore + permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS], include_admin=True + ) + cc_addresses = list(portfolio_view_requests_users.values_list("email", flat=True)) + send_templated_email( email_template, email_template_subject, recipient.email, context=context, bcc_address=bcc_address, + cc_addresses=cc_addresses, wrap_email=wrap_email, ) logger.info(f"The {new_status} email sent to: {recipient.email}") @@ -984,6 +1095,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 +1126,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 +1268,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/portfolio.py b/src/registrar/models/portfolio.py index 82afcd4d6..9607736f2 100644 --- a/src/registrar/models/portfolio.py +++ b/src/registrar/models/portfolio.py @@ -4,6 +4,7 @@ from registrar.models.domain_request import DomainRequest from registrar.models.federal_agency import FederalAgency from registrar.models.user import User from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices +from django.db.models import Q from .utility.time_stamped_model import TimeStampedModel @@ -122,6 +123,16 @@ class Portfolio(TimeStampedModel): if self.state_territory != self.StateTerritoryChoices.PUERTO_RICO and self.urbanization: self.urbanization = None + # If the org type is federal, and org federal agency is not blank, and is a federal agency + # overwrite the organization name with the federal agency's agency + if ( + self.organization_type == self.OrganizationChoices.FEDERAL + and self.federal_agency + and self.federal_agency != FederalAgency.get_non_federal_agency() + and self.federal_agency.agency + ): + self.organization_name = self.federal_agency.agency + super().save(*args, **kwargs) @property @@ -144,6 +155,25 @@ class Portfolio(TimeStampedModel): ).values_list("user__id", flat=True) return User.objects.filter(id__in=admin_ids) + def portfolio_users_with_permissions(self, permissions=[], include_admin=False): + """Gets all users with specified additional permissions for this particular portfolio. + Returns a queryset of User.""" + portfolio_users = self.portfolio_users + if permissions: + if include_admin: + portfolio_users = portfolio_users.filter( + Q(additional_permissions__overlap=permissions) + | Q( + roles__overlap=[ + UserPortfolioRoleChoices.ORGANIZATION_ADMIN, + ] + ), + ) + else: + portfolio_users = portfolio_users.filter(additional_permissions__overlap=permissions) + user_ids = portfolio_users.values_list("user__id", flat=True) + return User.objects.filter(id__in=user_ids) + # == Getters for domains == # def get_domains(self, order_by=None): """Returns all DomainInformations associated with this portfolio""" diff --git a/src/registrar/models/senior_official.py b/src/registrar/models/senior_official.py index 38ce4f35d..3268e9dc9 100644 --- a/src/registrar/models/senior_official.py +++ b/src/registrar/models/senior_official.py @@ -55,7 +55,9 @@ class SeniorOfficial(TimeStampedModel): return " ".join(names) if names else "Unknown" def __str__(self): - if self.first_name or self.last_name: + if self.federal_agency and (self.first_name or self.last_name): + return self.get_formatted_name() + " of " + self.federal_agency.__str__() + elif self.first_name or self.last_name: return self.get_formatted_name() elif self.pk: return str(self.pk) diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index bdfc6f804..1d508f88f 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -14,6 +14,8 @@ from .domain import Domain from .domain_request import DomainRequest from registrar.utility.waffle import flag_is_active_for_user from waffle.decorators import flag_is_active +from django.utils import timezone +from datetime import timedelta from phonenumber_field.modelfields import PhoneNumberField # type: ignore @@ -163,6 +165,23 @@ class User(AbstractUser): active_requests_count = self.domain_requests_created.filter(status__in=allowed_states).count() return active_requests_count + def get_num_expiring_domains(self, request): + """Return number of expiring domains""" + domain_ids = self.get_user_domain_ids(request) + now = timezone.now().date() + expiration_window = 60 + threshold_date = now + timedelta(days=expiration_window) + acceptable_statuses = [Domain.State.UNKNOWN, Domain.State.DNS_NEEDED, Domain.State.READY] + + num_of_expiring_domains = Domain.objects.filter( + id__in=domain_ids, + expiration_date__isnull=False, + expiration_date__lte=threshold_date, + expiration_date__gt=now, + state__in=acceptable_statuses, + ).count() + return num_of_expiring_domains + def get_rejected_requests_count(self): """Return count of rejected requests""" return self.domain_requests_created.filter(status=DomainRequest.DomainRequestStatus.REJECTED).count() @@ -259,6 +278,9 @@ class User(AbstractUser): def is_portfolio_admin(self, portfolio): return "Admin" in self.portfolio_role_summary(portfolio) + def has_domain_renewal_flag(self): + return flag_is_active_for_user(self, "domain_renewal") + def get_first_portfolio(self): permission = self.portfolio_permissions.first() if permission: diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py index a149a9476..c4be90a9b 100644 --- a/src/registrar/models/user_portfolio_permission.py +++ b/src/registrar/models/user_portfolio_permission.py @@ -21,16 +21,18 @@ class UserPortfolioPermission(TimeStampedModel): UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [ UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, + UserPortfolioPermissionChoices.EDIT_REQUESTS, UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, UserPortfolioPermissionChoices.VIEW_PORTFOLIO, UserPortfolioPermissionChoices.EDIT_PORTFOLIO, - # Domain: field specific permissions UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION, UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION, ], # NOTE: Check FORBIDDEN_PORTFOLIO_ROLE_PERMISSIONS before adding roles here. UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [ UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION, ], } @@ -38,9 +40,9 @@ class UserPortfolioPermission(TimeStampedModel): # Used to throw a ValidationError on clean() for UserPortfolioPermission and PortfolioInvitation. FORBIDDEN_PORTFOLIO_ROLE_PERMISSIONS = { UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [ - UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_PORTFOLIO, UserPortfolioPermissionChoices.EDIT_MEMBERS, - UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, + UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION, ], } @@ -110,8 +112,13 @@ class UserPortfolioPermission(TimeStampedModel): return self.get_portfolio_permissions(self.roles, self.additional_permissions) @classmethod - def get_portfolio_permissions(cls, roles, additional_permissions): - """Class method to return a list of permissions based on roles and addtl permissions""" + def get_portfolio_permissions(cls, roles, additional_permissions, get_list=True): + """Class method to return a list of permissions based on roles and addtl permissions. + Params: + roles => An array of roles + additional_permissions => An array of additional_permissions + get_list => If true, returns a list of perms. If false, returns a set of perms. + """ # Use a set to avoid duplicate permissions portfolio_permissions = set() if roles: @@ -119,7 +126,7 @@ class UserPortfolioPermission(TimeStampedModel): portfolio_permissions.update(cls.PORTFOLIO_ROLE_PERMISSIONS.get(role, [])) if additional_permissions: portfolio_permissions.update(additional_permissions) - return list(portfolio_permissions) + return list(portfolio_permissions) if get_list else portfolio_permissions @classmethod def get_domain_request_permission_display(cls, roles, additional_permissions): @@ -166,8 +173,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/models/utility/domain_helper.py b/src/registrar/models/utility/domain_helper.py index 87a885309..0459831e9 100644 --- a/src/registrar/models/utility/domain_helper.py +++ b/src/registrar/models/utility/domain_helper.py @@ -15,9 +15,11 @@ class DomainHelper: # a domain name is alphanumeric or hyphen, up to 63 characters, doesn't # begin or end with a hyphen, followed by a TLD of 2-6 alphabetic characters - DOMAIN_REGEX = re.compile(r"^(?!-)[A-Za-z0-9-]{1,63}(? {string_to_normalize} is not type str.") + return string_to_normalize + + new_string = " ".join(string_to_normalize.split()) + return new_string.lower() if lowercase else new_string + + +def count_capitals(text: str, leading_only: bool): + """Counts capital letters in a string. + Args: + text (str): The string to analyze. + leading_only (bool): If False, counts all capital letters. + If True, only counts capitals at the start of words. + Returns: + int: Number of capital letters found. + """ + if leading_only: + return sum(word[0].isupper() for word in text.split() if word) + return sum(c.isupper() for c in text if c) diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py index 3768aa77a..b3bb07c3d 100644 --- a/src/registrar/models/utility/portfolio_helper.py +++ b/src/registrar/models/utility/portfolio_helper.py @@ -4,6 +4,9 @@ from django.apps import apps from django.forms import ValidationError from registrar.utility.waffle import flag_is_active_for_user from django.contrib.auth import get_user_model +import logging + +logger = logging.getLogger(__name__) class UserPortfolioRoleChoices(models.TextChoices): @@ -16,7 +19,11 @@ class UserPortfolioRoleChoices(models.TextChoices): @classmethod def get_user_portfolio_role_label(cls, user_portfolio_role): - return cls(user_portfolio_role).label if user_portfolio_role else None + try: + return cls(user_portfolio_role).label if user_portfolio_role else None + except ValueError: + logger.warning(f"Invalid portfolio role: {user_portfolio_role}") + return f"Unknown ({user_portfolio_role})" class UserPortfolioPermissionChoices(models.TextChoices): @@ -129,7 +136,9 @@ def validate_user_portfolio_permission(user_portfolio_permission): "Based on current waffle flag settings, users cannot be assigned to multiple portfolios." ) - existing_invitations = PortfolioInvitation.objects.filter(email=user_portfolio_permission.user.email) + existing_invitations = PortfolioInvitation.objects.exclude( + portfolio=user_portfolio_permission.portfolio + ).filter(email=user_portfolio_permission.user.email) if existing_invitations.exists(): raise ValidationError( "This user is already assigned to a portfolio invitation. " 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..2c98148f2 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/includes/portfolio/portfolio_members_table.html b/src/registrar/templates/django/admin/includes/portfolio/portfolio_members_table.html index fe62f268b..d07e5abf4 100644 --- a/src/registrar/templates/django/admin/includes/portfolio/portfolio_members_table.html +++ b/src/registrar/templates/django/admin/includes/portfolio/portfolio_members_table.html @@ -30,7 +30,7 @@ {{ member.user.phone }} {% for role in member.user|portfolio_role_summary:original %} - {{ role }} + {{ role }} {% endfor %} 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_domain_role_change_form.html b/src/registrar/templates/django/admin/user_domain_role_change_form.html new file mode 100644 index 000000000..d8c298bc1 --- /dev/null +++ b/src/registrar/templates/django/admin/user_domain_role_change_form.html @@ -0,0 +1,14 @@ +{% extends 'django/admin/email_clipboard_change_form.html' %} +{% load custom_filters %} +{% load i18n static %} + +{% block content_subtitle %} +
    +
    +

    + If you add someone to a domain here, it will not trigger any emails. To trigger emails, use the User Domain Role invitations table instead. +

    +
    +
    + {{ 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/django/forms/label.html b/src/registrar/templates/django/forms/label.html index 545ccf781..3783c0fef 100644 --- a/src/registrar/templates/django/forms/label.html +++ b/src/registrar/templates/django/forms/label.html @@ -2,15 +2,25 @@ class="{% if label_classes %} {{ label_classes }}{% endif %}{% if label_tag == 'legend' %} {{ legend_classes }}{% endif %}" {% if not field.use_fieldset %}for="{{ widget.attrs.id }}"{% endif %} > - {% if span_for_text %} - {{ field.label }} + {% if legend_heading %} +

    {{ legend_heading }}

    + {% if widget.attrs.id == 'id_additional_details-has_cisa_representative' %} +

    .gov is managed by the Cybersecurity and Infrastructure Security Agency. CISA has 10 regions that some organizations choose to work with. Regional representatives use titles like protective security advisors, cyber security advisors, or election security advisors.

    + {% endif %} {% else %} - {{ field.label }} + {% if span_for_text %} + {{ field.label }} + {% else %} + {{ field.label }} + {% endif %} {% endif %} {% if widget.attrs.required %} - - {% if field.label == "Is your organization an election office?" or field.label == "What .gov domain do you want?" or field.label == "I read and agree to the requirements for operating a .gov domain." or field.label == "Please explain why there are no other employees from your organization we can contact to help us assess your eligibility for a .gov domain." %} + + {% if field.widget_type == 'radioselect' %} + Select one. * + + {% elif field.label == "Is your organization an election office?" or field.label == "What .gov domain do you want?" or field.label == "I read and agree to the requirements for operating a .gov domain." or field.label == "Please explain why there are no other employees from your organization we can contact to help us assess your eligibility for a .gov domain." or field.label == "Has other contacts" %} {% else %} * {% endif %} diff --git a/src/registrar/templates/django/forms/widgets/combobox.html b/src/registrar/templates/django/forms/widgets/combobox.html index 7ff31945b..02cd4e35e 100644 --- a/src/registrar/templates/django/forms/widgets/combobox.html +++ b/src/registrar/templates/django/forms/widgets/combobox.html @@ -11,6 +11,7 @@ for now we just carry the attribute to both the parent element and the select. {{ name }}="{{ value }}" {% endif %} {% endfor %} +data-default-value="{% for group_name, group_choices, group_index in widget.optgroups %}{% for option in group_choices %}{% if option.selected %}{{ option.value }}{% endif %}{% endfor %}{% endfor %}" > - {% include "django/forms/widgets/select.html" %} + {% include "django/forms/widgets/select.html" with is_combobox=True %}
diff --git a/src/registrar/templates/django/forms/widgets/multiple_input.html b/src/registrar/templates/django/forms/widgets/multiple_input.html index 90c241366..af98e898b 100644 --- a/src/registrar/templates/django/forms/widgets/multiple_input.html +++ b/src/registrar/templates/django/forms/widgets/multiple_input.html @@ -1,3 +1,5 @@ +{% load static custom_filters %} +
{% for group, options, index in widget.optgroups %} {% if group %}
{% endif %} @@ -13,7 +15,17 @@ + > + {{ option.label }} + {% comment %} Add a description on each, if available {% endcomment %} + {% if field and field.field and field.field.descriptions %} + {% with description=field.field.descriptions|get_dict_value:option.value %} + {% if description %} +

{{ description }}

+ {% endif %} + {% endwith %} + {% endif %} + {% endfor %} {% if group %}
{% endif %} {% endfor %} diff --git a/src/registrar/templates/django/forms/widgets/select.html b/src/registrar/templates/django/forms/widgets/select.html index cc62eb91d..db6deafe2 100644 --- a/src/registrar/templates/django/forms/widgets/select.html +++ b/src/registrar/templates/django/forms/widgets/select.html @@ -3,6 +3,9 @@ {# hint: spacing in the class string matters #} class="usa-select{% if classes %} {{ classes }}{% endif %}" {% include "django/forms/widgets/attrs.html" %} + {% if is_combobox %} + data-default-value="{% for group_name, group_choices, group_index in widget.optgroups %}{% for option in group_choices %}{% if option.selected %}{{ option.value }}{% endif %}{% endfor %}{% endfor %}" + {% endif %} > {% for group, options, index in widget.optgroups %} {% if group %}{% endif %} diff --git a/src/registrar/templates/domain_add_user.html b/src/registrar/templates/domain_add_user.html index 1429127e6..04565f61e 100644 --- a/src/registrar/templates/domain_add_user.html +++ b/src/registrar/templates/domain_add_user.html @@ -4,6 +4,9 @@ {% block title %}Add a domain manager | {% endblock %} {% block domain_content %} + + {% include "includes/form_errors.html" with form=form %} + {% block breadcrumb %} {% if portfolio %} @@ -37,6 +40,7 @@ {% endif %} {% endblock breadcrumb %} +

Add a domain manager

{% if has_organization_feature_flag %}

diff --git a/src/registrar/templates/domain_base.html b/src/registrar/templates/domain_base.html index 9f7e8d2e6..58038d0a4 100644 --- a/src/registrar/templates/domain_base.html +++ b/src/registrar/templates/domain_base.html @@ -1,15 +1,17 @@ {% extends "base.html" %} {% load static %} +{% load static url_helpers %} + {% block title %}{{ domain.name }} | {% endblock %} {% block content %} -

+
-
-
+
+

Domain name: {{ domain.name }}

@@ -24,7 +26,7 @@ {% if not domain.domain_info %}
-

Domain missing domain information

+

Domain missing domain information

You are attempting to manage a domain, {{ domain.name }}, which does not have a domain information object. Please correct this in the admin by editing the domain, and adding domain information, as appropriate.

@@ -34,7 +36,7 @@ {% if is_analyst_or_superuser and analyst_action == 'edit' and analyst_action_location == domain.pk %}
-

Attention!

+

Attention!

You are making changes to a registrant’s domain. When finished making changes, close this tab and inform the registrant of your updates.

@@ -44,7 +46,7 @@ {# messages block is under the back breadcrumb link #} {% if messages %} {% for message in messages %} -
+
{{ message }}
@@ -53,8 +55,11 @@ {% endif %} {% block domain_content %} - + {% if request.path|endswith:"renewal"%} +

Renew {{domain.name}}

+ {%else%}

Domain Overview

+ {% endif%} {% endblock %} {# domain_content #} {% endif %} @@ -62,4 +67,4 @@
-{% endblock %} {# content #} +{% endblock %} {# content #} \ No newline at end of file diff --git a/src/registrar/templates/domain_detail.html b/src/registrar/templates/domain_detail.html index add7ca725..03df2d59c 100644 --- a/src/registrar/templates/domain_detail.html +++ b/src/registrar/templates/domain_detail.html @@ -21,39 +21,51 @@ {{ block.super }}
-

{{ domain.name }}

+

{{ domain.name }}

-

- - Status: - - +

+ Status: {# UNKNOWN domains would not have an expiration date and thus would show 'Expired' #} {% if domain.is_expired and domain.state != domain.State.UNKNOWN %} Expired + {% elif has_domain_renewal_flag and domain.is_expiring %} + Expiring soon {% elif domain.state == domain.State.UNKNOWN or domain.state == domain.State.DNS_NEEDED %} DNS needed {% else %} - {{ domain.state|title }} + {{ domain.state|title }} {% endif %} - +

+ {% if domain.get_state_help_text %} -
- {{ domain.get_state_help_text }} -
+

+ {% if has_domain_renewal_flag and domain.is_expired and is_domain_manager %} + This domain has expired, but it is still online. + {% url 'domain-renewal' pk=domain.id as url %} + Renew to maintain access. + {% elif has_domain_renewal_flag and domain.is_expiring and is_domain_manager %} + This domain will expire soon. + {% url 'domain-renewal' pk=domain.id as url %} + Renew to maintain access. + {% elif has_domain_renewal_flag and domain.is_expiring and is_portfolio_user %} + This domain will expire soon. Contact one of the listed domain managers to renew the domain. + {% elif has_domain_renewal_flag and domain.is_expired and is_portfolio_user %} + This domain has expired, but it is still online. Contact one of the listed domain managers to renew the domain. + {% else %} + {{ domain.get_state_help_text }} + {% endif %} +

{% endif %} -

+
-
-
- +
{% include "includes/domain_dates.html" %} @@ -119,4 +131,4 @@ {% endif %}
-{% endblock %} {# domain_content #} +{% endblock %} {# domain_content #} \ No newline at end of file diff --git a/src/registrar/templates/domain_dnssec.html b/src/registrar/templates/domain_dnssec.html index cfec053c2..3beb2548e 100644 --- a/src/registrar/templates/domain_dnssec.html +++ b/src/registrar/templates/domain_dnssec.html @@ -27,7 +27,7 @@ {% endif %} {% endblock breadcrumb %} -

DNSSEC

+

DNSSEC

DNSSEC, or DNS Security Extensions, is an additional security layer to protect your website. Enabling DNSSEC ensures that when someone visits your domain, they can be certain that it’s connecting to the correct server, preventing potential hijacking or tampering with your domain's records.

@@ -35,21 +35,23 @@ {% csrf_token %} {% if has_dnssec_records %}
-

-

To fully disable DNSSEC

-
    -
  • Click “Disable DNSSEC” below.
  • -
  • Wait until the Time to Live (TTL) expires on your DNSSEC records managed by your DNS hosting provider. This is often less than 24 hours, but confirm with your provider.
  • -
  • After the TTL expiration, disable DNSSEC at your DNS hosting provider.
  • -
-

Warning: If you disable DNSSEC at your DNS hosting provider before TTL expiration, this may cause your domain to appear offline.

+

To fully disable DNSSEC

+ +
+
    +
  • Click “Disable DNSSEC” below.
  • +
  • Wait until the Time to Live (TTL) expires on your DNSSEC records managed by your DNS hosting provider. This is often less than 24 hours, but confirm with your provider.
  • +
  • After the TTL expiration, disable DNSSEC at your DNS hosting provider.
  • +
+

Warning: If you disable DNSSEC at your DNS hosting provider before TTL expiration, this may cause your domain to appear offline.

+
+

DNSSEC is enabled on your domain

@@ -78,7 +80,11 @@ aria-labelledby="Are you sure you want to continue?" aria-describedby="Your DNSSEC records will be deleted from the registry." > - {% include 'includes/modal.html' with modal_heading="Are you sure you want to disable DNSSEC?" modal_button=modal_button|safe %} + {% include 'includes/modal.html' with modal_heading="Are you sure you want to disable DNSSEC?" modal_button_id="disable-dnssec-button" modal_button_text="Confirm" modal_button_class="usa-button--secondary" %}
+
+ {% csrf_token %} + +
{% endblock %} {# domain_content #} diff --git a/src/registrar/templates/domain_dsdata.html b/src/registrar/templates/domain_dsdata.html index 0f60235e1..36eb811e3 100644 --- a/src/registrar/templates/domain_dsdata.html +++ b/src/registrar/templates/domain_dsdata.html @@ -5,6 +5,10 @@ {% block domain_content %} + {% for form in formset %} + {% include "includes/form_errors.html" with form=form %} + {% endfor %} + {% block breadcrumb %} {% if portfolio %} @@ -38,11 +42,7 @@
{% endif %} - {% for form in formset %} - {% include "includes/form_errors.html" with form=form %} - {% endfor %} - -

DS data

+

DS data

In order to enable DNSSEC, you must first configure it with your DNS hosting service.

@@ -141,7 +141,15 @@ aria-describedby="Your DNSSEC records will be deleted from the registry." data-force-action > - {% include 'includes/modal.html' with cancel_button_resets_ds_form=True modal_heading="Warning: You are about to remove all DS records on your domain." modal_description="To fully disable DNSSEC: In addition to removing your DS records here, you’ll need to delete the DS records at your DNS host. To avoid causing your domain to appear offline, you should wait to delete your DS records at your DNS host until the Time to Live (TTL) expires. This is often less than 24 hours, but confirm with your provider." modal_button=modal_button|safe %} + {% include 'includes/modal.html' with cancel_button_resets_ds_form=True modal_heading="Warning: You are about to remove all DS records on your domain." modal_description="To fully disable DNSSEC: In addition to removing your DS records here, you’ll need to delete the DS records at your DNS host. To avoid causing your domain to appear offline, you should wait to delete your DS records at your DNS host until the Time to Live (TTL) expires. This is often less than 24 hours, but confirm with your provider." modal_button_id="disable-override-click-button" modal_button_text="Remove all DS data" modal_button_class="usa-button--secondary" %}
+
+ {% csrf_token %} + +
+
+ {% csrf_token %} + +
{% endblock %} {# domain_content #} diff --git a/src/registrar/templates/domain_nameservers.html b/src/registrar/templates/domain_nameservers.html index a5fd171a2..ad8d61592 100644 --- a/src/registrar/templates/domain_nameservers.html +++ b/src/registrar/templates/domain_nameservers.html @@ -4,6 +4,12 @@ {% block title %}DNS name servers | {{ domain.name }} | {% endblock %} {% block domain_content %} + + {# this is right after the messages block in the parent template #} + {% for form in formset %} + {% include "includes/form_errors.html" with form=form %} + {% endfor %} + {% block breadcrumb %} {% if portfolio %} @@ -26,11 +32,6 @@ {% endif %} {% endblock breadcrumb %} - {# this is right after the messages block in the parent template #} - {% for form in formset %} - {% include "includes/form_errors.html" with form=form %} - {% endfor %} -

DNS name servers

Before your domain can be used we’ll need information about your domain name servers. Name server records indicate which DNS server is authoritative for your domain.

diff --git a/src/registrar/templates/domain_renewal.html b/src/registrar/templates/domain_renewal.html new file mode 100644 index 000000000..703c2358f --- /dev/null +++ b/src/registrar/templates/domain_renewal.html @@ -0,0 +1,138 @@ +{% extends "domain_base.html" %} +{% load static url_helpers %} +{% load custom_filters %} + +{% block domain_content %} + {% block breadcrumb %} + + + {% if form.is_policy_acknowledged.errors %} +
+
+ {% for error in form.is_policy_acknowledged.errors %} +

{{ error }}

+ {% endfor %} +
+
+ {% endif %} + + {% if portfolio %} + + + {% endif %} + {% endblock breadcrumb %} + + {{ block.super }} +
+

Confirm the following information for accuracy

+

Review the details below. We + require that you maintain accurate information for the domain. + The details you provide will only be used to support the administration of .gov and won't be made public. +

+

If you would like to retire your domain instead, please + contact us.

+

Required fields are marked with an asterisk (*). +

+ + + {% url 'user-profile' as url %} + {% include "includes/summary_item.html" with title='Your contact information' value=request.user edit_link=url editable=is_editable contact='true' %} + + {% if analyst_action != 'edit' or analyst_action_location != domain.pk %} + {% if is_portfolio_user and not is_domain_manager %} +
+
+

+ You don't have access to manage {{domain.name}}. If you need to make updates, contact one of the listed domain managers. +

+
+
+ {% endif %} + {% endif %} + + {% url 'domain-security-email' pk=domain.id as url %} + {% if security_email is not None and security_email not in hidden_security_emails%} + {% include "includes/summary_item.html" with title='Security email' value=security_email custom_text_for_value_none='We strongly recommend that you provide a security email. This email will allow the public to report observed or suspected security issues on your domain.' edit_link=url editable=is_editable %} + {% else %} + {% include "includes/summary_item.html" with title='Security email' value='None provided' custom_text_for_value_none='We strongly recommend that you provide a security email. This email will allow the public to report observed or suspected security issues on your domain.' edit_link=url editable=is_editable %} + {% endif %} + + {% url 'domain-users' pk=domain.id as url %} + {% if portfolio %} + {% include "includes/summary_item.html" with title='Domain managers' domain_permissions=True value=domain edit_link=url editable=is_editable %} + {% else %} + {% include "includes/summary_item.html" with title='Domain managers' list=True users=True value=domain.permissions.all edit_link=url editable=is_editable %} + {% endif %} + +
+ +
+ +

+ Acknowledgement of .gov domain requirements

+
+ +
+ {% csrf_token %} +
+ + {% if form.is_policy_acknowledged.errors %} + {% for error in form.is_policy_acknowledged.errors %} + + {% endfor %} +
+ {% endif %} + + + + + +
+ + + + +
+
+{% endblock %} {# domain_content #} \ No newline at end of file diff --git a/src/registrar/templates/domain_request_additional_details.html b/src/registrar/templates/domain_request_additional_details.html index 2a581bbd2..86fa79fa3 100644 --- a/src/registrar/templates/domain_request_additional_details.html +++ b/src/registrar/templates/domain_request_additional_details.html @@ -9,15 +9,9 @@ {% block form_fields %} -
- -

Are you working with a CISA regional representative on your domain request?

-

.gov is managed by the Cybersecurity and Infrastructure Security Agency. CISA has 10 regions that some organizations choose to work with. Regional representatives use titles like protective security advisors, cyber security advisors, or election security advisors.

-
- +
- Select one. * - {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} + {% with add_class="usa-radio__input--tile" add_legend_class="margin-top-0" add_legend_heading="Are you working with a CISA regional representative on your domain request?" %} {% input_with_errors forms.0.has_cisa_representative %} {% endwith %} {# forms.0 is a small yes/no form that toggles the visibility of "cisa representative" formset #} @@ -31,13 +25,8 @@
- -

Is there anything else you’d like us to know about your domain request?

-
- - Select one. * - {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} + {% with add_class="usa-radio__input--tile" add_legend_heading="Is there anything else you’d like us to know about your domain request?" %} {% input_with_errors forms.2.has_anything_else_text %} {% endwith %} {# forms.2 is a small yes/no form that toggles the visibility of "cisa representative" formset #} @@ -45,7 +34,7 @@

Provide details below. *

- {% with attr_maxlength=2000 add_label_class="usa-sr-only" %} + {% with attr_maxlength=2000 add_label_class="usa-sr-only" add_legend_class="usa-sr-only" add_legend_heading="Is there anything else you’d like us to know about your domain request?" add_aria_label="Provide details below. You can enter up to 2000 characters" %} {% input_with_errors forms.3.anything_else %} {% endwith %} {# forms.3 is a form for inputting the e-mail of a cisa representative #} diff --git a/src/registrar/templates/domain_request_current_sites.html b/src/registrar/templates/domain_request_current_sites.html index 2a2ac6885..769906309 100644 --- a/src/registrar/templates/domain_request_current_sites.html +++ b/src/registrar/templates/domain_request_current_sites.html @@ -3,8 +3,8 @@ {% block form_instructions %}

We can better evaluate your request if we know about domains you’re already using.

-

What are the current websites for your organization?

-

Enter your organization’s current public websites. If you already have a .gov domain, include that in your list. This question is optional.

+

What are the current websites for your organization?

+

Enter your organization’s current public websites. If you already have a .gov domain, include that in your list. This question is optional.

{% endblock %} {% block form_required_fields_help_text %} @@ -20,7 +20,7 @@ {% endwith %} {% endfor %} -

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_other_contacts.html b/src/registrar/templates/domain_request_other_contacts.html index 72e4abd8b..65641bf4c 100644 --- a/src/registrar/templates/domain_request_other_contacts.html +++ b/src/registrar/templates/domain_request_other_contacts.html @@ -9,7 +9,7 @@
  • We typically don’t reach out to these employees, but if contact is necessary, our practice is to coordinate with you first.
  • - + {% include "includes/required_fields.html" %} {% endblock %} {% block form_required_fields_help_text %} @@ -17,33 +17,27 @@ {% endblock %} {% block form_fields %} -
    - -

    Are there other employees who can help verify your request?

    -
    - - {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} +
    + {% with add_class="usa-radio__input--tile" add_legend_heading="Are there other employees who can help verify your request?" %} {% input_with_errors forms.0.has_other_contacts %} {% endwith %} {# forms.0 is a small yes/no form that toggles the visibility of "other contact" formset #} - -
    +
    - {% include "includes/required_fields.html" %} {{ forms.1.management_form }} {# forms.1 is a formset and this iterates over its forms #} {% for form in forms.1.forms %}
    -

    Organization contact {{ forloop.counter }}

    +

    Organization contact {{ forloop.counter }}

    - @@ -76,7 +70,7 @@
    {% endfor %} -
    +
    diff --git a/src/registrar/templates/includes/member_permissions_summary.html b/src/registrar/templates/includes/member_permissions_summary.html new file mode 100644 index 000000000..3a91d16f6 --- /dev/null +++ b/src/registrar/templates/includes/member_permissions_summary.html @@ -0,0 +1,33 @@ +

    Member access

    +{% if permissions.roles and 'organization_admin' in permissions.roles %} +

    Admin

    +{% elif permissions.roles and 'organization_member' in permissions.roles %} +

    Basic

    +{% else %} +

    +{% endif %} + +

    Domains

    +{% if member_has_view_all_domains_portfolio_permission %} +

    Viewer, all

    +{% else %} +

    Viewer, limited

    +{% endif %} + +

    Domain requests

    +{% if member_has_edit_request_portfolio_permission %} +

    Creator

    +{% elif member_has_view_all_requests_portfolio_permission %} +

    Viewer

    +{% else %} +

    No access

    +{% endif %} + +

    Members

    +{% if member_has_edit_members_portfolio_permission %} +

    Manager

    +{% elif member_has_view_members_portfolio_permission %} +

    Viewer

    +{% else %} +

    No access

    +{% endif %} \ No newline at end of file diff --git a/src/registrar/templates/includes/members_table.html b/src/registrar/templates/includes/members_table.html index d141dbaa6..6c1e6fd44 100644 --- a/src/registrar/templates/includes/members_table.html +++ b/src/registrar/templates/includes/members_table.html @@ -8,7 +8,7 @@
    -

    -
      +
      • Full name: {{ user.get_formatted_name }}
      • Organization email: {{ user.email }}
      • Title or role in your organization: {{ user.title }}
      • diff --git a/src/registrar/templates/includes/request_review_steps.html b/src/registrar/templates/includes/request_review_steps.html index 73b71d536..6151d01a8 100644 --- a/src/registrar/templates/includes/request_review_steps.html +++ b/src/registrar/templates/includes/request_review_steps.html @@ -88,7 +88,7 @@ {% endwith %} {% if domain_request.alternative_domains.all %} -

        Alternative domains

        +

        Alternative domains

          {% for site in domain_request.alternative_domains.all %}
        • {{ site.website }}
        • @@ -132,8 +132,8 @@ {% with title=form_titles|get_item:step %} {% if domain_request.has_additional_details %} {% include "includes/summary_item.html" with title="Additional Details" value=" " heading_level=heading_level editable=is_editable edit_link=domain_request_url %} -

          CISA Regional Representative

          -
            +

            CISA Regional Representative

            +
              {% if domain_request.cisa_representative_first_name %}
            • {{domain_request.cisa_representative_first_name}} {{domain_request.cisa_representative_last_name}}
            • {% if domain_request.cisa_representative_email %} @@ -144,8 +144,8 @@ {% endif %}
            -

            Anything else

            -
              +

              Anything else

              +
                {% if domain_request.anything_else %} {{domain_request.anything_else}} {% else %} diff --git a/src/registrar/templates/includes/request_status_manage.html b/src/registrar/templates/includes/request_status_manage.html index fc2fd8f12..d96adedf6 100644 --- a/src/registrar/templates/includes/request_status_manage.html +++ b/src/registrar/templates/includes/request_status_manage.html @@ -1,222 +1,203 @@ {% 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" %} -

                + {% 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 %} - 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. + 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 %} - 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 %} - {% if portfolio %} - {% include "includes/portfolio_request_review_steps.html" with is_editable=False domain_request=DomainRequest %} - {% else %} - {% 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 %} + +

                + {% 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 %} - {% if DomainRequest.organization_name %} - {% include "includes/summary_item.html" with title='Organization' value=DomainRequest address='true' heading_level=heading_level %} + {% 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 %} - {% if DomainRequest.about_your_organization %} - {% include "includes/summary_item.html" with title='About your organization' value=DomainRequest.about_your_organization heading_level=heading_level %} + {% block modify_request %} + {% if DomainRequest.is_withdrawable %} +

                + Withdraw request +

                {% endif %} + {% endblock modify_request %} +
                - {% if DomainRequest.senior_official %} - {% include "includes/summary_item.html" with title='Senior official' value=DomainRequest.senior_official contact='true' heading_level=heading_level %} - {% endif %} +
                + {% block request_summary_header %} +

                Summary of your domain request

                + {% endblock request_summary_header%} - {% 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 %} + {% block request_summary %} + {% if portfolio %} + {% include "includes/portfolio_request_review_steps.html" with is_editable=False domain_request=DomainRequest %} {% else %} - {% include "includes/summary_item.html" with title='Other employees from your organization' value=DomainRequest.no_other_contacts_rationale heading_level=heading_level %} - {% endif %} + {% with org_type=DomainRequest.get_generic_org_type_display %} + {% include "includes/summary_item.html" with title='Type of organization' value=org_type %} + {% endwith %} + + {% if DomainRequest.tribe_name %} + {% include "includes/summary_item.html" with title='Tribal government' value=DomainRequest.tribe_name %} + + {% 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 %} + {% 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 %} + {% endwith %} + {% endif %} + + {% if DomainRequest.organization_name %} + {% include "includes/summary_item.html" with title='Organization' value=DomainRequest address='true' %} + {% endif %} + + {% if DomainRequest.about_your_organization %} + {% include "includes/summary_item.html" with title='About your organization' value=DomainRequest.about_your_organization %} + {% endif %} + + {% if DomainRequest.senior_official %} + {% include "includes/summary_item.html" with title='Senior official' value=DomainRequest.senior_official contact='true' %} + {% endif %} + + {% if DomainRequest.current_websites.all %} + {% include "includes/summary_item.html" with title='Current websites' value=DomainRequest.current_websites.all list='true' %} + {% endif %} + + {% if DomainRequest.requested_domain %} + {% include "includes/summary_item.html" with title='.gov domain' value=DomainRequest.requested_domain %} + {% endif %} + + {% if DomainRequest.alternative_domains.all %} + {% include "includes/summary_item.html" with title='Alternative domains' value=DomainRequest.alternative_domains.all list='true' %} + {% endif %} + + {% if DomainRequest.purpose %} + {% include "includes/summary_item.html" with title='Purpose of your domain' value=DomainRequest.purpose %} + {% endif %} + + {% if DomainRequest.creator %} + {% include "includes/summary_item.html" with title='Your contact information' value=DomainRequest.creator contact='true' %} + {% 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' %} + {% else %} + {% include "includes/summary_item.html" with title='Other employees from your organization' value=DomainRequest.no_other_contacts_rationale %} + {% endif %} {# We always show this field even if None #} {% if DomainRequest %} -

                CISA Regional Representative

                +

                CISA Regional Representative

                  {% if DomainRequest.cisa_representative_first_name %} {{ DomainRequest.get_formatted_cisa_rep_name }} @@ -224,7 +205,7 @@ No {% endif %}
                -

                Anything else

                +

                Anything else

                  {% if DomainRequest.anything_else %} {{DomainRequest.anything_else}} @@ -233,8 +214,8 @@ {% endif %}
                {% endif %} - {% endwith %} {% endif %} {% endblock request_summary%} +
    \ 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 0302bc71f..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 %} @@ -26,7 +30,6 @@ {% elif not form.full_name.value and not form.title.value and not form.email.value %}

    - Your senior official is a person within your organization who can authorize domain requests. We don't have information about your organization's senior official. To suggest an update, email help@get.gov.

    {% else %} diff --git a/src/registrar/templates/includes/summary_item.html b/src/registrar/templates/includes/summary_item.html index 15cc0f67f..26e56fea7 100644 --- a/src/registrar/templates/includes/summary_item.html +++ b/src/registrar/templates/includes/summary_item.html @@ -9,10 +9,7 @@ {% else %}

    {{ title }} @@ -22,10 +19,10 @@

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

    {{ sub_header_text }}

    +

    {{ sub_header_text }}

    {% endif %} {% if permissions %} - {% include "includes/member_permissions.html" with permissions=value %} + {% include "includes/member_permissions_summary.html" with permissions=value %} {% elif domain_mgmt %} {% include "includes/member_domain_management.html" with domain_count=value %} {% elif address %} @@ -40,9 +37,7 @@ {% for item in value %}
    -

    Contact {{forloop.counter}} @@ -119,7 +114,7 @@ {% endif %} {% endif %} {% if value.invitations.all %} -

    Invited domain managers

    +

    Invited domain managers

      {% for item in value.invitations.all %}
    • {{ item.email }}
    • @@ -127,15 +122,15 @@
    {% endif %} {% else %} -

    + {% if custom_text_for_value_none %} +

    {{ custom_text_for_value_none }}

    + {% endif %} {% if value %} {{ value }} - {% elif custom_text_for_value_none %} - {{ custom_text_for_value_none }} - {% else %} + {% endif %} + {% if not value %} None {% endif %} -

    {% endif %}
    @@ -143,7 +138,7 @@
    {% block content %} -
    +
    {% if user.is_authenticated %} {# the entire logged in page goes here #} - +
    {% endblock content%} diff --git a/src/registrar/templates/portfolio_domains.html b/src/registrar/templates/portfolio_domains.html index dde51ea59..55d807e78 100644 --- a/src/registrar/templates/portfolio_domains.html +++ b/src/registrar/templates/portfolio_domains.html @@ -9,12 +9,14 @@ {% endblock %} {% block portfolio_content %} + + {% block messages %} {% include "includes/form_messages.html" %} -{% endblock %} +{% endblock messages%}

    Domains

    - {% include "includes/domains_table.html" with portfolio=portfolio user_domain_count=user_domain_count %} + {% include "includes/domains_table.html" with portfolio=portfolio user_domain_count=user_domain_count num_expiring_domains=num_expiring_domains%}
    -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/src/registrar/templates/portfolio_member.html b/src/registrar/templates/portfolio_member.html index f492dbd2f..709582e71 100644 --- a/src/registrar/templates/portfolio_member.html +++ b/src/registrar/templates/portfolio_member.html @@ -8,10 +8,15 @@ 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 %} -

    +
    +

    + .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/templatetags/custom_filters.py b/src/registrar/templatetags/custom_filters.py index e88830156..d21678d58 100644 --- a/src/registrar/templatetags/custom_filters.py +++ b/src/registrar/templatetags/custom_filters.py @@ -200,6 +200,7 @@ def is_domain_subpage(path): "domain-users-add", "domain-request-delete", "domain-user-delete", + "domain-renewal", "invitation-cancel", ] return get_url_name(path) in url_names @@ -282,3 +283,17 @@ def display_requesting_entity(domain_request): ) return display + + +@register.filter +def get_dict_value(dictionary, key): + """Get a value from a dictionary. Returns a string on empty.""" + if isinstance(dictionary, dict): + return dictionary.get(key, "") + return "" + + +@register.filter +def button_class(custom_class): + default_class = "usa-button" + return f"{default_class} {custom_class}" if custom_class else default_class diff --git a/src/registrar/templatetags/field_helpers.py b/src/registrar/templatetags/field_helpers.py index d0f85a231..28a4d4556 100644 --- a/src/registrar/templatetags/field_helpers.py +++ b/src/registrar/templatetags/field_helpers.py @@ -57,6 +57,7 @@ def input_with_errors(context, field=None): # noqa: C901 legend_classes = [] group_classes = [] aria_labels = [] + legend_headings = [] sublabel_text = [] # this will be converted to an attribute string @@ -91,6 +92,8 @@ def input_with_errors(context, field=None): # noqa: C901 label_classes.append(value) elif key == "add_legend_class": legend_classes.append(value) + elif key == "add_legend_heading": + legend_headings.append(value) elif key == "add_group_class": group_classes.append(value) @@ -120,9 +123,6 @@ def input_with_errors(context, field=None): # noqa: C901 else: context["label_tag"] = "label" - if field.use_fieldset: - label_classes.append("usa-legend") - if field.widget_type == "checkbox": label_classes.append("usa-checkbox__label") elif not field.use_fieldset: @@ -153,6 +153,9 @@ def input_with_errors(context, field=None): # noqa: C901 if legend_classes: context["legend_classes"] = " ".join(legend_classes) + if legend_headings: + context["legend_heading"] = " ".join(legend_headings) + if group_classes: context["group_classes"] = " ".join(group_classes) diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index af4345a14..bb65ef6b1 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -13,6 +13,7 @@ from django.contrib.auth import get_user_model, login from django.utils.timezone import make_aware from datetime import date, datetime, timedelta from django.utils import timezone +from django.utils.html import strip_spaces_between_tags from registrar.models import ( Contact, @@ -39,6 +40,7 @@ from epplibwrapper import ( ErrorCode, responses, ) +from registrar.models.suborganization import Suborganization from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.models.user_domain_role import UserDomainRole @@ -107,6 +109,11 @@ def get_time_aware_date(date=datetime(2023, 11, 1)): return timezone.make_aware(date) +def normalize_html(html): + """Normalize HTML by removing newlines and extra spaces.""" + return strip_spaces_between_tags(" ".join(html.split())) + + class GenericTestHelper(TestCase): """A helper class that contains various helper functions for TestCases""" @@ -571,6 +578,13 @@ class MockDb(TestCase): creator=cls.custom_superuser, federal_agency=cls.federal_agency_3, organization_type="federal" ) + cls.suborganization_1, _ = Suborganization.objects.get_or_create( + name="SubOrg 1", + portfolio=cls.portfolio_1, + city="Nashville", + state_territory="TN", + ) + current_date = get_time_aware_date(datetime(2024, 4, 2)) # Create start and end dates using timedelta @@ -841,6 +855,7 @@ class MockDb(TestCase): status=DomainRequest.DomainRequestStatus.IN_REVIEW, name="city2.gov", portfolio=cls.portfolio_1, + sub_organization=cls.suborganization_1, ) cls.domain_request_3 = completed_domain_request( status=DomainRequest.DomainRequestStatus.STARTED, @@ -856,6 +871,9 @@ class MockDb(TestCase): cls.domain_request_5 = completed_domain_request( status=DomainRequest.DomainRequestStatus.APPROVED, name="city5.gov", + requested_suborganization="requested_suborg", + suborganization_city="SanFran", + suborganization_state_territory="CA", ) cls.domain_request_6 = completed_domain_request( status=DomainRequest.DomainRequestStatus.STARTED, @@ -905,6 +923,7 @@ class MockDb(TestCase): DomainInformation.objects.all().delete() DomainRequest.objects.all().delete() UserDomainRole.objects.all().delete() + Suborganization.objects.all().delete() Portfolio.objects.all().delete() UserPortfolioPermission.objects.all().delete() User.objects.all().delete() @@ -1017,8 +1036,9 @@ def create_ready_domain(): # TODO in 1793: Remove the federal agency/updated federal agency fields def completed_domain_request( # noqa has_other_contacts=True, - has_current_website=True, - has_alternative_gov_domain=True, + # pass empty [] if you want current_websites or alternative_domains set to None + current_websites=["city.com"], + alternative_domains=["city1.gov"], has_about_your_organization=True, has_anything_else=True, has_cisa_representative=True, @@ -1032,8 +1052,14 @@ def completed_domain_request( # noqa federal_agency=None, federal_type=None, action_needed_reason=None, + city=None, + state_territory=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: @@ -1046,8 +1072,6 @@ def completed_domain_request( # noqa phone="(555) 555 5555", ) domain, _ = DraftDomain.objects.get_or_create(name=name) - alt, _ = Website.objects.get_or_create(website="city1.gov") - current, _ = Website.objects.get_or_create(website="city.com") other, _ = Contact.objects.get_or_create( first_name="Testy", last_name="Tester", @@ -1072,7 +1096,7 @@ def completed_domain_request( # noqa organization_name=organization_name if organization_name else "Testorg", address_line1="address 1", address_line2="address 2", - state_territory="NY", + state_territory="NY" if not state_territory else state_territory, zipcode="10002", senior_official=so, requested_domain=domain, @@ -1081,6 +1105,10 @@ def completed_domain_request( # noqa investigator=investigator, federal_agency=federal_agency, ) + + if city: + domain_request_kwargs["city"] = city + if has_about_your_organization: domain_request_kwargs["about_your_organization"] = "e-Government" if has_anything_else: @@ -1098,14 +1126,30 @@ 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: domain_request.other_contacts.add(other) - if has_current_website: - domain_request.current_websites.add(current) - if has_alternative_gov_domain: - domain_request.alternative_domains.add(alt) + if len(current_websites) > 0: + for website in current_websites: + current, _ = Website.objects.get_or_create(website=website) + domain_request.current_websites.add(current) + if len(alternative_domains) > 0: + for alternative_domain in alternative_domains: + alt, _ = Website.objects.get_or_create(website=alternative_domain) + domain_request.alternative_domains.add(alt) if has_cisa_representative: domain_request.cisa_representative_first_name = "CISA-first-name" domain_request.cisa_representative_last_name = "CISA-last-name" diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index a259e5bef..387319663 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 @@ -26,7 +28,6 @@ from registrar.admin import ( TransitionDomainAdmin, UserGroupAdmin, PortfolioAdmin, - UserPortfolioPermissionAdmin, ) from registrar.models import ( Domain, @@ -67,9 +68,7 @@ from django.contrib.sessions.backends.db import SessionStore from django.contrib.auth import get_user_model from django.contrib import messages -from unittest.mock import ANY, patch, Mock -from django.forms import ValidationError - +from unittest.mock import ANY, call, patch, Mock import logging @@ -129,23 +128,24 @@ class TestDomainInvitationAdmin(TestCase): tests have available superuser, client, and admin """ - @classmethod - def setUpClass(cls): - cls.factory = RequestFactory() - cls.admin = ListHeaderAdmin(model=DomainInvitationAdmin, admin_site=AdminSite()) - cls.superuser = create_superuser() - def setUp(self): + self.factory = RequestFactory() + self.admin = ListHeaderAdmin(model=DomainInvitationAdmin, admin_site=AdminSite()) + self.superuser = create_superuser() + self.domain = Domain.objects.create(name="example.com") + self.portfolio = Portfolio.objects.create(organization_name="new portfolio", creator=self.superuser) + DomainInformation.objects.create(domain=self.domain, portfolio=self.portfolio, creator=self.superuser) """Create a client object""" self.client = Client(HTTP_HOST="localhost:8080") def tearDown(self): """Delete all DomainInvitation objects""" + PortfolioInvitation.objects.all().delete() DomainInvitation.objects.all().delete() + DomainInformation.objects.all().delete() + Portfolio.objects.all().delete() + Domain.objects.all().delete() Contact.objects.all().delete() - - @classmethod - def tearDownClass(self): User.objects.all().delete() @less_console_noise_decorator @@ -166,6 +166,30 @@ class TestDomainInvitationAdmin(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) + + domain, _ = Domain.objects.get_or_create(name="systemofadown.com") + + domain_invitation, _ = DomainInvitation.objects.get_or_create(email="toxicity@systemofadown.com", domain=domain) + + response = self.client.get( + "/admin/registrar/domaininvitation/{}/change/".format(domain_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 domain here, it will trigger emails to the invitee and all managers of the domain", + ) + + @less_console_noise_decorator def test_get_filters(self): """Ensures that our filters are displaying correctly""" with less_console_noise(): @@ -190,14 +214,863 @@ class TestDomainInvitationAdmin(TestCase): self.assertContains(response, invited_html, count=1) self.assertContains(response, retrieved_html, count=1) + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @patch("registrar.admin.send_domain_invitation_email") + @patch("registrar.admin.send_portfolio_invitation_email") + @patch("django.contrib.messages.success") + def test_add_domain_invitation_success_when_user_not_portfolio_member( + self, mock_messages_success, mock_send_portfolio_email, mock_send_domain_email + ): + """Test saving a domain invitation when the user exists and is not a portfolio member. + + Should send out domain and portfolio invites. + Should trigger success messages for both email sends. + Should attempt to retrieve the domain invitation. + Should attempt to retrieve the portfolio invitation.""" + + user = User.objects.create_user(email="test@example.com", username="username") + + # Create a domain invitation instance + invitation = DomainInvitation(email="test@example.com", domain=self.domain) + + admin_instance = DomainInvitationAdmin(DomainInvitation, admin_site=None) + + # Create a request object + request = self.factory.post("/admin/registrar/DomainInvitation/add/") + request.user = self.superuser + + admin_instance.save_model(request, invitation, form=None, change=False) + + # Assert sends appropriate emails - domain and portfolio invites + mock_send_domain_email.assert_called_once_with( + email="test@example.com", + requestor=self.superuser, + domains=self.domain, + is_member_of_different_org=None, + requested_user=user, + ) + mock_send_portfolio_email.assert_called_once_with( + email="test@example.com", + requestor=self.superuser, + portfolio=self.portfolio, + ) + + # Assert success message + mock_messages_success.assert_has_calls( + [ + call(request, "test@example.com has been invited to the organization: new portfolio"), + call(request, "test@example.com has been invited to the domain: example.com"), + ] + ) + + # Assert the invitations were saved + self.assertEqual(DomainInvitation.objects.count(), 1) + self.assertEqual(DomainInvitation.objects.first().email, "test@example.com") + self.assertEqual(PortfolioInvitation.objects.count(), 1) + self.assertEqual(PortfolioInvitation.objects.first().email, "test@example.com") + + # Assert invitations were retrieved + domain_invitation = DomainInvitation.objects.get(email=user.email, domain=self.domain) + portfolio_invitation = PortfolioInvitation.objects.get(email=user.email, portfolio=self.portfolio) + + self.assertEqual(domain_invitation.status, DomainInvitation.DomainInvitationStatus.RETRIEVED) + self.assertEqual(portfolio_invitation.status, PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED) + self.assertEqual(UserDomainRole.objects.count(), 1) + self.assertEqual(UserDomainRole.objects.first().user, user) + self.assertEqual(UserPortfolioPermission.objects.count(), 1) + self.assertEqual(UserPortfolioPermission.objects.first().user, user) + + @less_console_noise_decorator + @override_flag("organization_feature", active=False) + @patch("registrar.admin.send_domain_invitation_email") + @patch("registrar.admin.send_portfolio_invitation_email") + @patch("django.contrib.messages.success") + def test_add_domain_invitation_success_when_user_not_portfolio_member_and_organization_feature_off( + self, mock_messages_success, mock_send_portfolio_email, mock_send_domain_email + ): + """Test saving a domain invitation when the user exists and organization_feature flag is off. + + Should send out a domain invitation. + Should not send a out portfolio invitation. + Should trigger success message for the domain invitation. + Should retrieve the domain invitation. + Should not create a portfolio invitation.""" + + user = User.objects.create_user(email="test@example.com", username="username") + + # Create a domain invitation instance + invitation = DomainInvitation(email="test@example.com", domain=self.domain) + + admin_instance = DomainInvitationAdmin(DomainInvitation, admin_site=None) + + # Create a request object + request = self.factory.post("/admin/registrar/DomainInvitation/add/") + request.user = self.superuser + + admin_instance.save_model(request, invitation, form=None, change=False) + + # Assert sends appropriate emails - domain but not portfolio + mock_send_domain_email.assert_called_once_with( + email="test@example.com", + requestor=self.superuser, + domains=self.domain, + is_member_of_different_org=None, + requested_user=user, + ) + mock_send_portfolio_email.assert_not_called() + + # Assert correct invite was created + self.assertEqual(DomainInvitation.objects.count(), 1) + self.assertEqual(PortfolioInvitation.objects.count(), 0) + + # Assert success message + mock_messages_success.assert_called_once_with( + request, "test@example.com has been invited to the domain: example.com" + ) + + # Assert the domain invitation was saved + self.assertEqual(DomainInvitation.objects.count(), 1) + self.assertEqual(DomainInvitation.objects.first().email, "test@example.com") + self.assertEqual(PortfolioInvitation.objects.count(), 0) + + # Assert the domain invitation was retrieved + domain_invitation = DomainInvitation.objects.get(email=user.email, domain=self.domain) + + self.assertEqual(domain_invitation.status, DomainInvitation.DomainInvitationStatus.RETRIEVED) + self.assertEqual(UserDomainRole.objects.count(), 1) + self.assertEqual(UserDomainRole.objects.first().user, user) + self.assertEqual(UserPortfolioPermission.objects.count(), 0) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("multiple_portfolios", active=True) + @patch("registrar.admin.send_domain_invitation_email") + @patch("registrar.admin.send_portfolio_invitation_email") + @patch("django.contrib.messages.success") + def test_add_domain_invitation_success_when_user_not_portfolio_member_and_multiple_portfolio_feature_on( + self, mock_messages_success, mock_send_portfolio_email, mock_send_domain_email + ): + """Test saving a domain invitation when the user exists and multiple_portfolio flag is on. + + Should send out a domain invitation. + Should not send a out portfolio invitation. + Should trigger success message for the domain invitation. + Should retrieve the domain invitation. + Should not create a portfolio invitation. + + NOTE: This test may need to be reworked when the multiple_portfolio flag is fully fleshed out. + """ + + user = User.objects.create_user(email="test@example.com", username="username") + + # Create a domain invitation instance + invitation = DomainInvitation(email="test@example.com", domain=self.domain) + + admin_instance = DomainInvitationAdmin(DomainInvitation, admin_site=None) + + # Create a request object + request = self.factory.post("/admin/registrar/DomainInvitation/add/") + request.user = self.superuser + + admin_instance.save_model(request, invitation, form=None, change=False) + + # Assert sends appropriate emails - domain but not portfolio + mock_send_domain_email.assert_called_once_with( + email="test@example.com", + requestor=self.superuser, + domains=self.domain, + is_member_of_different_org=None, + requested_user=user, + ) + mock_send_portfolio_email.assert_not_called() + + # Assert correct invite was created + self.assertEqual(DomainInvitation.objects.count(), 1) + self.assertEqual(PortfolioInvitation.objects.count(), 0) + + # Assert success message + mock_messages_success.assert_called_once_with( + request, "test@example.com has been invited to the domain: example.com" + ) + + # Assert the domain invitation was saved + self.assertEqual(DomainInvitation.objects.count(), 1) + self.assertEqual(DomainInvitation.objects.first().email, "test@example.com") + self.assertEqual(PortfolioInvitation.objects.count(), 0) + + # Assert the domain invitation was retrieved + domain_invitation = DomainInvitation.objects.get(email=user.email, domain=self.domain) + + self.assertEqual(domain_invitation.status, DomainInvitation.DomainInvitationStatus.RETRIEVED) + self.assertEqual(UserDomainRole.objects.count(), 1) + self.assertEqual(UserDomainRole.objects.first().user, user) + self.assertEqual(UserPortfolioPermission.objects.count(), 0) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @patch("registrar.admin.send_domain_invitation_email") + @patch("registrar.admin.send_portfolio_invitation_email") + @patch("django.contrib.messages.success") + def test_add_domain_invitation_success_when_user_existing_portfolio_member( + self, mock_messages_success, mock_send_portfolio_email, mock_send_domain_email + ): + """Test saving a domain invitation when the user exists and a portfolio invitation exists. + + Should send out domain invitation only. + Should trigger success message for the domain invitation. + Should retrieve the domain invitation.""" + + user = User.objects.create_user(email="test@example.com", username="username") + + # Create a domain invitation instance + invitation = DomainInvitation(email="test@example.com", domain=self.domain) + + UserPortfolioPermission.objects.create( + user=user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER] + ) + + admin_instance = DomainInvitationAdmin(DomainInvitation, admin_site=None) + + # Create a request object + request = self.factory.post("/admin/registrar/DomainInvitation/add/") + request.user = self.superuser + + # Patch the retrieve method to ensure it is not called + with patch.object(DomainInvitation, "retrieve") as domain_invitation_mock_retrieve: + with patch.object(PortfolioInvitation, "retrieve") as portfolio_invitation_mock_retrieve: + admin_instance.save_model(request, invitation, form=None, change=False) + + # Assert sends appropriate emails - domain and portfolio invites + mock_send_domain_email.assert_called_once_with( + email="test@example.com", + requestor=self.superuser, + domains=self.domain, + is_member_of_different_org=None, + requested_user=user, + ) + mock_send_portfolio_email.assert_not_called + + # Assert retrieve was not called + domain_invitation_mock_retrieve.assert_called_once() + portfolio_invitation_mock_retrieve.assert_not_called() + + # Assert success message + mock_messages_success.assert_called_once_with( + request, "test@example.com has been invited to the domain: example.com" + ) + + # Assert the invitations were saved + self.assertEqual(DomainInvitation.objects.count(), 1) + self.assertEqual(DomainInvitation.objects.first().email, "test@example.com") + self.assertEqual(PortfolioInvitation.objects.count(), 0) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @patch("registrar.admin.send_domain_invitation_email") + @patch("registrar.admin.send_portfolio_invitation_email") + @patch("django.contrib.messages.error") + def test_add_domain_invitation_when_user_not_portfolio_member_raises_exception_sending_portfolio_email( + self, mock_messages_error, mock_send_portfolio_email, mock_send_domain_email + ): + """Test saving a domain invitation when the user exists and is not a portfolio member raises + sending portfolio email exception. + + Should only attempt to send the portfolio invitation. + Should trigger error message on portfolio invitation. + Should not attempt to retrieve the domain invitation.""" + + mock_send_portfolio_email.side_effect = MissingEmailError("craving a burger") + + User.objects.create_user(email="test@example.com", username="username") + + # Create a domain invitation instance + invitation = DomainInvitation(email="test@example.com", domain=self.domain) + + admin_instance = DomainInvitationAdmin(DomainInvitation, admin_site=None) + + # Create a request object + request = self.factory.post("/admin/registrar/DomainInvitation/add/") + request.user = self.superuser + + # Patch the retrieve method to ensure it is not called + with patch.object(DomainInvitation, "retrieve") as domain_invitation_mock_retrieve: + with patch.object(PortfolioInvitation, "retrieve") as portfolio_invitation_mock_retrieve: + admin_instance.save_model(request, invitation, form=None, change=False) + + # Assert sends appropriate emails - domain and portfolio invites + mock_send_domain_email.assert_not_called() + mock_send_portfolio_email.assert_called_once_with( + email="test@example.com", + requestor=self.superuser, + portfolio=self.portfolio, + ) + + # Assert retrieve on domain invite only was called + domain_invitation_mock_retrieve.assert_not_called() + portfolio_invitation_mock_retrieve.assert_not_called() + + # Assert error message + mock_messages_error.assert_called_once_with( + request, "Can't send invitation email. No email is associated with your user account." + ) + + # Assert the invitations were saved + self.assertEqual(DomainInvitation.objects.count(), 0) + self.assertEqual(PortfolioInvitation.objects.count(), 0) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @patch("registrar.admin.send_domain_invitation_email") + @patch("registrar.admin.send_portfolio_invitation_email") + @patch("django.contrib.messages.success") + @patch("django.contrib.messages.error") + def test_add_domain_invitation_when_user_not_portfolio_member_raises_exception_sending_domain_email( + self, mock_messages_error, mock_messages_success, mock_send_portfolio_email, mock_send_domain_email + ): + """Test saving a domain invitation when the user exists and is not a portfolio member raises + sending domain email exception. + + Should send out the portfolio invitation and attempt to send the domain invitation. + Should trigger portfolio invitation success message. + Should trigger domain invitation error message. + Should not attempt to retrieve the domain invitation. + Should attempt to retrieve the portfolio invitation.""" + + mock_send_domain_email.side_effect = MissingEmailError("craving a burger") + + user = User.objects.create_user(email="test@example.com", username="username") + + # Create a domain invitation instance + invitation = DomainInvitation(email="test@example.com", domain=self.domain) + + admin_instance = DomainInvitationAdmin(DomainInvitation, admin_site=None) + + # Create a request object + request = self.factory.post("/admin/registrar/DomainInvitation/add/") + request.user = self.superuser + + # Patch the retrieve method to ensure it is not called + with patch.object(DomainInvitation, "retrieve") as domain_invitation_mock_retrieve: + with patch.object(PortfolioInvitation, "retrieve") as portfolio_invitation_mock_retrieve: + admin_instance.save_model(request, invitation, form=None, change=False) + + # Assert sends appropriate emails - domain and portfolio invites + mock_send_domain_email.assert_called_once_with( + email="test@example.com", + requestor=self.superuser, + domains=self.domain, + is_member_of_different_org=None, + requested_user=user, + ) + mock_send_portfolio_email.assert_called_once_with( + email="test@example.com", + requestor=self.superuser, + portfolio=self.portfolio, + ) + + # Assert retrieve on domain invite only was called + domain_invitation_mock_retrieve.assert_not_called() + portfolio_invitation_mock_retrieve.assert_called_once() + + # Assert success message + mock_messages_success.assert_called_once_with( + request, "test@example.com has been invited to the organization: new portfolio" + ) + + # Assert error message + mock_messages_error.assert_called_once_with( + request, "Can't send invitation email. No email is associated with your user account." + ) + + # Assert the invitations were saved + self.assertEqual(DomainInvitation.objects.count(), 0) + self.assertEqual(PortfolioInvitation.objects.count(), 1) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @patch("registrar.admin.send_domain_invitation_email") + @patch("registrar.admin.send_portfolio_invitation_email") + @patch("django.contrib.messages.success") + @patch("django.contrib.messages.error") + def test_add_domain_invitation_when_user_existing_portfolio_member_raises_exception_sending_domain_email( + self, mock_messages_error, mock_messages_success, mock_send_portfolio_email, mock_send_domain_email + ): + """Test saving a domain invitation when the user exists and is not a portfolio member raises + sending domain email exception. + + Should send out the portfolio invitation and attempt to send the domain invitation. + Should trigger portfolio invitation success message. + Should trigger domain invitation error message. + Should not attempt to retrieve the domain invitation. + Should attempt to retrieve the portfolio invitation.""" + + mock_send_domain_email.side_effect = MissingEmailError("craving a burger") + + user = User.objects.create_user(email="test@example.com", username="username") + + UserPortfolioPermission.objects.create( + user=user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER] + ) + + # Create a domain invitation instance + invitation = DomainInvitation(email="test@example.com", domain=self.domain) + + admin_instance = DomainInvitationAdmin(DomainInvitation, admin_site=None) + + # Create a request object + request = self.factory.post("/admin/registrar/DomainInvitation/add/") + request.user = self.superuser + + # Patch the retrieve method to ensure it is not called + with patch.object(DomainInvitation, "retrieve") as domain_invitation_mock_retrieve: + with patch.object(PortfolioInvitation, "retrieve") as portfolio_invitation_mock_retrieve: + admin_instance.save_model(request, invitation, form=None, change=False) + + # Assert sends appropriate emails - domain and portfolio invites + mock_send_domain_email.assert_called_once_with( + email="test@example.com", + requestor=self.superuser, + domains=self.domain, + is_member_of_different_org=None, + requested_user=user, + ) + mock_send_portfolio_email.assert_not_called() + + # Assert retrieve on domain invite only was called + domain_invitation_mock_retrieve.assert_not_called() + portfolio_invitation_mock_retrieve.assert_not_called() + + # Assert success message + mock_messages_success.assert_not_called() + + # Assert error message + mock_messages_error.assert_called_once_with( + request, "Can't send invitation email. No email is associated with your user account." + ) + + # Assert the invitations were saved + self.assertEqual(DomainInvitation.objects.count(), 0) + self.assertEqual(PortfolioInvitation.objects.count(), 0) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @patch("registrar.admin.send_domain_invitation_email") + @patch("registrar.admin.send_portfolio_invitation_email") + @patch("django.contrib.messages.success") + def test_add_domain_invitation_success_when_email_not_portfolio_member( + self, mock_messages_success, mock_send_portfolio_email, mock_send_domain_email + ): + """Test saving a domain invitation when the user does not exist. + + Should send out domain and portfolio invitations. + Should trigger success messages. + Should not attempt to retrieve the domain invitation. + Should not attempt to retrieve the portfolio invitation.""" + # Create a domain invitation instance + invitation = DomainInvitation(email="nonexistent@example.com", domain=self.domain) + + admin_instance = DomainInvitationAdmin(DomainInvitation, admin_site=None) + + # Create a request object + request = self.factory.post("/admin/registrar/DomainInvitation/add/") + request.user = self.superuser + + # Patch the retrieve method to ensure it is not called + with patch.object(DomainInvitation, "retrieve") as domain_invitation_mock_retrieve: + with patch.object(PortfolioInvitation, "retrieve") as portfolio_invitation_mock_retrieve: + admin_instance.save_model(request, invitation, form=None, change=False) + + # Assert sends appropriate emails - domain and portfolio invites + mock_send_domain_email.assert_called_once_with( + email="nonexistent@example.com", + requestor=self.superuser, + domains=self.domain, + is_member_of_different_org=None, + requested_user=None, + ) + mock_send_portfolio_email.assert_called_once_with( + email="nonexistent@example.com", + requestor=self.superuser, + portfolio=self.portfolio, + ) + + # Assert retrieve was not called + domain_invitation_mock_retrieve.assert_not_called() + portfolio_invitation_mock_retrieve.assert_not_called() + + # Assert success message + mock_messages_success.assert_has_calls( + [ + call(request, "nonexistent@example.com has been invited to the organization: new portfolio"), + call(request, "nonexistent@example.com has been invited to the domain: example.com"), + ] + ) + + # Assert the invitations were saved + self.assertEqual(DomainInvitation.objects.count(), 1) + self.assertEqual(DomainInvitation.objects.first().email, "nonexistent@example.com") + self.assertEqual(PortfolioInvitation.objects.count(), 1) + self.assertEqual(PortfolioInvitation.objects.first().email, "nonexistent@example.com") + + @less_console_noise_decorator + @override_flag("organization_feature", active=False) + @patch("registrar.admin.send_domain_invitation_email") + @patch("registrar.admin.send_portfolio_invitation_email") + @patch("django.contrib.messages.success") + def test_add_domain_invitation_success_when_email_not_portfolio_member_and_organization_feature_off( + self, mock_messages_success, mock_send_portfolio_email, mock_send_domain_email + ): + """Test saving a domain invitation when the user does not exist and organization_feature flag is off. + + Should send out a domain invitation. + Should not send a out portfolio invitation. + Should trigger success message for domain invitation. + Should not retrieve the domain invitation. + Should not create a portfolio invitation.""" + # Create a domain invitation instance + invitation = DomainInvitation(email="nonexistent@example.com", domain=self.domain) + + admin_instance = DomainInvitationAdmin(DomainInvitation, admin_site=None) + + # Create a request object + request = self.factory.post("/admin/registrar/DomainInvitation/add/") + request.user = self.superuser + + # Patch the retrieve method to ensure it is not called + with patch.object(DomainInvitation, "retrieve") as domain_invitation_mock_retrieve: + with patch.object(PortfolioInvitation, "retrieve") as portfolio_invitation_mock_retrieve: + admin_instance.save_model(request, invitation, form=None, change=False) + + # Assert sends appropriate emails - domain but not portfolio + mock_send_domain_email.assert_called_once_with( + email="nonexistent@example.com", + requestor=self.superuser, + domains=self.domain, + is_member_of_different_org=None, + requested_user=None, + ) + mock_send_portfolio_email.assert_not_called() + + # Assert retrieve on domain invite only was called + domain_invitation_mock_retrieve.assert_not_called() + portfolio_invitation_mock_retrieve.assert_not_called() + + # Assert success message + mock_messages_success.assert_called_once_with( + request, "nonexistent@example.com has been invited to the domain: example.com" + ) + + # Assert the domain invitation was saved + self.assertEqual(DomainInvitation.objects.count(), 1) + self.assertEqual(DomainInvitation.objects.first().email, "nonexistent@example.com") + self.assertEqual(PortfolioInvitation.objects.count(), 0) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("multiple_portfolios", active=True) + @patch("registrar.admin.send_domain_invitation_email") + @patch("registrar.admin.send_portfolio_invitation_email") + @patch("django.contrib.messages.success") + def test_add_domain_invitation_success_when_email_not_portfolio_member_and_multiple_portfolio_feature_on( + self, mock_messages_success, mock_send_portfolio_email, mock_send_domain_email + ): + """Test saving a domain invitation when the user does not exist and multiple_portfolio flag is on. + + Should send out a domain invitation. + Should not send a out portfolio invitation. + Should trigger success message for domain invitation. + Should not retrieve the domain invitation. + Should not create a portfolio invitation.""" + # Create a domain invitation instance + invitation = DomainInvitation(email="nonexistent@example.com", domain=self.domain) + + admin_instance = DomainInvitationAdmin(DomainInvitation, admin_site=None) + + # Create a request object + request = self.factory.post("/admin/registrar/DomainInvitation/add/") + request.user = self.superuser + + # Patch the retrieve method to ensure it is not called + with patch.object(DomainInvitation, "retrieve") as domain_invitation_mock_retrieve: + with patch.object(PortfolioInvitation, "retrieve") as portfolio_invitation_mock_retrieve: + admin_instance.save_model(request, invitation, form=None, change=False) + + # Assert sends appropriate emails - domain but not portfolio + mock_send_domain_email.assert_called_once_with( + email="nonexistent@example.com", + requestor=self.superuser, + domains=self.domain, + is_member_of_different_org=None, + requested_user=None, + ) + mock_send_portfolio_email.assert_not_called() + + # Assert retrieve on domain invite only was called + domain_invitation_mock_retrieve.assert_not_called() + portfolio_invitation_mock_retrieve.assert_not_called() + + # Assert success message + mock_messages_success.assert_called_once_with( + request, "nonexistent@example.com has been invited to the domain: example.com" + ) + + # Assert the domain invitation was saved + self.assertEqual(DomainInvitation.objects.count(), 1) + self.assertEqual(DomainInvitation.objects.first().email, "nonexistent@example.com") + self.assertEqual(PortfolioInvitation.objects.count(), 0) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @patch("registrar.admin.send_domain_invitation_email") + @patch("registrar.admin.send_portfolio_invitation_email") + @patch("django.contrib.messages.success") + def test_add_domain_invitation_success_when_email_existing_portfolio_invitation( + self, mock_messages_success, mock_send_portfolio_email, mock_send_domain_email + ): + """Test saving a domain invitation when the user does not exist and a portfolio invitation exists. + + Should send out domain invitation only. + Should trigger success message for the domain invitation. + Should not attempt to retrieve the domain invitation. + Should not attempt to retrieve the portfolio invitation.""" + + PortfolioInvitation.objects.create( + email="nonexistent@example.com", + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + ) + + # Create a domain invitation instance + invitation = DomainInvitation(email="nonexistent@example.com", domain=self.domain) + + admin_instance = DomainInvitationAdmin(DomainInvitation, admin_site=None) + + # Create a request object + request = self.factory.post("/admin/registrar/DomainInvitation/add/") + request.user = self.superuser + + # Patch the retrieve method to ensure it is not called + with patch.object(DomainInvitation, "retrieve") as domain_invitation_mock_retrieve: + with patch.object(PortfolioInvitation, "retrieve") as portfolio_invitation_mock_retrieve: + admin_instance.save_model(request, invitation, form=None, change=False) + + # Assert sends appropriate emails - domain and portfolio invites + mock_send_domain_email.assert_called_once_with( + email="nonexistent@example.com", + requestor=self.superuser, + domains=self.domain, + is_member_of_different_org=False, + requested_user=None, + ) + mock_send_portfolio_email.assert_not_called + + # Assert retrieve was not called + domain_invitation_mock_retrieve.assert_not_called() + portfolio_invitation_mock_retrieve.assert_not_called() + + # Assert success message + mock_messages_success.assert_called_once_with( + request, "nonexistent@example.com has been invited to the domain: example.com" + ) + + # Assert the invitations were saved + self.assertEqual(DomainInvitation.objects.count(), 1) + self.assertEqual(DomainInvitation.objects.first().email, "nonexistent@example.com") + self.assertEqual(PortfolioInvitation.objects.count(), 1) + self.assertEqual(PortfolioInvitation.objects.first().email, "nonexistent@example.com") + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @patch("registrar.admin.send_domain_invitation_email") + @patch("registrar.admin.send_portfolio_invitation_email") + @patch("django.contrib.messages.error") + def test_add_domain_invitation_when_user_not_portfolio_email_raises_exception_sending_portfolio_email( + self, mock_messages_error, mock_send_portfolio_email, mock_send_domain_email + ): + """Test saving a domain invitation when the user exists and is not a portfolio member raises + sending portfolio email exception. + + Should only attempt to send the portfolio invitation. + Should trigger error message on portfolio invitation. + Should not attempt to retrieve the domain invitation. + Should not attempt to retrieve the portfolio invitation.""" + + mock_send_portfolio_email.side_effect = MissingEmailError("craving a burger") + + # Create a domain invitation instance + invitation = DomainInvitation(email="nonexistent@example.com", domain=self.domain) + + admin_instance = DomainInvitationAdmin(DomainInvitation, admin_site=None) + + # Create a request object + request = self.factory.post("/admin/registrar/DomainInvitation/add/") + request.user = self.superuser + + # Patch the retrieve method to ensure it is not called + with patch.object(DomainInvitation, "retrieve") as domain_invitation_mock_retrieve: + with patch.object(PortfolioInvitation, "retrieve") as portfolio_invitation_mock_retrieve: + admin_instance.save_model(request, invitation, form=None, change=False) + + # Assert sends appropriate emails - domain and portfolio invites + mock_send_domain_email.assert_not_called() + mock_send_portfolio_email.assert_called_once_with( + email="nonexistent@example.com", + requestor=self.superuser, + portfolio=self.portfolio, + ) + + # Assert retrieve on domain invite only was called + domain_invitation_mock_retrieve.assert_not_called() + portfolio_invitation_mock_retrieve.assert_not_called() + + # Assert error message + mock_messages_error.assert_called_once_with( + request, "Can't send invitation email. No email is associated with your user account." + ) + + # Assert the invitations were saved + self.assertEqual(DomainInvitation.objects.count(), 0) + self.assertEqual(PortfolioInvitation.objects.count(), 0) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @patch("registrar.admin.send_domain_invitation_email") + @patch("registrar.admin.send_portfolio_invitation_email") + @patch("django.contrib.messages.success") + @patch("django.contrib.messages.error") + def test_add_domain_invitation_when_user_not_portfolio_email_raises_exception_sending_domain_email( + self, mock_messages_error, mock_messages_success, mock_send_portfolio_email, mock_send_domain_email + ): + """Test saving a domain invitation when the user exists and is not a portfolio member + raises sending domain email exception. + + Should send out the portfolio invitation and attempt to send the domain invitation. + Should trigger portfolio invitation success message. + Should trigger domain invitation error message. + Should not attempt to retrieve the domain invitation. + Should attempt to retrieve the portfolio invitation.""" + + mock_send_domain_email.side_effect = MissingEmailError("craving a burger") + + # Create a domain invitation instance + invitation = DomainInvitation(email="nonexistent@example.com", domain=self.domain) + + admin_instance = DomainInvitationAdmin(DomainInvitation, admin_site=None) + + # Create a request object + request = self.factory.post("/admin/registrar/DomainInvitation/add/") + request.user = self.superuser + + # Patch the retrieve method to ensure it is not called + with patch.object(DomainInvitation, "retrieve") as domain_invitation_mock_retrieve: + with patch.object(PortfolioInvitation, "retrieve") as portfolio_invitation_mock_retrieve: + admin_instance.save_model(request, invitation, form=None, change=False) + + # Assert sends appropriate emails - domain and portfolio invites + mock_send_domain_email.assert_called_once_with( + email="nonexistent@example.com", + requestor=self.superuser, + domains=self.domain, + is_member_of_different_org=None, + requested_user=None, + ) + mock_send_portfolio_email.assert_called_once_with( + email="nonexistent@example.com", + requestor=self.superuser, + portfolio=self.portfolio, + ) + + # Assert retrieve on domain invite only was called + domain_invitation_mock_retrieve.assert_not_called() + portfolio_invitation_mock_retrieve.assert_not_called() + + # Assert success message + mock_messages_success.assert_called_once_with( + request, "nonexistent@example.com has been invited to the organization: new portfolio" + ) + + # Assert error message + mock_messages_error.assert_called_once_with( + request, "Can't send invitation email. No email is associated with your user account." + ) + + # Assert the invitations were saved + self.assertEqual(DomainInvitation.objects.count(), 0) + self.assertEqual(PortfolioInvitation.objects.count(), 1) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @patch("registrar.admin.send_domain_invitation_email") + @patch("registrar.admin.send_portfolio_invitation_email") + @patch("django.contrib.messages.success") + @patch("django.contrib.messages.error") + def test_add_domain_invitation_when_user_existing_portfolio_email_raises_exception_sending_domain_email( + self, mock_messages_error, mock_messages_success, mock_send_portfolio_email, mock_send_domain_email + ): + """Test saving a domain invitation when the user exists and is not a portfolio member + raises sending domain email exception. + + Should send out the portfolio invitation and attempt to send the domain invitation. + Should trigger portfolio invitation success message. + Should trigger domain invitation error message. + Should not attempt to retrieve the domain invitation. + Should attempt to retrieve the portfolio invitation.""" + + mock_send_domain_email.side_effect = MissingEmailError("craving a burger") + + PortfolioInvitation.objects.create( + email="nonexistent@example.com", + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + ) + + # Create a domain invitation instance + invitation = DomainInvitation(email="nonexistent@example.com", domain=self.domain) + + admin_instance = DomainInvitationAdmin(DomainInvitation, admin_site=None) + + # Create a request object + request = self.factory.post("/admin/registrar/DomainInvitation/add/") + request.user = self.superuser + + # Patch the retrieve method to ensure it is not called + with patch.object(DomainInvitation, "retrieve") as domain_invitation_mock_retrieve: + with patch.object(PortfolioInvitation, "retrieve") as portfolio_invitation_mock_retrieve: + admin_instance.save_model(request, invitation, form=None, change=False) + + # Assert sends appropriate emails - domain and portfolio invites + mock_send_domain_email.assert_called_once_with( + email="nonexistent@example.com", + requestor=self.superuser, + domains=self.domain, + is_member_of_different_org=False, + requested_user=None, + ) + mock_send_portfolio_email.assert_not_called() + + # Assert retrieve on domain invite only was called + domain_invitation_mock_retrieve.assert_not_called() + portfolio_invitation_mock_retrieve.assert_not_called() + + # Assert success message + mock_messages_success.assert_not_called() + + # Assert error message + mock_messages_error.assert_called_once_with( + request, "Can't send invitation email. No email is associated with your user account." + ) + + # Assert the invitations were saved + self.assertEqual(DomainInvitation.objects.count(), 0) + self.assertEqual(PortfolioInvitation.objects.count(), 1) + class TestUserPortfolioPermissionAdmin(TestCase): """Tests for the PortfolioInivtationAdmin class""" def setUp(self): """Create a client object""" - self.factory = RequestFactory() - self.admin = ListHeaderAdmin(model=UserPortfolioPermissionAdmin, admin_site=AdminSite()) self.client = Client(HTTP_HOST="localhost:8080") self.superuser = create_superuser() self.portfolio = Portfolio.objects.create(organization_name="Test Portfolio", creator=self.superuser) @@ -205,78 +1078,33 @@ class TestUserPortfolioPermissionAdmin(TestCase): def tearDown(self): """Delete all DomainInvitation objects""" Portfolio.objects.all().delete() - PortfolioInvitation.objects.all().delete() Contact.objects.all().delete() User.objects.all().delete() + UserPortfolioPermission.objects.all().delete() @less_console_noise_decorator - def test_clean_user_portfolio_permission(self): - """Tests validation of user portfolio permission""" + 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) - # Test validation fails when portfolio missing but permissions are present - permission = UserPortfolioPermission(user=self.superuser, roles=["organization_admin"], portfolio=None) - with self.assertRaises(ValidationError) as err: - permission.clean() - self.assertEqual( - str(err.exception), - "When portfolio roles or additional permissions are assigned, portfolio is required.", - ) - - # Test validation fails when portfolio present but no permissions are present - permission = UserPortfolioPermission(user=self.superuser, roles=None, portfolio=self.portfolio) - with self.assertRaises(ValidationError) as err: - permission.clean() - self.assertEqual( - str(err.exception), - "When portfolio is assigned, portfolio roles or additional permissions are required.", - ) - - # Test validation fails with forbidden permissions for single role - forbidden_member_roles = UserPortfolioPermission.FORBIDDEN_PORTFOLIO_ROLE_PERMISSIONS.get( - UserPortfolioRoleChoices.ORGANIZATION_MEMBER - ) - permission = UserPortfolioPermission( - user=self.superuser, - roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], - additional_permissions=forbidden_member_roles, - portfolio=self.portfolio, - ) - with self.assertRaises(ValidationError) as err: - permission.clean() - self.assertEqual( - str(err.exception), - "These permissions cannot be assigned to Member: " - "", - ) - - @less_console_noise_decorator - def test_get_forbidden_permissions_with_multiple_roles(self): - """Tests that forbidden permissions are properly handled when a user has multiple roles""" - # Get forbidden permissions for member role - member_forbidden = UserPortfolioPermission.FORBIDDEN_PORTFOLIO_ROLE_PERMISSIONS.get( - UserPortfolioRoleChoices.ORGANIZATION_MEMBER + user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( + user=self.superuser, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] ) - # Test with both admin and member roles - roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN, UserPortfolioRoleChoices.ORGANIZATION_MEMBER] - - # These permissions would be forbidden for member alone, but should be allowed - # when combined with admin role - permissions = UserPortfolioPermission.get_forbidden_permissions( - roles=roles, additional_permissions=member_forbidden + response = self.client.get( + "/admin/registrar/userportfoliopermission/{}/change/".format(user_portfolio_permission.pk), + follow=True, ) - # Should return empty set since no permissions are commonly forbidden between admin and member - self.assertEqual(permissions, set()) + # Make sure that the page is loaded correctly + self.assertEqual(response.status_code, 200) - # Verify the same permissions are forbidden when only member role is present - member_only_permissions = UserPortfolioPermission.get_forbidden_permissions( - roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], additional_permissions=member_forbidden + # Test for a description snippet + self.assertContains( + response, + "If you add someone to a portfolio here, it will not trigger an invitation email.", ) - # Should return the forbidden permissions for member role - self.assertEqual(member_only_permissions, set(member_forbidden)) - class TestPortfolioInvitationAdmin(TestCase): """Tests for the PortfolioInvitationAdmin class as super user @@ -302,117 +1130,12 @@ class TestPortfolioInvitationAdmin(TestCase): Portfolio.objects.all().delete() PortfolioInvitation.objects.all().delete() Contact.objects.all().delete() + User.objects.all().delete() @classmethod def tearDownClass(self): User.objects.all().delete() - @less_console_noise_decorator - @override_flag("multiple_portfolios", active=False) - def test_clean_multiple_portfolios_inactive(self): - """Tests that users cannot have multiple portfolios or invitations when flag is inactive""" - # Create the first portfolio permission - UserPortfolioPermission.objects.create( - user=self.superuser, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] - ) - - # Test a second portfolio permission object (should fail) - second_portfolio = Portfolio.objects.create(organization_name="Second Portfolio", creator=self.superuser) - second_permission = UserPortfolioPermission( - user=self.superuser, portfolio=second_portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] - ) - - with self.assertRaises(ValidationError) as err: - second_permission.clean() - self.assertIn("users cannot be assigned to multiple portfolios", str(err.exception)) - - # Test that adding a new portfolio invitation also fails - third_portfolio = Portfolio.objects.create(organization_name="Third Portfolio", creator=self.superuser) - invitation = PortfolioInvitation( - email=self.superuser.email, portfolio=third_portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] - ) - - with self.assertRaises(ValidationError) as err: - invitation.clean() - self.assertIn("users cannot be assigned to multiple portfolios", str(err.exception)) - - @less_console_noise_decorator - @override_flag("multiple_portfolios", active=True) - def test_clean_multiple_portfolios_active(self): - """Tests that users can have multiple portfolios and invitations when flag is active""" - # Create first portfolio permission - UserPortfolioPermission.objects.create( - user=self.superuser, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] - ) - - # Second portfolio permission should succeed - second_portfolio = Portfolio.objects.create(organization_name="Second Portfolio", creator=self.superuser) - second_permission = UserPortfolioPermission( - user=self.superuser, portfolio=second_portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] - ) - second_permission.clean() - second_permission.save() - - # Verify both permissions exist - user_permissions = UserPortfolioPermission.objects.filter(user=self.superuser) - self.assertEqual(user_permissions.count(), 2) - - # Portfolio invitation should also succeed - third_portfolio = Portfolio.objects.create(organization_name="Third Portfolio", creator=self.superuser) - invitation = PortfolioInvitation( - email=self.superuser.email, portfolio=third_portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] - ) - invitation.clean() - invitation.save() - - # Verify invitation exists - self.assertTrue( - PortfolioInvitation.objects.filter( - email=self.superuser.email, - portfolio=third_portfolio, - ).exists() - ) - - @less_console_noise_decorator - def test_clean_portfolio_invitation(self): - """Tests validation of portfolio invitation permissions""" - - # Test validation fails when portfolio missing but permissions present - invitation = PortfolioInvitation(email="test@example.com", roles=["organization_admin"], portfolio=None) - with self.assertRaises(ValidationError) as err: - invitation.clean() - self.assertEqual( - str(err.exception), - "When portfolio roles or additional permissions are assigned, portfolio is required.", - ) - - # Test validation fails when portfolio present but no permissions - invitation = PortfolioInvitation(email="test@example.com", roles=None, portfolio=self.portfolio) - with self.assertRaises(ValidationError) as err: - invitation.clean() - self.assertEqual( - str(err.exception), - "When portfolio is assigned, portfolio roles or additional permissions are required.", - ) - - # Test validation fails with forbidden permissions - forbidden_member_roles = UserPortfolioPermission.FORBIDDEN_PORTFOLIO_ROLE_PERMISSIONS.get( - UserPortfolioRoleChoices.ORGANIZATION_MEMBER - ) - invitation = PortfolioInvitation( - email="test@example.com", - roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], - additional_permissions=forbidden_member_roles, - portfolio=self.portfolio, - ) - with self.assertRaises(ValidationError) as err: - invitation.clean() - self.assertEqual( - str(err.exception), - "These permissions cannot be assigned to Member: " - "", - ) - @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" @@ -432,29 +1155,305 @@ 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(): - self.client.force_login(self.superuser) + self.client.force_login(self.superuser) - response = self.client.get( - "/admin/registrar/portfolioinvitation/", - {}, - follow=True, - ) + response = self.client.get( + "/admin/registrar/portfolioinvitation/", + {}, + follow=True, + ) - # Assert that the filters are added - self.assertContains(response, "invited", count=4) - self.assertContains(response, "Invited", count=2) - self.assertContains(response, "retrieved", count=2) - self.assertContains(response, "Retrieved", count=2) + # Assert that the filters are added + self.assertContains(response, "invited", count=4) + self.assertContains(response, "Invited", count=2) + self.assertContains(response, "retrieved", count=2) + self.assertContains(response, "Retrieved", count=2) - # Check for the HTML context specificially - invited_html = 'Invited' - retrieved_html = 'Retrieved' + # Check for the HTML context specificially + invited_html = 'Invited' + retrieved_html = 'Retrieved' - self.assertContains(response, invited_html, count=1) - self.assertContains(response, retrieved_html, count=1) + 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_success, mock_send_email): + """On save_model, an email is sent if an invitation already exists.""" + + # 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 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_success.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.success") # Mock the `messages.warning` call + def test_add_portfolio_invitation_auto_retrieves_invitation_when_user_exists( + self, mock_messages_success, mock_send_email + ): + """On save_model, we create and retrieve a portfolio invitation if the user exists.""" + + # Create an instance of the admin class + admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=None) + + User.objects.create_user(email="james.gordon@gotham.gov", username="username") + + # 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 + with patch.object(PortfolioInvitation, "retrieve") as portfolio_invitation_mock_retrieve: + admin_instance.save_model(request, portfolio_invitation, None, None) + + # Assert that send_portfolio_invitation_email is 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_success.assert_called_once_with(request, "james.gordon@gotham.gov has been invited.") + + # The invitation is not retrieved + portfolio_invitation_mock_retrieve.assert_called_once() + + @less_console_noise_decorator + @patch("registrar.admin.send_portfolio_invitation_email") + @patch("django.contrib.messages.success") # Mock the `messages.warning` call + def test_add_portfolio_invitation_does_not_retrieve_invitation_when_no_user( + self, mock_messages_success, mock_send_email + ): + """On save_model, we create but do not retrieve a portfolio invitation if the user does not exist.""" + + # 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 + with patch.object(PortfolioInvitation, "retrieve") as portfolio_invitation_mock_retrieve: + admin_instance.save_model(request, portfolio_invitation, None, None) + + # Assert that send_portfolio_invitation_email is 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_success.assert_called_once_with(request, "james.gordon@gotham.gov has been invited.") + + # The invitation is not retrieved + portfolio_invitation_mock_retrieve.assert_not_called() + + @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, "Email service unavailable") + + @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.") class TestHostAdmin(TestCase): @@ -981,6 +1980,31 @@ class TestUserDomainRoleAdmin(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) + + domain, _ = Domain.objects.get_or_create(name="systemofadown.com") + + user_domain_role, _ = UserDomainRole.objects.get_or_create( + user=self.superuser, domain=domain, role=[UserDomainRole.Roles.MANAGER] + ) + + response = self.client.get( + "/admin/registrar/userdomainrole/{}/change/".format(user_domain_role.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 domain here, it will not trigger any emails.", + ) + def test_domain_sortable(self): """Tests if the UserDomainrole sorts by domain correctly""" with less_console_noise(): @@ -2466,7 +3490,7 @@ class TestTransferUser(WebTest): @less_console_noise_decorator def test_transfer_user_transfers_user_portfolio_roles_no_error_when_duplicates(self): - """Assert that duplicate portfolio user roles do not throw errorsd""" + """Assert that duplicate portfolio user roles do not throw errors""" portfolio1 = Portfolio.objects.create(organization_name="Hotel California", creator=self.user2) UserPortfolioPermission.objects.create( user=self.user1, portfolio=portfolio1, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] @@ -2598,7 +3622,7 @@ class TestTransferUser(WebTest): with self.assertRaises(User.DoesNotExist): self.user2.refresh_from_db() - @less_console_noise_decorator + # @less_console_noise_decorator def test_transfer_user_throws_transfer_and_delete_success_messages(self): """Test that success messages for data transfer and user deletion are displayed.""" # Ensure the setup for VerifiedByStaff @@ -2616,11 +3640,13 @@ class TestTransferUser(WebTest): self.assertContains(after_submit, "

    Change user

    ") + print(mock_success_message.call_args_list) + mock_success_message.assert_any_call( ANY, ( "Data transferred successfully for the following objects: ['Changed requestor " - + 'from "Furiosa Jabassa " to "Max Rokatanski " on immortan.joe@citadel.com\']' + + "from Furiosa Jabassa to Max Rokatanski on immortan.joe@citadel.com']" ), ) @@ -2630,7 +3656,7 @@ class TestTransferUser(WebTest): def test_transfer_user_throws_error_message(self): """Test that an error message is thrown if the transfer fails.""" with patch( - "registrar.views.TransferUserView.transfer_user_fields_and_log", side_effect=Exception("Simulated Error") + "registrar.views.TransferUserView.transfer_related_fields_and_log", side_effect=Exception("Simulated Error") ): with patch("django.contrib.messages.error") as mock_error: # Access the transfer user page diff --git a/src/registrar/tests/test_admin_domain.py b/src/registrar/tests/test_admin_domain.py index 072bc1f7f..867bf1b82 100644 --- a/src/registrar/tests/test_admin_domain.py +++ b/src/registrar/tests/test_admin_domain.py @@ -779,7 +779,7 @@ class TestDomainAdminWithClient(TestCase): response = self.client.get("/admin/registrar/domain/") # There are 4 template references to Federal (4) plus four references in the table # for our actual domain_request - self.assertContains(response, "Federal", count=57) + self.assertContains(response, "Federal", count=56) # This may be a bit more robust self.assertContains(response, 'Federal', count=1) # Now let's make sure the long description does not exist diff --git a/src/registrar/tests/test_admin_request.py b/src/registrar/tests/test_admin_request.py index df0902719..f7dfed108 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, @@ -35,8 +40,9 @@ from .common import ( multiple_unalphabetical_domain_objects, MockEppLib, GenericTestHelper, + normalize_html, ) -from unittest.mock import patch +from unittest.mock import ANY, patch from django.conf import settings import boto3_mocking # type: ignore @@ -76,12 +82,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 +100,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""" @@ -576,7 +662,7 @@ class TestDomainRequestAdmin(MockEppLib): response = self.client.get("/admin/registrar/domainrequest/?generic_org_type__exact=federal") # There are 2 template references to Federal (4) and two in the results data # of the request - self.assertContains(response, "Federal", count=55) + self.assertContains(response, "Federal", count=54) # This may be a bit more robust self.assertContains(response, 'Federal', count=1) # Now let's make sure the long description does not exist @@ -1445,8 +1531,9 @@ class TestDomainRequestAdmin(MockEppLib): self.assertContains(response, expected_url) @less_console_noise_decorator - def test_other_websites_has_readonly_link(self): - """Tests if the readonly other_websites field has links""" + def test_other_websites_has_one_readonly_link(self): + """Tests if the readonly other_websites field has links. + Test markup for one website.""" # Create a fake domain request domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) @@ -1462,8 +1549,224 @@ class TestDomainRequestAdmin(MockEppLib): self.assertContains(response, domain_request.requested_domain.name) # Check that the page contains the link we expect. - expected_url = 'city.com' - self.assertContains(response, expected_url) + expected_markup = """ +

    + + city.com + +

    + """ + + normalized_expected = normalize_html(expected_markup) + normalized_response = normalize_html(response.content.decode("utf-8")) + + index = normalized_response.find(normalized_expected) + + # Assert that the expected markup is found in the response + if index == -1: + self.fail( + f"Expected markup not found in the response.\n\n" + f"Expected:\n{normalized_expected}\n\n" + f"Start index of mismatch: {index}\n\n" + f"Consider checking the surrounding response for context." + ) + + @less_console_noise_decorator + def test_other_websites_has_few_readonly_links(self): + """Tests if the readonly other_websites field has links. + Test markup for 5 or less websites.""" + + # Create a domain request with 4 current websites + domain_request = completed_domain_request( + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + current_websites=["city.gov", "city2.gov", "city3.gov", "city4.gov"], + ) + + self.client.force_login(self.staffuser) + response = self.client.get( + "/admin/registrar/domainrequest/{}/change/".format(domain_request.pk), + follow=True, + ) + + # Make sure the page loaded, and that we're on the right page + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain_request.requested_domain.name) + + # Check that the page contains the link we expect. + expected_markup = """ + + """ + + normalized_expected = normalize_html(expected_markup) + normalized_response = normalize_html(response.content.decode("utf-8")) + + index = normalized_response.find(normalized_expected) + + # Assert that the expected markup is found in the response + if index == -1: + self.fail( + f"Expected markup not found in the response.\n\n" + f"Expected:\n{normalized_expected}\n\n" + f"Start index of mismatch: {index}\n\n" + f"Consider checking the surrounding response for context." + ) + + @less_console_noise_decorator + def test_other_websites_has_lots_readonly_links(self): + """Tests if the readonly other_websites field has links. + Test markup for 6 or more websites.""" + + # Create a domain requests with 6 current websites + domain_request = completed_domain_request( + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + current_websites=["city.gov", "city2.gov", "city3.gov", "city4.gov", "city5.gov", "city6.gov"], + ) + + self.client.force_login(self.staffuser) + response = self.client.get( + "/admin/registrar/domainrequest/{}/change/".format(domain_request.pk), + follow=True, + ) + + # Make sure the page loaded, and that we're on the right page + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain_request.requested_domain.name) + + # Check that the page contains the link we expect. + expected_markup = """ + + """ + + normalized_expected = normalize_html(expected_markup) + normalized_response = normalize_html(response.content.decode("utf-8")) + + index = normalized_response.find(normalized_expected) + + # Assert that the expected markup is found in the response + if index == -1: + self.fail( + f"Expected markup not found in the response.\n\n" + f"Expected:\n{normalized_expected}\n\n" + f"Start index of mismatch: {index}\n\n" + f"Consider checking the surrounding response for context." + ) + + @less_console_noise_decorator + def test_alternative_domains_has_one_readonly_link(self): + """Tests if the readonly alternative_domains field has links. + Test markup for one website.""" + + # Create a fake domain request + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + + self.client.force_login(self.staffuser) + response = self.client.get( + "/admin/registrar/domainrequest/{}/change/".format(domain_request.pk), + follow=True, + ) + + # Make sure the page loaded, and that we're on the right page + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain_request.requested_domain.name) + + # Check that the page contains the link we expect. + website = Website.objects.filter(website="city1.gov").first() + base_url = "/admin/registrar/website" + return_path = f"/admin/registrar/domainrequest/{domain_request.pk}/change/" + expected_markup = f""" +

    + city1.gov +

    + """ + + normalized_expected = normalize_html(expected_markup) + normalized_response = normalize_html(response.content.decode("utf-8")) + + index = normalized_response.find(normalized_expected) + + # Assert that the expected markup is found in the response + if index == -1: + self.fail( + f"Expected markup not found in the response.\n\n" + f"Expected:\n{normalized_expected}\n\n" + f"Start index of mismatch: {index}\n\n" + f"Consider checking the surrounding response for context." + ) + + @less_console_noise_decorator + def test_alternative_domains_has_lots_readonly_link(self): + """Tests if the readonly other_websites field has links. + Test markup for 6 or more websites.""" + + # Create a domain request with 6 alternative domains + domain_request = completed_domain_request( + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + alternative_domains=[ + "altcity1.gov", + "altcity2.gov", + "altcity3.gov", + "altcity4.gov", + "altcity5.gov", + "altcity6.gov", + ], + ) + + self.client.force_login(self.staffuser) + response = self.client.get( + "/admin/registrar/domainrequest/{}/change/".format(domain_request.pk), + follow=True, + ) + + # Make sure the page loaded, and that we're on the right page + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain_request.requested_domain.name) + + # Check that the page contains the link we expect. + website1 = Website.objects.filter(website="altcity1.gov").first() + website2 = Website.objects.filter(website="altcity2.gov").first() + website3 = Website.objects.filter(website="altcity3.gov").first() + website4 = Website.objects.filter(website="altcity4.gov").first() + website5 = Website.objects.filter(website="altcity5.gov").first() + website6 = Website.objects.filter(website="altcity6.gov").first() + base_url = "/admin/registrar/website" + return_path = f"/admin/registrar/domainrequest/{domain_request.pk}/change/" + attr = 'target="_blank"' + expected_markup = f""" + + """ + + normalized_expected = normalize_html(expected_markup) + normalized_response = normalize_html(response.content.decode("utf-8")) + + index = normalized_response.find(normalized_expected) + + # Assert that the expected markup is found in the response + if index == -1: + self.fail( + f"Expected markup not found in the response.\n\n" + f"Expected:\n{normalized_expected}\n\n" + f"Start index of mismatch: {index}\n\n" + f"Consider checking the surrounding response for context." + ) @less_console_noise_decorator def test_contact_fields_have_detail_table(self): @@ -1731,9 +2034,6 @@ class TestDomainRequestAdmin(MockEppLib): "cisa_representative_first_name", "cisa_representative_last_name", "cisa_representative_email", - "requested_suborganization", - "suborganization_city", - "suborganization_state_territory", ] self.assertEqual(readonly_fields, expected_fields) @@ -1811,6 +2111,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. @@ -1967,6 +2298,7 @@ class TestDomainRequestAdmin(MockEppLib): # Grab the current list of table filters readonly_fields = self.admin.get_list_filter(request) expected_fields = ( + DomainRequestAdmin.PortfolioFilter, DomainRequestAdmin.StatusListFilter, DomainRequestAdmin.GenericOrgFilter, DomainRequestAdmin.FederalTypeFilter, diff --git a/src/registrar/tests/test_email_invitations.py b/src/registrar/tests/test_email_invitations.py new file mode 100644 index 000000000..1377dec42 --- /dev/null +++ b/src/registrar/tests/test_email_invitations.py @@ -0,0 +1,471 @@ +import unittest +from unittest.mock import patch, MagicMock +from datetime import date +from registrar.models.domain import Domain +from registrar.models.user import User +from registrar.models.user_domain_role import UserDomainRole +from registrar.utility.email import EmailSendingError +from registrar.utility.email_invitations import send_domain_invitation_email, send_emails_to_domain_managers + +from api.tests.common import less_console_noise_decorator + + +class DomainInvitationEmail(unittest.TestCase): + + @less_console_noise_decorator + @patch("registrar.utility.email_invitations.send_templated_email") + @patch("registrar.utility.email_invitations.UserDomainRole.objects.filter") + @patch("registrar.utility.email_invitations._validate_invitation") + @patch("registrar.utility.email_invitations.get_requestor_email") + @patch("registrar.utility.email_invitations.send_invitation_email") + @patch("registrar.utility.email_invitations.normalize_domains") + def test_send_domain_invitation_email( + self, + mock_normalize_domains, + mock_send_invitation_email, + mock_get_requestor_email, + mock_validate_invitation, + mock_user_domain_role_filter, + mock_send_templated_email, + ): + """Test sending domain invitation email for one domain. + Should also send emails to manager of that domain. + """ + # Setup + mock_domain = MagicMock(name="domain1") + mock_domain.name = "example.com" + mock_normalize_domains.return_value = [mock_domain] + + mock_requestor = MagicMock() + mock_requestor_email = "requestor@example.com" + mock_get_requestor_email.return_value = mock_requestor_email + + mock_user1 = MagicMock() + mock_user1.email = "manager1@example.com" + + mock_user_domain_role_filter.return_value = [MagicMock(user=mock_user1)] + + email = "invitee@example.com" + is_member_of_different_org = False + + # Call the function + send_domain_invitation_email( + email=email, + requestor=mock_requestor, + domains=mock_domain, + is_member_of_different_org=is_member_of_different_org, + ) + + # Assertions + mock_normalize_domains.assert_called_once_with(mock_domain) + mock_get_requestor_email.assert_called_once_with(mock_requestor, [mock_domain]) + mock_validate_invitation.assert_called_once_with( + email, None, [mock_domain], mock_requestor, is_member_of_different_org + ) + mock_send_invitation_email.assert_called_once_with(email, mock_requestor_email, [mock_domain], None) + mock_user_domain_role_filter.assert_called_once_with(domain=mock_domain) + mock_send_templated_email.assert_called_once_with( + "emails/domain_manager_notification.txt", + "emails/domain_manager_notification_subject.txt", + to_address=mock_user1.email, + context={ + "domain": mock_domain, + "requestor_email": mock_requestor_email, + "invited_email_address": email, + "domain_manager": mock_user1, + "date": date.today(), + }, + ) + + @less_console_noise_decorator + @patch("registrar.utility.email_invitations.send_templated_email") + @patch("registrar.utility.email_invitations.UserDomainRole.objects.filter") + @patch("registrar.utility.email_invitations._validate_invitation") + @patch("registrar.utility.email_invitations.get_requestor_email") + @patch("registrar.utility.email_invitations.send_invitation_email") + @patch("registrar.utility.email_invitations.normalize_domains") + def test_send_domain_invitation_email_multiple_domains( + self, + mock_normalize_domains, + mock_send_invitation_email, + mock_get_requestor_email, + mock_validate_invitation, + mock_user_domain_role_filter, + mock_send_templated_email, + ): + """Test sending domain invitation email for multiple domains. + Should also send emails to managers of each domain. + """ + # Setup + # Create multiple mock domains + mock_domain1 = MagicMock(name="domain1") + mock_domain1.name = "example.com" + mock_domain2 = MagicMock(name="domain2") + mock_domain2.name = "example.org" + + mock_normalize_domains.return_value = [mock_domain1, mock_domain2] + + mock_requestor = MagicMock() + mock_requestor_email = "requestor@example.com" + mock_get_requestor_email.return_value = mock_requestor_email + + mock_user1 = MagicMock() + mock_user1.email = "manager1@example.com" + mock_user2 = MagicMock() + mock_user2.email = "manager2@example.com" + + # Configure domain roles for each domain + def filter_side_effect(domain): + if domain == mock_domain1: + return [MagicMock(user=mock_user1)] + elif domain == mock_domain2: + return [MagicMock(user=mock_user2)] + return [] + + mock_user_domain_role_filter.side_effect = filter_side_effect + + email = "invitee@example.com" + is_member_of_different_org = False + + # Call the function + send_domain_invitation_email( + email=email, + requestor=mock_requestor, + domains=[mock_domain1, mock_domain2], + is_member_of_different_org=is_member_of_different_org, + ) + + # Assertions + mock_normalize_domains.assert_called_once_with([mock_domain1, mock_domain2]) + mock_get_requestor_email.assert_called_once_with(mock_requestor, [mock_domain1, mock_domain2]) + mock_validate_invitation.assert_called_once_with( + email, None, [mock_domain1, mock_domain2], mock_requestor, is_member_of_different_org + ) + mock_send_invitation_email.assert_called_once_with( + email, mock_requestor_email, [mock_domain1, mock_domain2], None + ) + + # Check that domain manager emails were sent for both domains + mock_user_domain_role_filter.assert_any_call(domain=mock_domain1) + mock_user_domain_role_filter.assert_any_call(domain=mock_domain2) + + mock_send_templated_email.assert_any_call( + "emails/domain_manager_notification.txt", + "emails/domain_manager_notification_subject.txt", + to_address=mock_user1.email, + context={ + "domain": mock_domain1, + "requestor_email": mock_requestor_email, + "invited_email_address": email, + "domain_manager": mock_user1, + "date": date.today(), + }, + ) + mock_send_templated_email.assert_any_call( + "emails/domain_manager_notification.txt", + "emails/domain_manager_notification_subject.txt", + to_address=mock_user2.email, + context={ + "domain": mock_domain2, + "requestor_email": mock_requestor_email, + "invited_email_address": email, + "domain_manager": mock_user2, + "date": date.today(), + }, + ) + + # Verify the total number of calls to send_templated_email + self.assertEqual(mock_send_templated_email.call_count, 2) + + @less_console_noise_decorator + @patch("registrar.utility.email_invitations._validate_invitation") + def test_send_domain_invitation_email_raises_invite_validation_exception(self, mock_validate_invitation): + """Test sending domain invitation email for one domain and assert exception + when invite validation fails. + """ + # Setup + mock_validate_invitation.side_effect = ValueError("Validation failed") + email = "invitee@example.com" + requestor = MagicMock() + domain = MagicMock() + + # Call and assert exception + with self.assertRaises(ValueError) as context: + send_domain_invitation_email(email, requestor, domain, is_member_of_different_org=False) + + self.assertEqual(str(context.exception), "Validation failed") + mock_validate_invitation.assert_called_once() + + @less_console_noise_decorator + @patch("registrar.utility.email_invitations.get_requestor_email") + def test_send_domain_invitation_email_raises_get_requestor_email_exception(self, mock_get_requestor_email): + """Test sending domain invitation email for one domain and assert exception + when get_requestor_email fails. + """ + # Setup + mock_get_requestor_email.side_effect = ValueError("Validation failed") + email = "invitee@example.com" + requestor = MagicMock() + domain = MagicMock() + + # Call and assert exception + with self.assertRaises(ValueError) as context: + send_domain_invitation_email(email, requestor, domain, is_member_of_different_org=False) + + self.assertEqual(str(context.exception), "Validation failed") + mock_get_requestor_email.assert_called_once() + + @less_console_noise_decorator + @patch("registrar.utility.email_invitations._validate_invitation") + @patch("registrar.utility.email_invitations.get_requestor_email") + @patch("registrar.utility.email_invitations.send_invitation_email") + @patch("registrar.utility.email_invitations.normalize_domains") + def test_send_domain_invitation_email_raises_sending_email_exception( + self, + mock_normalize_domains, + mock_send_invitation_email, + mock_get_requestor_email, + mock_validate_invitation, + ): + """Test sending domain invitation email for one domain and assert exception + when send_invitation_email fails. + """ + # Setup + mock_domain = MagicMock(name="domain1") + mock_domain.name = "example.com" + mock_normalize_domains.return_value = [mock_domain] + + mock_requestor = MagicMock() + mock_requestor_email = "requestor@example.com" + mock_get_requestor_email.return_value = mock_requestor_email + + mock_user1 = MagicMock() + mock_user1.email = "manager1@example.com" + + email = "invitee@example.com" + is_member_of_different_org = False + + mock_send_invitation_email.side_effect = EmailSendingError("Error sending email") + + # Call and assert exception + with self.assertRaises(EmailSendingError) as context: + send_domain_invitation_email( + email=email, + requestor=mock_requestor, + domains=mock_domain, + is_member_of_different_org=is_member_of_different_org, + ) + + # Assertions + mock_normalize_domains.assert_called_once_with(mock_domain) + mock_get_requestor_email.assert_called_once_with(mock_requestor, [mock_domain]) + mock_validate_invitation.assert_called_once_with( + email, None, [mock_domain], mock_requestor, is_member_of_different_org + ) + self.assertEqual(str(context.exception), "Error sending email") + + @less_console_noise_decorator + @patch("registrar.utility.email_invitations.send_emails_to_domain_managers") + @patch("registrar.utility.email_invitations._validate_invitation") + @patch("registrar.utility.email_invitations.get_requestor_email") + @patch("registrar.utility.email_invitations.send_invitation_email") + @patch("registrar.utility.email_invitations.normalize_domains") + def test_send_domain_invitation_email_manager_emails_send_mail_exception( + self, + mock_normalize_domains, + mock_send_invitation_email, + mock_get_requestor_email, + mock_validate_invitation, + mock_send_domain_manager_emails, + ): + """Test sending domain invitation email for one domain and assert exception + when send_emails_to_domain_managers fails. + """ + # Setup + mock_domain = MagicMock(name="domain1") + mock_domain.name = "example.com" + mock_normalize_domains.return_value = [mock_domain] + + mock_requestor = MagicMock() + mock_requestor_email = "requestor@example.com" + mock_get_requestor_email.return_value = mock_requestor_email + + email = "invitee@example.com" + is_member_of_different_org = False + + # Change the return value to False for mock_send_domain_manager_emails + mock_send_domain_manager_emails.return_value = False + + # Call and assert that send_domain_invitation_email returns False + result = send_domain_invitation_email( + email=email, + requestor=mock_requestor, + domains=mock_domain, + is_member_of_different_org=is_member_of_different_org, + ) + + # Assertions + mock_normalize_domains.assert_called_once_with(mock_domain) + mock_get_requestor_email.assert_called_once_with(mock_requestor, [mock_domain]) + mock_validate_invitation.assert_called_once_with( + email, None, [mock_domain], mock_requestor, is_member_of_different_org + ) + mock_send_invitation_email.assert_called_once_with(email, mock_requestor_email, [mock_domain], None) + + # Assert that the result is False + self.assertFalse(result) + + @less_console_noise_decorator + @patch("registrar.utility.email_invitations.send_templated_email") + @patch("registrar.models.UserDomainRole.objects.filter") + def test_send_emails_to_domain_managers_all_emails_sent_successfully(self, mock_filter, mock_send_templated_email): + """Test when all emails are sent successfully.""" + + # Setup mocks + mock_domain = MagicMock(spec=Domain) + mock_requestor_email = "requestor@example.com" + mock_email = "invitee@example.com" + + # Create mock user and UserDomainRole + mock_user = MagicMock(spec=User) + mock_user.email = "manager@example.com" + mock_user_domain_role = MagicMock(spec=UserDomainRole, user=mock_user) + + # Mock the filter method to return a list of mock UserDomainRole objects + mock_filter.return_value = [mock_user_domain_role] + + # Mock successful email sending + mock_send_templated_email.return_value = None # No exception means success + + # Call function + result = send_emails_to_domain_managers(mock_email, mock_requestor_email, mock_domain) + + # Assertions + self.assertTrue(result) # All emails should be successfully sent + mock_send_templated_email.assert_called_once_with( + "emails/domain_manager_notification.txt", + "emails/domain_manager_notification_subject.txt", + to_address="manager@example.com", + context={ + "domain": mock_domain, + "requestor_email": mock_requestor_email, + "invited_email_address": mock_email, + "domain_manager": mock_user, + "date": date.today(), + }, + ) + + @less_console_noise_decorator + @patch("registrar.utility.email_invitations.send_templated_email") + @patch("registrar.models.UserDomainRole.objects.filter") + def test_send_emails_to_domain_managers_email_send_fails(self, mock_filter, mock_send_templated_email): + """Test when sending an email fails (raises EmailSendingError).""" + + # Setup mocks + mock_domain = MagicMock(spec=Domain) + mock_requestor_email = "requestor@example.com" + mock_email = "invitee@example.com" + + # Create mock user and UserDomainRole + mock_user = MagicMock(spec=User) + mock_user.email = "manager@example.com" + mock_user_domain_role = MagicMock(spec=UserDomainRole, user=mock_user) + + # Mock the filter method to return a list of mock UserDomainRole objects + mock_filter.return_value = [mock_user_domain_role] + + # Mock sending email to raise an EmailSendingError + mock_send_templated_email.side_effect = EmailSendingError("Email sending failed") + + # Call function + result = send_emails_to_domain_managers(mock_email, mock_requestor_email, mock_domain) + + # Assertions + self.assertFalse(result) # The result should be False as email sending failed + mock_send_templated_email.assert_called_once_with( + "emails/domain_manager_notification.txt", + "emails/domain_manager_notification_subject.txt", + to_address="manager@example.com", + context={ + "domain": mock_domain, + "requestor_email": mock_requestor_email, + "invited_email_address": mock_email, + "domain_manager": mock_user, + "date": date.today(), + }, + ) + + @less_console_noise_decorator + @patch("registrar.utility.email_invitations.send_templated_email") + @patch("registrar.models.UserDomainRole.objects.filter") + def test_send_emails_to_domain_managers_no_domain_managers(self, mock_filter, mock_send_templated_email): + """Test when there are no domain managers.""" + + # Setup mocks + mock_domain = MagicMock(spec=Domain) + mock_requestor_email = "requestor@example.com" + mock_email = "invitee@example.com" + + # Mock no domain managers (empty UserDomainRole queryset) + mock_filter.return_value = [] + + # Call function + result = send_emails_to_domain_managers(mock_email, mock_requestor_email, mock_domain) + + # Assertions + self.assertTrue(result) # No emails to send, so it should return True + mock_send_templated_email.assert_not_called() # No emails should be sent + + @less_console_noise_decorator + @patch("registrar.utility.email_invitations.send_templated_email") + @patch("registrar.models.UserDomainRole.objects.filter") + def test_send_emails_to_domain_managers_some_emails_fail(self, mock_filter, mock_send_templated_email): + """Test when some emails fail to send.""" + + # Setup mocks + mock_domain = MagicMock(spec=Domain) + mock_requestor_email = "requestor@example.com" + mock_email = "invitee@example.com" + + # Create mock users and UserDomainRoles + mock_user_1 = MagicMock(spec=User) + mock_user_1.email = "manager1@example.com" + mock_user_2 = MagicMock(spec=User) + mock_user_2.email = "manager2@example.com" + + mock_user_domain_role_1 = MagicMock(spec=UserDomainRole, user=mock_user_1) + mock_user_domain_role_2 = MagicMock(spec=UserDomainRole, user=mock_user_2) + mock_filter.return_value = [mock_user_domain_role_1, mock_user_domain_role_2] + + # Mock first email success and second email failure + mock_send_templated_email.side_effect = [None, EmailSendingError("Failed to send email")] + + # Call function + result = send_emails_to_domain_managers(mock_email, mock_requestor_email, mock_domain) + + # Assertions + self.assertFalse(result) # One email failed, so result should be False + mock_send_templated_email.assert_any_call( + "emails/domain_manager_notification.txt", + "emails/domain_manager_notification_subject.txt", + to_address="manager1@example.com", + context={ + "domain": mock_domain, + "requestor_email": mock_requestor_email, + "invited_email_address": mock_email, + "domain_manager": mock_user_1, + "date": date.today(), + }, + ) + mock_send_templated_email.assert_any_call( + "emails/domain_manager_notification.txt", + "emails/domain_manager_notification_subject.txt", + to_address="manager2@example.com", + context={ + "domain": mock_domain, + "requestor_email": mock_requestor_email, + "invited_email_address": mock_email, + "domain_manager": mock_user_2, + "date": date.today(), + }, + ) diff --git a/src/registrar/tests/test_emails.py b/src/registrar/tests/test_emails.py index e76a6124f..f39f11517 100644 --- a/src/registrar/tests/test_emails.py +++ b/src/registrar/tests/test_emails.py @@ -150,7 +150,7 @@ class TestEmails(TestCase): def test_submission_confirmation_no_current_website_spacing(self): """Test line spacing without current_website.""" domain_request = completed_domain_request( - has_current_website=False, user=User.objects.create(username="test", email="testy@town.com") + current_websites=[], user=User.objects.create(username="test", email="testy@town.com") ) with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): domain_request.submit() @@ -164,9 +164,7 @@ class TestEmails(TestCase): @less_console_noise_decorator def test_submission_confirmation_current_website_spacing(self): """Test line spacing with current_website.""" - domain_request = completed_domain_request( - has_current_website=True, user=User.objects.create(username="test", email="testy@town.com") - ) + domain_request = completed_domain_request(user=User.objects.create(username="test", email="testy@town.com")) with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): domain_request.submit() _, kwargs = self.mock_client.send_email.call_args @@ -218,9 +216,7 @@ class TestEmails(TestCase): @less_console_noise_decorator def test_submission_confirmation_alternative_govdomain_spacing(self): """Test line spacing with alternative .gov domain.""" - domain_request = completed_domain_request( - has_alternative_gov_domain=True, user=User.objects.create(username="test", email="testy@town.com") - ) + domain_request = completed_domain_request(user=User.objects.create(username="test", email="testy@town.com")) with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): domain_request.submit() _, kwargs = self.mock_client.send_email.call_args @@ -234,7 +230,7 @@ class TestEmails(TestCase): def test_submission_confirmation_no_alternative_govdomain_spacing(self): """Test line spacing without alternative .gov domain.""" domain_request = completed_domain_request( - has_alternative_gov_domain=False, user=User.objects.create(username="test", email="testy@town.com") + alternative_domains=[], user=User.objects.create(username="test", email="testy@town.com") ) with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class): domain_request.submit() diff --git a/src/registrar/tests/test_forms.py b/src/registrar/tests/test_forms.py index 12d9af8ac..82e3b40bb 100644 --- a/src/registrar/tests/test_forms.py +++ b/src/registrar/tests/test_forms.py @@ -3,6 +3,7 @@ import json from django.test import TestCase, RequestFactory from api.views import available +from api.tests.common import less_console_noise_decorator from registrar.forms.domain_request_wizard import ( AlternativeDomainForm, @@ -18,7 +19,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 @@ -29,6 +40,7 @@ class TestFormValidation(MockEppLib): self.user = get_user_model().objects.create(username="username") self.factory = RequestFactory() + @less_console_noise_decorator def test_org_contact_zip_invalid(self): form = OrganizationContactForm(data={"zipcode": "nah"}) self.assertEqual( @@ -36,11 +48,13 @@ class TestFormValidation(MockEppLib): ["Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789."], ) + @less_console_noise_decorator def test_org_contact_zip_valid(self): for zipcode in ["12345", "12345-6789"]: form = OrganizationContactForm(data={"zipcode": zipcode}) self.assertNotIn("zipcode", form.errors) + @less_console_noise_decorator def test_website_invalid(self): form = CurrentSitesForm(data={"website": "nah"}) self.assertEqual( @@ -48,33 +62,39 @@ class TestFormValidation(MockEppLib): ["Enter your organization's current website in the required format, like example.com."], ) + @less_console_noise_decorator def test_website_valid(self): form = CurrentSitesForm(data={"website": "hyphens-rule.gov.uk"}) self.assertEqual(len(form.errors), 0) + @less_console_noise_decorator def test_website_scheme_valid(self): form = CurrentSitesForm(data={"website": "http://hyphens-rule.gov.uk"}) self.assertEqual(len(form.errors), 0) form = CurrentSitesForm(data={"website": "https://hyphens-rule.gov.uk"}) self.assertEqual(len(form.errors), 0) + @less_console_noise_decorator def test_requested_domain_valid(self): """Just a valid domain name with no .gov at the end.""" form = DotGovDomainForm(data={"requested_domain": "top-level-agency"}) self.assertEqual(len(form.errors), 0) + @less_console_noise_decorator def test_requested_domain_starting_www(self): """Test a valid domain name with .www at the beginning.""" form = DotGovDomainForm(data={"requested_domain": "www.top-level-agency"}) self.assertEqual(len(form.errors), 0) self.assertEqual(form.cleaned_data["requested_domain"], "top-level-agency") + @less_console_noise_decorator def test_requested_domain_ending_dotgov(self): """Just a valid domain name with .gov at the end.""" form = DotGovDomainForm(data={"requested_domain": "top-level-agency.gov"}) self.assertEqual(len(form.errors), 0) self.assertEqual(form.cleaned_data["requested_domain"], "top-level-agency") + @less_console_noise_decorator def test_requested_domain_ending_dotcom_invalid(self): """don't accept domains ending other than .gov.""" form = DotGovDomainForm(data={"requested_domain": "top-level-agency.com"}) @@ -83,6 +103,7 @@ class TestFormValidation(MockEppLib): ["Enter the .gov domain you want without any periods."], ) + @less_console_noise_decorator def test_requested_domain_errors_consistent(self): """Tests if the errors on submit and with the check availability buttons are consistent for requested_domains @@ -140,6 +161,7 @@ class TestFormValidation(MockEppLib): # for good measure, test if the two objects are equal anyway self.assertEqual([json_error], form_error) + @less_console_noise_decorator def test_alternate_domain_errors_consistent(self): """Tests if the errors on submit and with the check availability buttons are consistent for alternative_domains @@ -190,6 +212,7 @@ class TestFormValidation(MockEppLib): # for good measure, test if the two objects are equal anyway self.assertEqual([json_error], form_error) + @less_console_noise_decorator def test_requested_domain_two_dots_invalid(self): """don't accept domains that are subdomains""" form = DotGovDomainForm(data={"requested_domain": "sub.top-level-agency.gov"}) @@ -208,6 +231,7 @@ class TestFormValidation(MockEppLib): ["Enter the .gov domain you want without any periods."], ) + @less_console_noise_decorator def test_requested_domain_invalid_characters(self): """must be a valid .gov domain name.""" form = DotGovDomainForm(data={"requested_domain": "underscores_forever"}) @@ -216,6 +240,7 @@ class TestFormValidation(MockEppLib): ["Enter a domain using only letters, numbers, or hyphens (though we don't recommend using hyphens)."], ) + @less_console_noise_decorator def test_senior_official_email_invalid(self): """must be a valid email address.""" form = SeniorOfficialForm(data={"email": "boss@boss"}) @@ -224,6 +249,7 @@ class TestFormValidation(MockEppLib): ["Enter an email address in the required format, like name@example.com."], ) + @less_console_noise_decorator def test_purpose_form_character_count_invalid(self): """Response must be less than 2000 characters.""" form = PurposeForm( @@ -271,6 +297,7 @@ class TestFormValidation(MockEppLib): ["Response must be less than 2000 characters."], ) + @less_console_noise_decorator def test_anything_else_form_about_your_organization_character_count_invalid(self): """Response must be less than 2000 characters.""" form = AnythingElseForm( @@ -317,6 +344,7 @@ class TestFormValidation(MockEppLib): ["Response must be less than 2000 characters."], ) + @less_console_noise_decorator def test_anything_else_form_character_count_invalid(self): """Response must be less than 2000 characters.""" form = AboutYourOrganizationForm( @@ -365,6 +393,7 @@ class TestFormValidation(MockEppLib): ["Response must be less than 2000 characters."], ) + @less_console_noise_decorator def test_other_contact_email_invalid(self): """must be a valid email address.""" form = OtherContactsForm(data={"email": "splendid@boss"}) @@ -373,11 +402,13 @@ class TestFormValidation(MockEppLib): ["Enter an email address in the required format, like name@example.com."], ) + @less_console_noise_decorator def test_other_contact_phone_invalid(self): """Must be a valid phone number.""" form = OtherContactsForm(data={"phone": "super@boss"}) self.assertTrue(form.errors["phone"][0].startswith("Enter a valid 10-digit phone number.")) + @less_console_noise_decorator def test_requirements_form_blank(self): """Requirements box unchecked is an error.""" form = RequirementsForm(data={}) @@ -386,6 +417,7 @@ class TestFormValidation(MockEppLib): ["Check the box if you read and agree to the requirements for operating a .gov domain."], ) + @less_console_noise_decorator def test_requirements_form_unchecked(self): """Requirements box unchecked is an error.""" form = RequirementsForm(data={"is_policy_acknowledged": False}) @@ -394,6 +426,7 @@ class TestFormValidation(MockEppLib): ["Check the box if you read and agree to the requirements for operating a .gov domain."], ) + @less_console_noise_decorator def test_tribal_government_unrecognized(self): """Not state or federally recognized is an error.""" form = TribalGovernmentForm(data={"state_recognized": False, "federally_recognized": False}) @@ -401,10 +434,250 @@ class TestFormValidation(MockEppLib): class TestContactForm(TestCase): + @less_console_noise_decorator def test_contact_form_email_invalid(self): form = ContactForm(data={"email": "example.net"}) self.assertEqual(form.errors["email"], ["Enter a valid email address."]) + @less_console_noise_decorator 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: + 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"]) + + @less_console_noise_decorator + 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_permissions": "", # Simulate missing field + "domain_permissions": "", # Simulate missing field + "member_permissions": "", # Simulate missing field + } + + # Check required fields for all forms + self._assert_form_has_error(PortfolioMemberForm, data, "domain_request_permissions") + self._assert_form_has_error(PortfolioMemberForm, data, "domain_permissions") + self._assert_form_has_error(PortfolioMemberForm, data, "member_permissions") + self._assert_form_has_error(PortfolioInvitedMemberForm, data, "domain_request_permissions") + self._assert_form_has_error(PortfolioInvitedMemberForm, data, "domain_permissions") + self._assert_form_has_error(PortfolioInvitedMemberForm, data, "member_permissions") + self._assert_form_has_error(PortfolioNewMemberForm, data, "domain_request_permissions") + self._assert_form_has_error(PortfolioNewMemberForm, data, "domain_permissions") + self._assert_form_has_error(PortfolioNewMemberForm, data, "member_permissions") + + @less_console_noise_decorator + def test_clean_validates_required_fields_for_admin_role(self): + """Test that the `clean` method validates the correct fields for admin 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, + } + + # 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]) + + form = self._assert_form_is_valid(PortfolioInvitedMemberForm, data, portfolio_invitation) + cleaned_data = form.cleaned_data + self.assertEqual(cleaned_data["roles"], [UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value]) + + data = { + "email": "hi@ho.com", + "portfolio": self.portfolio.id, + "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value, + } + + form = self._assert_form_is_valid(PortfolioNewMemberForm, data) + cleaned_data = form.cleaned_data + self.assertEqual(cleaned_data["roles"], [UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value]) + + @less_console_noise_decorator + def test_clean_validates_required_fields_for_basic_role(self): + """Test that the `clean` method validates the correct fields for basic 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_MEMBER.value, + "domain_request_permissions": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, + "domain_permissions": UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value, + "member_permissions": UserPortfolioPermissionChoices.VIEW_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_MEMBER.value]) + self.assertEqual( + cleaned_data["domain_request_permissions"], UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value + ) + self.assertEqual(cleaned_data["domain_permissions"], UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value) + self.assertEqual(cleaned_data["member_permissions"], UserPortfolioPermissionChoices.VIEW_MEMBERS.value) + + form = self._assert_form_is_valid(PortfolioInvitedMemberForm, data, portfolio_invitation) + cleaned_data = form.cleaned_data + self.assertEqual(cleaned_data["roles"], [UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value]) + self.assertEqual( + cleaned_data["domain_request_permissions"], UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value + ) + self.assertEqual(cleaned_data["domain_permissions"], UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value) + self.assertEqual(cleaned_data["member_permissions"], UserPortfolioPermissionChoices.VIEW_MEMBERS.value) + + data = { + "email": "hi@ho.com", + "portfolio": self.portfolio.id, + "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, + "domain_request_permissions": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, + "domain_permissions": UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value, + "member_permissions": UserPortfolioPermissionChoices.VIEW_MEMBERS.value, + } + + form = self._assert_form_is_valid(PortfolioNewMemberForm, data) + cleaned_data = form.cleaned_data + self.assertEqual(cleaned_data["roles"], [UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value]) + self.assertEqual( + cleaned_data["domain_request_permissions"], UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value + ) + self.assertEqual(cleaned_data["domain_permissions"], UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value) + self.assertEqual(cleaned_data["member_permissions"], UserPortfolioPermissionChoices.VIEW_MEMBERS.value) + + @less_console_noise_decorator + 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_permissions": "no_access", # Simulate no access permission + "domain_permissions": UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value, + "member_permissions": UserPortfolioPermissionChoices.VIEW_MEMBERS.value, + } + + form = self._assert_form_is_valid(PortfolioMemberForm, data, user_portfolio_permission) + cleaned_data = form.cleaned_data + self.assertEqual(cleaned_data["domain_request_permissions"], None) + + form = self._assert_form_is_valid(PortfolioInvitedMemberForm, data, portfolio_invitation) + cleaned_data = form.cleaned_data + self.assertEqual(cleaned_data["domain_request_permissions"], None) + + @less_console_noise_decorator + 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], + ) + portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create( + portfolio=self.portfolio, + email="hi@ho", + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + ) + + expected_initial_data = { + "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN, + } + self._assert_initial_data(PortfolioMemberForm, user_portfolio_permission, expected_initial_data) + self._assert_initial_data(PortfolioInvitedMemberForm, portfolio_invitation, expected_initial_data) + + @less_console_noise_decorator + 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_permissions": 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) + + @less_console_noise_decorator + def test_invalid_data_for_member(self): + """Test invalid form submission for a member role with missing permissions.""" + data = { + "email": "hi@ho.com", + "portfolio": self.portfolio.id, + "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, + "domain_request_permissions": "", # Missing field + "member_permissions": "", # Missing field + "domain_permissions": "", # Missing field + } + self._assert_form_has_error(PortfolioMemberForm, data, "domain_request_permissions") + self._assert_form_has_error(PortfolioInvitedMemberForm, data, "member_permissions") diff --git a/src/registrar/tests/test_management_scripts.py b/src/registrar/tests/test_management_scripts.py index 7cce0d2b2..fd53c21f8 100644 --- a/src/registrar/tests/test_management_scripts.py +++ b/src/registrar/tests/test_management_scripts.py @@ -3,7 +3,10 @@ import boto3_mocking # type: ignore from datetime import date, datetime, time from django.core.management import call_command from django.test import TestCase, override_settings +from registrar.models.domain_group import DomainGroup +from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.senior_official import SeniorOfficial +from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.utility.constants import BranchChoices from django.utils import timezone from django.utils.module_loading import import_string @@ -32,7 +35,13 @@ import tablib from unittest.mock import patch, call, MagicMock, mock_open from epplibwrapper import commands, common -from .common import MockEppLib, less_console_noise, completed_domain_request, MockSESClient +from .common import ( + MockEppLib, + less_console_noise, + completed_domain_request, + MockSESClient, + MockDbForIndividualTests, +) from api.tests.common import less_console_noise_decorator @@ -1516,6 +1525,91 @@ class TestCreateFederalPortfolio(TestCase): ): call_command("create_federal_portfolio", **kwargs) + @less_console_noise_decorator + def test_post_process_started_domain_requests_existing_portfolio(self): + """Ensures that federal agency is cleared when agency name matches portfolio name. + As the name implies, this implicitly tests the "post_process_started_domain_requests" function. + """ + federal_agency_2 = FederalAgency.objects.create(agency="Sugarcane", federal_type=BranchChoices.EXECUTIVE) + + # Test records with portfolios and no org names + # Create a portfolio. This script skips over "started" + portfolio = Portfolio.objects.create(organization_name="Sugarcane", creator=self.user) + # Create a domain request with matching org name + matching_request = completed_domain_request( + name="matching.gov", + status=DomainRequest.DomainRequestStatus.STARTED, + generic_org_type=DomainRequest.OrganizationChoices.FEDERAL, + federal_agency=federal_agency_2, + user=self.user, + portfolio=portfolio, + ) + + # Create a request not in started (no change should occur) + matching_request_in_wrong_status = completed_domain_request( + name="kinda-matching.gov", + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + generic_org_type=DomainRequest.OrganizationChoices.FEDERAL, + federal_agency=self.federal_agency, + user=self.user, + ) + + self.run_create_federal_portfolio(agency_name="Sugarcane", parse_requests=True) + self.run_create_federal_portfolio(agency_name="Test Federal Agency", parse_requests=True) + + # Refresh from db + matching_request.refresh_from_db() + matching_request_in_wrong_status.refresh_from_db() + + # Request with matching name should have federal_agency cleared + self.assertIsNone(matching_request.federal_agency) + self.assertIsNotNone(matching_request.portfolio) + self.assertEqual(matching_request.portfolio.organization_name, "Sugarcane") + + # Request with matching name but wrong state should keep its federal agency + self.assertEqual(matching_request_in_wrong_status.federal_agency, self.federal_agency) + self.assertIsNotNone(matching_request_in_wrong_status.portfolio) + self.assertEqual(matching_request_in_wrong_status.portfolio.organization_name, "Test Federal Agency") + + @less_console_noise_decorator + def test_post_process_started_domain_requests(self): + """Tests that federal agency is cleared when agency name + matches an existing portfolio's name, even if the domain request isn't + directly on that portfolio.""" + + federal_agency_2 = FederalAgency.objects.create(agency="Sugarcane", federal_type=BranchChoices.EXECUTIVE) + + # Create a request with matching federal_agency name but no direct portfolio association + matching_agency_request = completed_domain_request( + name="agency-match.gov", + status=DomainRequest.DomainRequestStatus.STARTED, + generic_org_type=DomainRequest.OrganizationChoices.FEDERAL, + federal_agency=federal_agency_2, + user=self.user, + ) + + # Create a control request that shouldn't match + non_matching_request = completed_domain_request( + name="no-match.gov", + status=DomainRequest.DomainRequestStatus.STARTED, + generic_org_type=DomainRequest.OrganizationChoices.FEDERAL, + federal_agency=self.federal_agency, + user=self.user, + ) + + # We expect the matching agency to have its fed agency cleared. + self.run_create_federal_portfolio(agency_name="Sugarcane", parse_requests=True) + matching_agency_request.refresh_from_db() + non_matching_request.refresh_from_db() + + # Request with matching agency name should have federal_agency cleared + self.assertIsNone(matching_agency_request.federal_agency) + + # Non-matching request should keep its federal_agency + self.assertIsNotNone(non_matching_request.federal_agency) + self.assertEqual(non_matching_request.federal_agency, self.federal_agency) + + @less_console_noise_decorator def test_create_single_portfolio(self): """Test portfolio creation with suborg and senior official.""" self.run_create_federal_portfolio(agency_name="Test Federal Agency", parse_requests=True) @@ -1588,6 +1682,34 @@ class TestCreateFederalPortfolio(TestCase): self.assertTrue(all([creator == User.get_default_user() for creator in creators])) self.assertTrue(all([note == "Auto-generated record" for note in notes])) + def test_script_adds_requested_suborganization_information(self): + """Tests that the script adds the requested suborg fields for domain requests""" + # Create a new domain request with some errant spacing + custom_suborg_request = completed_domain_request( + name="custom_org.gov", + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + generic_org_type=DomainRequest.OrganizationChoices.FEDERAL, + federal_agency=self.executive_agency_2, + user=self.user, + organization_name=" requested org name ", + city="Austin ", + state_territory=DomainRequest.StateTerritoryChoices.TEXAS, + ) + + self.assertIsNone(custom_suborg_request.requested_suborganization) + self.assertIsNone(custom_suborg_request.suborganization_city) + self.assertIsNone(custom_suborg_request.suborganization_state_territory) + + # Run the script and test it + self.run_create_federal_portfolio(branch="executive", parse_requests=True) + custom_suborg_request.refresh_from_db() + + self.assertEqual(custom_suborg_request.requested_suborganization, "requested org name") + self.assertEqual(custom_suborg_request.suborganization_city, "Austin") + self.assertEqual( + custom_suborg_request.suborganization_state_territory, DomainRequest.StateTerritoryChoices.TEXAS + ) + def test_create_multiple_portfolios_for_branch_executive(self): """Tests creating all portfolios under a given branch""" federal_choice = DomainRequest.OrganizationChoices.FEDERAL @@ -1709,7 +1831,7 @@ class TestCreateFederalPortfolio(TestCase): self.run_create_federal_portfolio(agency_name="Non-existent Agency", parse_requests=True) def test_does_not_update_existing_portfolio(self): - """Tests that an existing portfolio is not updated""" + """Tests that an existing portfolio is not updated when""" # Create an existing portfolio existing_portfolio = Portfolio.objects.create( federal_agency=self.federal_agency, @@ -1731,3 +1853,504 @@ class TestCreateFederalPortfolio(TestCase): self.assertEqual(existing_portfolio.organization_name, self.federal_agency.agency) self.assertEqual(existing_portfolio.notes, "Old notes") self.assertEqual(existing_portfolio.creator, self.user) + + def test_skip_existing_portfolios(self): + """Tests the skip_existing_portfolios to ensure that it doesn't add + suborgs, domain requests, and domain info.""" + # Create an existing portfolio with a suborganization + existing_portfolio = Portfolio.objects.create( + federal_agency=self.federal_agency, + organization_name="Test Federal Agency", + organization_type=DomainRequest.OrganizationChoices.CITY, + creator=self.user, + notes="Old notes", + ) + + existing_suborg = Suborganization.objects.create( + portfolio=existing_portfolio, name="Existing Suborg", city="Old City", state_territory="CA" + ) + + # Create a domain request that would normally be associated + domain_request = completed_domain_request( + name="wackytaco.gov", + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + generic_org_type=DomainRequest.OrganizationChoices.FEDERAL, + federal_agency=self.federal_agency, + user=self.user, + organization_name="would_create_suborg", + ) + domain_request.approve() + domain = Domain.objects.get(name="wackytaco.gov").domain_info + + # Run the command with skip_existing_portfolios=True + self.run_create_federal_portfolio( + agency_name="Test Federal Agency", parse_requests=True, skip_existing_portfolios=True + ) + + # Refresh objects from database + existing_portfolio.refresh_from_db() + existing_suborg.refresh_from_db() + domain_request.refresh_from_db() + domain.refresh_from_db() + + # Verify nothing was changed on the portfolio itself + # SANITY CHECK: if the portfolio updates, it will change to FEDERAL. + # if this case fails, it means we are overriding data (and not simply just other weirdness) + self.assertNotEqual(existing_portfolio.organization_type, DomainRequest.OrganizationChoices.FEDERAL) + + # Notes and creator should be untouched + self.assertEqual(existing_portfolio.organization_type, DomainRequest.OrganizationChoices.CITY) + self.assertEqual(existing_portfolio.organization_name, self.federal_agency.agency) + self.assertEqual(existing_portfolio.notes, "Old notes") + self.assertEqual(existing_portfolio.creator, self.user) + + # Verify suborganization wasn't modified + self.assertEqual(existing_suborg.city, "Old City") + self.assertEqual(existing_suborg.state_territory, "CA") + + # Verify that the domain request wasn't modified + self.assertIsNone(domain_request.portfolio) + self.assertIsNone(domain_request.sub_organization) + + # Verify that the domain wasn't modified + self.assertIsNone(domain.portfolio) + self.assertIsNone(domain.sub_organization) + + # Verify that a new suborg wasn't created + self.assertFalse(Suborganization.objects.filter(name="would_create_suborg").exists()) + + @less_console_noise_decorator + def test_post_process_suborganization_fields(self): + """Test suborganization field updates from domain and request data. + Also tests the priority order for updating city and state_territory: + 1. Domain information fields + 2. Domain request suborganization fields + 3. Domain request standard fields + """ + # Create test data with different field combinations + self.domain_info.organization_name = "super" + self.domain_info.city = "Domain City " + self.domain_info.state_territory = "NY" + self.domain_info.save() + + self.domain_request.organization_name = "super" + self.domain_request.suborganization_city = "Request Suborg City" + self.domain_request.suborganization_state_territory = "CA" + self.domain_request.city = "Request City" + self.domain_request.state_territory = "TX" + self.domain_request.save() + + # Create another request/info pair without domain info data + self.domain_info_2.organization_name = "creative" + self.domain_info_2.city = None + self.domain_info_2.state_territory = None + self.domain_info_2.save() + + self.domain_request_2.organization_name = "creative" + self.domain_request_2.suborganization_city = "Second Suborg City" + self.domain_request_2.suborganization_state_territory = "WA" + self.domain_request_2.city = "Second City" + self.domain_request_2.state_territory = "OR" + self.domain_request_2.save() + + # Create a third request/info pair without suborg data + self.domain_info_3.organization_name = "names" + self.domain_info_3.city = None + self.domain_info_3.state_territory = None + self.domain_info_3.save() + + self.domain_request_3.organization_name = "names" + self.domain_request_3.suborganization_city = None + self.domain_request_3.suborganization_state_territory = None + self.domain_request_3.city = "Third City" + self.domain_request_3.state_territory = "FL" + self.domain_request_3.save() + + # Test running the script with both, and just with parse_requests + self.run_create_federal_portfolio(agency_name="Test Federal Agency", parse_requests=True, parse_domains=True) + self.run_create_federal_portfolio( + agency_name="Executive Agency 1", + parse_requests=True, + ) + + self.domain_info.refresh_from_db() + self.domain_request.refresh_from_db() + self.domain_info_2.refresh_from_db() + self.domain_request_2.refresh_from_db() + self.domain_info_3.refresh_from_db() + self.domain_request_3.refresh_from_db() + + # Verify suborganizations were created with correct field values + # Should use domain info values + suborg_1 = Suborganization.objects.get(name=self.domain_info.organization_name) + self.assertEqual(suborg_1.city, "Domain City") + self.assertEqual(suborg_1.state_territory, "NY") + + # Should use domain request suborg values + suborg_2 = Suborganization.objects.get(name=self.domain_info_2.organization_name) + self.assertEqual(suborg_2.city, "Second Suborg City") + self.assertEqual(suborg_2.state_territory, "WA") + + # Should use domain request standard values + suborg_3 = Suborganization.objects.get(name=self.domain_info_3.organization_name) + self.assertEqual(suborg_3.city, "Third City") + self.assertEqual(suborg_3.state_territory, "FL") + + @less_console_noise_decorator + def test_post_process_suborganization_fields_duplicate_records(self): + """Test suborganization field updates when multiple domains/requests exist for the same org. + Tests that: + 1. City / state_territory us updated when all location info matches + 2. Updates are skipped when locations don't match + 3. Priority order is maintained across multiple records: + a. Domain information fields + b. Domain request suborganization fields + c. Domain request standard fields + """ + # Case 1: Multiple records with all fields matching + matching_request_1 = completed_domain_request( + name="matching1.gov", + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + organization_name="matching org", + city="Standard City", + state_territory=DomainRequest.StateTerritoryChoices.TEXAS, + suborganization_city="Suborg City", + suborganization_state_territory=DomainRequest.StateTerritoryChoices.CALIFORNIA, + federal_agency=self.federal_agency, + ) + matching_request_1.approve() + domain_info_1 = DomainInformation.objects.get(domain_request=matching_request_1) + domain_info_1.city = "Domain Info City" + domain_info_1.state_territory = DomainRequest.StateTerritoryChoices.NEW_YORK + domain_info_1.save() + + matching_request_2 = completed_domain_request( + name="matching2.gov", + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + organization_name="matching org", + city="Standard City", + state_territory=DomainRequest.StateTerritoryChoices.TEXAS, + suborganization_city="Suborg City", + suborganization_state_territory=DomainRequest.StateTerritoryChoices.CALIFORNIA, + federal_agency=self.federal_agency, + ) + matching_request_2.approve() + domain_info_2 = DomainInformation.objects.get(domain_request=matching_request_2) + domain_info_2.city = "Domain Info City" + domain_info_2.state_territory = DomainRequest.StateTerritoryChoices.NEW_YORK + domain_info_2.save() + + # Case 2: Multiple records with only request fields (no domain info) + request_only_1 = completed_domain_request( + name="request1.gov", + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + organization_name="request org", + city="Standard City", + state_territory=DomainRequest.StateTerritoryChoices.TEXAS, + suborganization_city="Suborg City", + suborganization_state_territory=DomainRequest.StateTerritoryChoices.CALIFORNIA, + federal_agency=self.federal_agency, + ) + request_only_1.approve() + domain_info_3 = DomainInformation.objects.get(domain_request=request_only_1) + domain_info_3.city = None + domain_info_3.state_territory = None + domain_info_3.save() + + request_only_2 = completed_domain_request( + name="request2.gov", + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + organization_name="request org", + city="Standard City", + state_territory=DomainRequest.StateTerritoryChoices.TEXAS, + suborganization_city="Suborg City", + suborganization_state_territory=DomainRequest.StateTerritoryChoices.CALIFORNIA, + federal_agency=self.federal_agency, + ) + request_only_2.approve() + domain_info_4 = DomainInformation.objects.get(domain_request=request_only_2) + domain_info_4.city = None + domain_info_4.state_territory = None + domain_info_4.save() + + # Case 3: Multiple records with only standard fields (no suborg) + standard_only_1 = completed_domain_request( + name="standard1.gov", + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + organization_name="standard org", + city="Standard City", + state_territory=DomainRequest.StateTerritoryChoices.TEXAS, + federal_agency=self.federal_agency, + ) + standard_only_1.approve() + domain_info_5 = DomainInformation.objects.get(domain_request=standard_only_1) + domain_info_5.city = None + domain_info_5.state_territory = None + domain_info_5.save() + + standard_only_2 = completed_domain_request( + name="standard2.gov", + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + organization_name="standard org", + city="Standard City", + state_territory=DomainRequest.StateTerritoryChoices.TEXAS, + federal_agency=self.federal_agency, + ) + standard_only_2.approve() + domain_info_6 = DomainInformation.objects.get(domain_request=standard_only_2) + domain_info_6.city = None + domain_info_6.state_territory = None + domain_info_6.save() + + # Case 4: Multiple records with mismatched locations + mismatch_request_1 = completed_domain_request( + name="mismatch1.gov", + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + organization_name="mismatch org", + city="City One", + state_territory=DomainRequest.StateTerritoryChoices.FLORIDA, + federal_agency=self.federal_agency, + ) + mismatch_request_1.approve() + domain_info_5 = DomainInformation.objects.get(domain_request=mismatch_request_1) + domain_info_5.city = "Different City" + domain_info_5.state_territory = DomainRequest.StateTerritoryChoices.ALASKA + domain_info_5.save() + + mismatch_request_2 = completed_domain_request( + name="mismatch2.gov", + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + organization_name="mismatch org", + city="City Two", + state_territory=DomainRequest.StateTerritoryChoices.HAWAII, + federal_agency=self.federal_agency, + ) + mismatch_request_2.approve() + domain_info_6 = DomainInformation.objects.get(domain_request=mismatch_request_2) + domain_info_6.city = "Another City" + domain_info_6.state_territory = DomainRequest.StateTerritoryChoices.CALIFORNIA + domain_info_6.save() + + # Run the portfolio creation script + self.run_create_federal_portfolio(agency_name="Test Federal Agency", parse_requests=True, parse_domains=True) + + # Case 1: Should use domain info values (highest priority) + matching_suborg = Suborganization.objects.get(name="matching org") + self.assertEqual(matching_suborg.city, "Domain Info City") + self.assertEqual(matching_suborg.state_territory, DomainRequest.StateTerritoryChoices.NEW_YORK) + + # Case 2: Should use suborg values (second priority) + request_suborg = Suborganization.objects.get(name="request org") + self.assertEqual(request_suborg.city, "Suborg City") + self.assertEqual(request_suborg.state_territory, DomainRequest.StateTerritoryChoices.CALIFORNIA) + + # Case 3: Should use standard values (lowest priority) + standard_suborg = Suborganization.objects.get(name="standard org") + self.assertEqual(standard_suborg.city, "Standard City") + self.assertEqual(standard_suborg.state_territory, DomainRequest.StateTerritoryChoices.TEXAS) + + # Case 4: Should skip update due to mismatched locations + mismatch_suborg = Suborganization.objects.get(name="mismatch org") + self.assertIsNone(mismatch_suborg.city) + self.assertIsNone(mismatch_suborg.state_territory) + + +class TestPatchSuborganizations(MockDbForIndividualTests): + """Tests for the patch_suborganizations management command.""" + + @less_console_noise_decorator + def run_patch_suborganizations(self): + """Helper method to run the patch_suborganizations command.""" + with patch( + "registrar.management.commands.utility.terminal_helper.TerminalHelper.prompt_for_execution", + return_value=True, + ): + call_command("patch_suborganizations") + + @less_console_noise_decorator + def test_space_and_case_duplicates(self): + """Test cleaning up duplicates that differ by spaces and case. + + Should keep the version with: + 1. Fewest spaces + 2. Most leading capitals + """ + # Delete any other suborganizations defined in the initial test dataset + DomainRequest.objects.all().delete() + Suborganization.objects.all().delete() + + Suborganization.objects.create(name="Test Organization ", portfolio=self.portfolio_1) + Suborganization.objects.create(name="test organization", portfolio=self.portfolio_1) + Suborganization.objects.create(name="Test Organization", portfolio=self.portfolio_1) + + # Create an unrelated record to test that it doesn't get deleted, too + Suborganization.objects.create(name="unrelated org", portfolio=self.portfolio_1) + self.run_patch_suborganizations() + self.assertEqual(Suborganization.objects.count(), 2) + self.assertEqual(Suborganization.objects.filter(name__in=["unrelated org", "Test Organization"]).count(), 2) + + @less_console_noise_decorator + def test_hardcoded_record(self): + """Tests that our hardcoded records update as we expect them to""" + # Delete any other suborganizations defined in the initial test dataset + DomainRequest.objects.all().delete() + Suborganization.objects.all().delete() + + # Create orgs with old and new name formats + old_name = "USDA/OC" + new_name = "USDA, Office of Communications" + + Suborganization.objects.create(name=old_name, portfolio=self.portfolio_1) + Suborganization.objects.create(name=new_name, portfolio=self.portfolio_1) + + self.run_patch_suborganizations() + + # Verify only the new one of the two remains + self.assertEqual(Suborganization.objects.count(), 1) + remaining = Suborganization.objects.first() + self.assertEqual(remaining.name, new_name) + + @less_console_noise_decorator + def test_reference_updates(self): + """Test that references are updated on domain info and domain request before deletion.""" + # Create suborganizations + keep_org = Suborganization.objects.create(name="Test Organization", portfolio=self.portfolio_1) + delete_org = Suborganization.objects.create(name="test organization ", portfolio=self.portfolio_1) + unrelated_org = Suborganization.objects.create(name="awesome", portfolio=self.portfolio_1) + + # We expect these references to update + self.domain_request_1.sub_organization = delete_org + self.domain_information_1.sub_organization = delete_org + self.domain_request_1.save() + self.domain_information_1.save() + + # But not these ones + self.domain_request_2.sub_organization = unrelated_org + self.domain_information_2.sub_organization = unrelated_org + self.domain_request_2.save() + self.domain_information_2.save() + + self.run_patch_suborganizations() + + self.domain_request_1.refresh_from_db() + self.domain_information_1.refresh_from_db() + self.domain_request_2.refresh_from_db() + self.domain_information_2.refresh_from_db() + + self.assertEqual(self.domain_request_1.sub_organization, keep_org) + self.assertEqual(self.domain_information_1.sub_organization, keep_org) + self.assertEqual(self.domain_request_2.sub_organization, unrelated_org) + self.assertEqual(self.domain_information_2.sub_organization, unrelated_org) + + +class TestRemovePortfolios(TestCase): + """Test the remove_unused_portfolios command""" + + def setUp(self): + self.user = User.objects.create(username="testuser") + + self.logger_patcher = patch("registrar.management.commands.export_tables.logger") + self.logger_mock = self.logger_patcher.start() + + # Create mock database objects + self.portfolio_ok = Portfolio.objects.create( + organization_name="Department of Veterans Affairs", creator=self.user + ) + self.unused_portfolio_with_related_objects = Portfolio.objects.create( + organization_name="Test with orphaned objects", creator=self.user + ) + self.unused_portfolio_with_suborgs = Portfolio.objects.create( + organization_name="Test with suborg", creator=self.user + ) + + # Create related objects for unused_portfolio_with_related_objects + self.domain_information = DomainInformation.objects.create( + portfolio=self.unused_portfolio_with_related_objects, creator=self.user + ) + self.domain_request = DomainRequest.objects.create( + portfolio=self.unused_portfolio_with_related_objects, creator=self.user + ) + self.inv = PortfolioInvitation.objects.create(portfolio=self.unused_portfolio_with_related_objects) + self.group = DomainGroup.objects.create( + portfolio=self.unused_portfolio_with_related_objects, name="Test Domain Group" + ) + self.perm = UserPortfolioPermission.objects.create( + portfolio=self.unused_portfolio_with_related_objects, user=self.user + ) + + # Create a suborganization and suborg related objects for unused_portfolio_with_suborgs + self.suborganization = Suborganization.objects.create( + portfolio=self.unused_portfolio_with_suborgs, name="Test Suborg" + ) + self.suborg_domain_information = DomainInformation.objects.create( + sub_organization=self.suborganization, creator=self.user + ) + + def tearDown(self): + self.logger_patcher.stop() + DomainInformation.objects.all().delete() + DomainRequest.objects.all().delete() + Suborganization.objects.all().delete() + Portfolio.objects.all().delete() + User.objects.all().delete() + + @patch("registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no") + def test_delete_unlisted_portfolios(self, mock_query_yes_no): + """Test that portfolios not on the allowed list are deleted.""" + mock_query_yes_no.return_value = True + + # Ensure all portfolios exist before running the command + self.assertEqual(Portfolio.objects.count(), 3) + + # Run the command + call_command("remove_unused_portfolios", debug=False) + + # Check that the unlisted portfolio was removed + self.assertEqual(Portfolio.objects.count(), 1) + self.assertFalse(Portfolio.objects.filter(organization_name="Test with orphaned objects").exists()) + self.assertFalse(Portfolio.objects.filter(organization_name="Test with suborg").exists()) + self.assertTrue(Portfolio.objects.filter(organization_name="Department of Veterans Affairs").exists()) + + @patch("registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no") + def test_delete_entries_with_related_objects(self, mock_query_yes_no): + """Test deletion with related objects being handled properly.""" + mock_query_yes_no.return_value = True + + # Ensure related objects exist before running the command + self.assertEqual(DomainInformation.objects.count(), 2) + self.assertEqual(DomainRequest.objects.count(), 1) + + # Run the command + call_command("remove_unused_portfolios", debug=False) + + # Check that related objects were updated + self.assertEqual( + DomainInformation.objects.filter(portfolio=self.unused_portfolio_with_related_objects).count(), 0 + ) + self.assertEqual(DomainRequest.objects.filter(portfolio=self.unused_portfolio_with_related_objects).count(), 0) + self.assertEqual(DomainInformation.objects.filter(portfolio=None).count(), 2) + self.assertEqual(DomainRequest.objects.filter(portfolio=None).count(), 1) + + # Check that the portfolio was deleted + self.assertFalse(Portfolio.objects.filter(organization_name="Test with orphaned objects").exists()) + + @patch("registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no") + def test_delete_entries_with_suborganizations(self, mock_query_yes_no): + """Test that suborganizations and their related objects are deleted along with the portfolio.""" + mock_query_yes_no.return_value = True + + # Ensure suborganization and related objects exist before running the command + self.assertEqual(Suborganization.objects.count(), 1) + self.assertEqual(DomainInformation.objects.filter(sub_organization=self.suborganization).count(), 1) + + # Run the command + call_command("remove_unused_portfolios", debug=False) + + # Check that the suborganization was deleted + self.assertEqual(Suborganization.objects.filter(portfolio=self.unused_portfolio_with_suborgs).count(), 0) + + # Check that deletion of suborganization had cascading effects (orphaned DomainInformation) + self.assertEqual(DomainInformation.objects.filter(sub_organization=self.suborganization).count(), 0) + + # Check that the portfolio was deleted + self.assertFalse(Portfolio.objects.filter(organization_name="Test with suborg").exists()) diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index 46604a44a..ef811e083 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -28,6 +28,7 @@ from registrar.models.verified_by_staff import VerifiedByStaff # type: ignore from .common import ( MockSESClient, completed_domain_request, + create_superuser, create_test_user, ) from waffle.testutils import override_flag @@ -155,6 +156,7 @@ class TestPortfolioInvitations(TestCase): roles=[self.portfolio_role_base, self.portfolio_role_admin], additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2], ) + self.superuser = create_superuser() def tearDown(self): super().tearDown() @@ -294,10 +296,158 @@ class TestPortfolioInvitations(TestCase): # Verify self.assertEquals(self.invitation.get_portfolio_permissions(), perm_list) + @less_console_noise_decorator + @override_flag("multiple_portfolios", active=False) + def test_clean_multiple_portfolios_inactive(self): + """Tests that users cannot have multiple portfolios or invitations when flag is inactive""" + # Create the first portfolio permission + UserPortfolioPermission.objects.create( + user=self.superuser, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + + # Test a second portfolio permission object (should fail) + second_portfolio = Portfolio.objects.create(organization_name="Second Portfolio", creator=self.superuser) + second_permission = UserPortfolioPermission( + user=self.superuser, portfolio=second_portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + + with self.assertRaises(ValidationError) as err: + second_permission.clean() + self.assertIn("users cannot be assigned to multiple portfolios", str(err.exception)) + + # Test that adding a new portfolio invitation also fails + third_portfolio = Portfolio.objects.create(organization_name="Third Portfolio", creator=self.superuser) + invitation = PortfolioInvitation( + email=self.superuser.email, portfolio=third_portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + + with self.assertRaises(ValidationError) as err: + invitation.clean() + self.assertIn("users cannot be assigned to multiple portfolios", str(err.exception)) + + @less_console_noise_decorator + @override_flag("multiple_portfolios", active=True) + def test_clean_multiple_portfolios_active(self): + """Tests that users can have multiple portfolios and invitations when flag is active""" + # Create first portfolio permission + UserPortfolioPermission.objects.create( + user=self.superuser, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + + # Second portfolio permission should succeed + second_portfolio = Portfolio.objects.create(organization_name="Second Portfolio", creator=self.superuser) + second_permission = UserPortfolioPermission( + user=self.superuser, portfolio=second_portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + second_permission.clean() + second_permission.save() + + # Verify both permissions exist + user_permissions = UserPortfolioPermission.objects.filter(user=self.superuser) + self.assertEqual(user_permissions.count(), 2) + + # Portfolio invitation should also succeed + third_portfolio = Portfolio.objects.create(organization_name="Third Portfolio", creator=self.superuser) + invitation = PortfolioInvitation( + email=self.superuser.email, portfolio=third_portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + invitation.clean() + invitation.save() + + # Verify invitation exists + self.assertTrue( + PortfolioInvitation.objects.filter( + email=self.superuser.email, + portfolio=third_portfolio, + ).exists() + ) + + @less_console_noise_decorator + def test_clean_portfolio_invitation(self): + """Tests validation of portfolio invitation permissions""" + + # Test validation fails when portfolio missing but permissions present + invitation = PortfolioInvitation(email="test@example.com", roles=["organization_admin"], portfolio=None) + with self.assertRaises(ValidationError) as err: + invitation.clean() + self.assertEqual( + str(err.exception), + "When portfolio roles or additional permissions are assigned, portfolio is required.", + ) + + # Test validation fails when portfolio present but no permissions + invitation = PortfolioInvitation(email="test@example.com", roles=None, portfolio=self.portfolio) + with self.assertRaises(ValidationError) as err: + invitation.clean() + self.assertEqual( + str(err.exception), + "When portfolio is assigned, portfolio roles or additional permissions are required.", + ) + + # Test validation fails with forbidden permissions + forbidden_member_roles = UserPortfolioPermission.FORBIDDEN_PORTFOLIO_ROLE_PERMISSIONS.get( + UserPortfolioRoleChoices.ORGANIZATION_MEMBER + ) + invitation = PortfolioInvitation( + email="test@example.com", + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + additional_permissions=forbidden_member_roles, + portfolio=self.portfolio, + ) + with self.assertRaises(ValidationError) as err: + invitation.clean() + self.assertEqual( + str(err.exception), + "These permissions cannot be assigned to Member: " + "", + ) + + @less_console_noise_decorator + @override_flag("multiple_portfolios", active=False) + def test_clean_user_portfolio_permission_multiple_portfolios_flag_off_and_duplicate_permission(self): + """MISSING TEST: Test validation of multiple_portfolios flag. + Scenario 1: Flag is inactive, and the user has existing portfolio permissions + + NOTE: Refer to the same test under TestUserPortfolioPermission""" + + pass + + @less_console_noise_decorator + @override_flag("multiple_portfolios", active=False) + def test_clean_user_portfolio_permission_multiple_portfolios_flag_off_and_existing_invitation(self): + """MISSING TEST: Test validation of multiple_portfolios flag. + Scenario 2: Flag is inactive, and the user has existing portfolio invitation to another portfolio + + NOTE: Refer to the same test under TestUserPortfolioPermission""" + + pass + + @less_console_noise_decorator + @override_flag("multiple_portfolios", active=True) + def test_clean_user_portfolio_permission_multiple_portfolios_flag_on_and_duplicate_permission(self): + """MISSING TEST: Test validation of multiple_portfolios flag. + Scenario 3: Flag is active, and the user has existing portfolio invitation + + NOTE: Refer to the same test under TestUserPortfolioPermission""" + + pass + + @less_console_noise_decorator + @override_flag("multiple_portfolios", active=True) + def test_clean_user_portfolio_permission_multiple_portfolios_flag_on_and_existing_invitation(self): + """MISSING TEST: Test validation of multiple_portfolios flag. + Scenario 4: Flag is active, and the user has existing portfolio invitation to another portfolio + + NOTE: Refer to the same test under TestUserPortfolioPermission""" + + pass + class TestUserPortfolioPermission(TestCase): @less_console_noise_decorator def setUp(self): + self.superuser = create_superuser() + self.portfolio = Portfolio.objects.create(organization_name="Test Portfolio", creator=self.superuser) self.user, _ = User.objects.get_or_create(email="mayor@igorville.gov") self.user2, _ = User.objects.get_or_create(email="user2@igorville.gov", username="user2") super().setUp() @@ -311,6 +461,7 @@ class TestUserPortfolioPermission(TestCase): Portfolio.objects.all().delete() User.objects.all().delete() UserDomainRole.objects.all().delete() + PortfolioInvitation.objects.all().delete() @less_console_noise_decorator @override_flag("multiple_portfolios", active=True) @@ -427,6 +578,178 @@ class TestUserPortfolioPermission(TestCase): # Assert self.assertEqual(portfolio_permission.get_managed_domains_count(), 1) + @less_console_noise_decorator + def test_clean_user_portfolio_permission(self): + """Tests validation of user portfolio permission""" + + # Test validation fails when portfolio missing but permissions are present + permission = UserPortfolioPermission(user=self.superuser, roles=["organization_admin"], portfolio=None) + with self.assertRaises(ValidationError) as err: + permission.clean() + self.assertEqual( + str(err.exception), + "When portfolio roles or additional permissions are assigned, portfolio is required.", + ) + + # Test validation fails when portfolio present but no permissions are present + permission = UserPortfolioPermission(user=self.superuser, roles=None, portfolio=self.portfolio) + with self.assertRaises(ValidationError) as err: + permission.clean() + self.assertEqual( + str(err.exception), + "When portfolio is assigned, portfolio roles or additional permissions are required.", + ) + + # Test validation fails with forbidden permissions for single role + forbidden_member_roles = UserPortfolioPermission.FORBIDDEN_PORTFOLIO_ROLE_PERMISSIONS.get( + UserPortfolioRoleChoices.ORGANIZATION_MEMBER + ) + permission = UserPortfolioPermission( + user=self.superuser, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + additional_permissions=forbidden_member_roles, + portfolio=self.portfolio, + ) + with self.assertRaises(ValidationError) as err: + permission.clean() + self.assertEqual( + str(err.exception), + "These permissions cannot be assigned to Member: " + "", + ) + + @less_console_noise_decorator + @override_flag("multiple_portfolios", active=False) + def test_clean_user_portfolio_permission_multiple_portfolios_flag_off_and_duplicate_permission(self): + """Test validation of multiple_portfolios flag. + Scenario 1: Flag is inactive, and the user has existing portfolio permissions""" + + # existing permission + UserPortfolioPermission.objects.create( + user=self.superuser, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + portfolio=self.portfolio, + ) + + permission = UserPortfolioPermission( + user=self.superuser, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + portfolio=self.portfolio, + ) + + with self.assertRaises(ValidationError) as err: + permission.clean() + + self.assertEqual( + str(err.exception.messages[0]), + "This user is already assigned to a portfolio. " + "Based on current waffle flag settings, users cannot be assigned to multiple portfolios.", + ) + + @less_console_noise_decorator + @override_flag("multiple_portfolios", active=False) + def test_clean_user_portfolio_permission_multiple_portfolios_flag_off_and_existing_invitation(self): + """Test validation of multiple_portfolios flag. + Scenario 2: Flag is inactive, and the user has existing portfolio invitation to another portfolio""" + + portfolio2 = Portfolio.objects.create(creator=self.superuser, organization_name="Joey go away") + + PortfolioInvitation.objects.create( + email=self.superuser.email, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], portfolio=portfolio2 + ) + + permission = UserPortfolioPermission( + user=self.superuser, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + portfolio=self.portfolio, + ) + + with self.assertRaises(ValidationError) as err: + permission.clean() + + self.assertEqual( + str(err.exception.messages[0]), + "This user is already assigned to a portfolio invitation. " + "Based on current waffle flag settings, users cannot be assigned to multiple portfolios.", + ) + + @less_console_noise_decorator + @override_flag("multiple_portfolios", active=True) + def test_clean_user_portfolio_permission_multiple_portfolios_flag_on_and_duplicate_permission(self): + """Test validation of multiple_portfolios flag. + Scenario 3: Flag is active, and the user has existing portfolio invitation""" + + # existing permission + UserPortfolioPermission.objects.create( + user=self.superuser, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + portfolio=self.portfolio, + ) + + permission = UserPortfolioPermission( + user=self.superuser, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + portfolio=self.portfolio, + ) + + # Should not raise any exceptions + try: + permission.clean() + except ValidationError: + self.fail("ValidationError was raised unexpectedly when flag is active.") + + @less_console_noise_decorator + @override_flag("multiple_portfolios", active=True) + def test_clean_user_portfolio_permission_multiple_portfolios_flag_on_and_existing_invitation(self): + """Test validation of multiple_portfolios flag. + Scenario 4: Flag is active, and the user has existing portfolio invitation to another portfolio""" + + portfolio2 = Portfolio.objects.create(creator=self.superuser, organization_name="Joey go away") + + PortfolioInvitation.objects.create( + email=self.superuser.email, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], portfolio=portfolio2 + ) + + permission = UserPortfolioPermission( + user=self.superuser, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + portfolio=self.portfolio, + ) + + # Should not raise any exceptions + try: + permission.clean() + except ValidationError: + self.fail("ValidationError was raised unexpectedly when flag is active.") + + @less_console_noise_decorator + def test_get_forbidden_permissions_with_multiple_roles(self): + """Tests that forbidden permissions are properly handled when a user has multiple roles""" + # Get forbidden permissions for member role + member_forbidden = UserPortfolioPermission.FORBIDDEN_PORTFOLIO_ROLE_PERMISSIONS.get( + UserPortfolioRoleChoices.ORGANIZATION_MEMBER + ) + + # Test with both admin and member roles + roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN, UserPortfolioRoleChoices.ORGANIZATION_MEMBER] + + # These permissions would be forbidden for member alone, but should be allowed + # when combined with admin role + permissions = UserPortfolioPermission.get_forbidden_permissions( + roles=roles, additional_permissions=member_forbidden + ) + + # Should return empty set since no permissions are commonly forbidden between admin and member + self.assertEqual(permissions, set()) + + # Verify the same permissions are forbidden when only member role is present + member_only_permissions = UserPortfolioPermission.get_forbidden_permissions( + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], additional_permissions=member_forbidden + ) + + # Should return the forbidden permissions for member role + self.assertEqual(member_only_permissions, set(member_forbidden)) + class TestUser(TestCase): """Test actions that occur on user login, @@ -1750,13 +2073,18 @@ class TestPortfolio(TestCase): self.user, _ = User.objects.get_or_create( username="intern@igorville.com", email="intern@igorville.com", first_name="Lava", last_name="World" ) + self.non_federal_agency, _ = FederalAgency.objects.get_or_create(agency="Non-Federal Agency") + self.federal_agency, _ = FederalAgency.objects.get_or_create(agency="Federal Agency") super().setUp() def tearDown(self): super().tearDown() Portfolio.objects.all().delete() + self.federal_agency.delete() + # not deleting non_federal_agency so as not to interfere potentially with other tests User.objects.all().delete() + @less_console_noise_decorator def test_urbanization_field_resets_when_not_puetro_rico(self): """The urbanization field should only be populated when the state is puetro rico. Otherwise, this field should be empty.""" @@ -1777,6 +2105,7 @@ class TestPortfolio(TestCase): self.assertEqual(portfolio.urbanization, None) self.assertEqual(portfolio.state_territory, DomainRequest.StateTerritoryChoices.ALABAMA) + @less_console_noise_decorator def test_can_add_urbanization_field(self): """Ensures that you can populate the urbanization field when conditions are right""" # Create a portfolio that cannot have this field @@ -1798,6 +2127,32 @@ class TestPortfolio(TestCase): self.assertEqual(portfolio.urbanization, "test123") self.assertEqual(portfolio.state_territory, DomainRequest.StateTerritoryChoices.PUERTO_RICO) + @less_console_noise_decorator + def test_organization_name_updates_for_federal_agency(self): + # Create a Portfolio instance with a federal agency + portfolio = Portfolio( + creator=self.user, + organization_type=DomainRequest.OrganizationChoices.FEDERAL, + federal_agency=self.federal_agency, + ) + portfolio.save() + + # Assert that organization_name is updated to the federal agency's name + self.assertEqual(portfolio.organization_name, "Federal Agency") + + @less_console_noise_decorator + def test_organization_name_does_not_update_for_non_federal_agency(self): + # Create a Portfolio instance with a non-federal agency + portfolio = Portfolio( + creator=self.user, + organization_type=DomainRequest.OrganizationChoices.FEDERAL, + federal_agency=self.non_federal_agency, + ) + portfolio.save() + + # Assert that organization_name remains None + self.assertIsNone(portfolio.organization_name) + class TestAllowedEmail(TestCase): """Tests our allowed email whitelist""" diff --git a/src/registrar/tests/test_models_domain.py b/src/registrar/tests/test_models_domain.py index 1aa08ffe4..083725a55 100644 --- a/src/registrar/tests/test_models_domain.py +++ b/src/registrar/tests/test_models_domain.py @@ -7,7 +7,7 @@ This file tests the various ways in which the registrar interacts with the regis from django.test import TestCase from django.db.utils import IntegrityError from unittest.mock import MagicMock, patch, call -import datetime +from datetime import datetime, date, timedelta from django.utils.timezone import make_aware from api.tests.common import less_console_noise_decorator from registrar.models import Domain, Host, HostIP @@ -349,6 +349,70 @@ class TestDomainCache(MockEppLib): class TestDomainCreation(MockEppLib): """Rule: An approved domain request must result in a domain""" + @less_console_noise_decorator + def test_get_or_create_public_contact_race_condition(self): + """ + Scenario: Two processes try to create the same security contact simultaneously + Given a domain in UNKNOWN state + When a race condition occurs during contact creation + Then no IntegrityError is raised + And only one security contact exists in database + And the correct public contact is returned + + CONTEXT: We ran into an intermittent but somewhat rare issue where IntegrityError + was raised when creating PublicContact. + Per our logs, this seemed to appear during periods of high app activity. + """ + domain, _ = Domain.objects.get_or_create(name="defaultsecurity.gov") + + self.first_call = True + + def mock_filter(*args, **kwargs): + """Simulates a race condition by creating a + duplicate contact between the first filter and save. + """ + # Return an empty queryset for the first call. Otherwise just proceed as normal. + if self.first_call: + self.first_call = False + duplicate = PublicContact( + domain=domain, + contact_type=PublicContact.ContactTypeChoices.SECURITY, + registry_id="defaultSec", + email="dotgov@cisa.dhs.gov", + name="Registry Customer Service", + ) + duplicate.save(skip_epp_save=True) + return PublicContact.objects.none() + + return PublicContact.objects.filter(*args, **kwargs) + + with patch.object(PublicContact.objects, "filter", side_effect=mock_filter): + try: + public_contact = PublicContact( + domain=domain, + contact_type=PublicContact.ContactTypeChoices.SECURITY, + registry_id="defaultSec", + email="dotgov@cisa.dhs.gov", + name="Registry Customer Service", + ) + returned_public_contact = domain._get_or_create_public_contact(public_contact) + except IntegrityError: + self.fail( + "IntegrityError was raised during contact creation due to a race condition. " + "This indicates that concurrent contact creation is not working in some cases. " + "The error occurs when two processes try to create the same contact simultaneously. " + "Expected behavior: gracefully handle duplicate creation and return existing contact." + ) + + # Verify that only one contact exists and its correctness + security_contacts = PublicContact.objects.filter( + domain=domain, contact_type=PublicContact.ContactTypeChoices.SECURITY + ) + self.assertEqual(security_contacts.count(), 1) + self.assertEqual(returned_public_contact, security_contacts.get()) + self.assertEqual(returned_public_contact.registry_id, "defaultSec") + self.assertEqual(returned_public_contact.email, "dotgov@cisa.dhs.gov") + @boto3_mocking.patching def test_approved_domain_request_creates_domain_locally(self): """ @@ -2267,13 +2331,13 @@ class TestExpirationDate(MockEppLib): """assert that the setter for expiration date is not implemented and will raise error""" with less_console_noise(): with self.assertRaises(NotImplementedError): - self.domain.registry_expiration_date = datetime.date.today() + self.domain.registry_expiration_date = date.today() def test_renew_domain(self): """assert that the renew_domain sets new expiration date in cache and saves to registrar""" with less_console_noise(): self.domain.renew_domain() - test_date = datetime.date(2023, 5, 25) + test_date = date(2023, 5, 25) self.assertEquals(self.domain._cache["ex_date"], test_date) self.assertEquals(self.domain.expiration_date, test_date) @@ -2295,18 +2359,42 @@ class TestExpirationDate(MockEppLib): with less_console_noise(): # to do this, need to mock value returned from timezone.now # set now to 2023-01-01 - mocked_datetime = datetime.datetime(2023, 1, 1, 12, 0, 0) + mocked_datetime = datetime(2023, 1, 1, 12, 0, 0) # force fetch_cache which sets the expiration date to 2023-05-25 self.domain.statuses with patch("registrar.models.domain.timezone.now", return_value=mocked_datetime): self.assertFalse(self.domain.is_expired()) + def test_is_expiring_within_threshold(self): + """assert that is_expiring returns true when expiration date is within 60 days""" + with less_console_noise(): + mocked_datetime = datetime(2023, 1, 1, 12, 0, 0) + expiration_date = mocked_datetime.date() + timedelta(days=30) + + # set domain's expiration date + self.domain.expiration_date = expiration_date + + with patch("registrar.models.domain.timezone.now", return_value=mocked_datetime): + self.assertTrue(self.domain.is_expiring()) + + def test_is_not_expiring_outside_threshold(self): + """assert that is_expiring returns false when expiration date is outside 60 days""" + with less_console_noise(): + mocked_datetime = datetime(2023, 1, 1, 12, 0, 0) + expiration_date = mocked_datetime.date() + timedelta(days=61) + + # set domain's expiration date + self.domain.expiration_date = expiration_date + + with patch("registrar.models.domain.timezone.now", return_value=mocked_datetime): + self.assertFalse(self.domain.is_expiring()) + def test_expiration_date_updated_on_info_domain_call(self): """assert that expiration date in db is updated on info domain call""" with less_console_noise(): # force fetch_cache to be called self.domain.statuses - test_date = datetime.date(2023, 5, 25) + test_date = date(2023, 5, 25) self.assertEquals(self.domain.expiration_date, test_date) @@ -2322,7 +2410,7 @@ class TestCreationDate(MockEppLib): self.domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY) # creation_date returned from mockDataInfoDomain with creation date: # cr_date=datetime.datetime(2023, 5, 25, 19, 45, 35) - self.creation_date = make_aware(datetime.datetime(2023, 5, 25, 19, 45, 35)) + self.creation_date = make_aware(datetime(2023, 5, 25, 19, 45, 35)) def tearDown(self): Domain.objects.all().delete() @@ -2331,7 +2419,7 @@ class TestCreationDate(MockEppLib): def test_creation_date_setter_not_implemented(self): """assert that the setter for creation date is not implemented and will raise error""" with self.assertRaises(NotImplementedError): - self.domain.creation_date = datetime.date.today() + self.domain.creation_date = date.today() def test_creation_date_updated_on_info_domain_call(self): """assert that creation date in db is updated on info domain call""" diff --git a/src/registrar/tests/test_models_requests.py b/src/registrar/tests/test_models_requests.py index da474224c..c3528311d 100644 --- a/src/registrar/tests/test_models_requests.py +++ b/src/registrar/tests/test_models_requests.py @@ -15,7 +15,10 @@ from registrar.models import ( FederalAgency, AllowedEmail, Portfolio, + Suborganization, + UserPortfolioPermission, ) +from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices import boto3_mocking from registrar.utility.constants import BranchChoices @@ -23,6 +26,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, @@ -43,6 +48,14 @@ class TestDomainRequest(TestCase): self.dummy_user_2, _ = User.objects.get_or_create( username="intern@igorville.com", email="intern@igorville.com", first_name="Lava", last_name="World" ) + + self.dummy_user_3, _ = User.objects.get_or_create( + username="portfolioadmin@igorville.com", + email="portfolioadmin@igorville.com", + first_name="Portfolio", + last_name="Admin", + ) + self.started_domain_request = completed_domain_request( status=DomainRequest.DomainRequestStatus.STARTED, name="started.gov", @@ -270,7 +283,14 @@ class TestDomainRequest(TestCase): self.assertEqual(domain_request.status, domain_request.DomainRequestStatus.SUBMITTED) def check_email_sent( - self, domain_request, msg, action, expected_count, expected_content=None, expected_email="mayor@igorville.com" + self, + domain_request, + msg, + action, + expected_count, + expected_content=None, + expected_email="mayor@igorville.com", + expected_cc=[], ): """Check if an email was sent after performing an action.""" email_allowed, _ = AllowedEmail.objects.get_or_create(email=expected_email) @@ -289,6 +309,11 @@ class TestDomainRequest(TestCase): ] self.assertEqual(len(sent_emails), expected_count) + if expected_cc: + sent_cc_adddresses = sent_emails[0]["kwargs"]["Destination"]["CcAddresses"] + for cc_address in expected_cc: + self.assertIn(cc_address, sent_cc_adddresses) + if expected_content: email_content = sent_emails[0]["kwargs"]["Content"]["Simple"]["Body"]["Text"]["Data"] self.assertIn(expected_content, email_content) @@ -1070,3 +1095,172 @@ 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) + + @less_console_noise_decorator + def test_portfolio_domain_requests_cc_requests_viewers(self): + """test that portfolio domain request emails cc portfolio members who have read requests access""" + fed_agency = FederalAgency.objects.filter(agency="Non-Federal Agency").first() + portfolio = Portfolio.objects.create( + organization_name="Test Portfolio", + creator=self.dummy_user_2, + federal_agency=fed_agency, + organization_type=DomainRequest.OrganizationChoices.FEDERAL, + ) + user_portfolio_permission = UserPortfolioPermission.objects.create( # noqa: F841 + user=self.dummy_user_3, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + # Adds cc'ed email in this test's allow list + AllowedEmail.objects.create(email="portfolioadmin@igorville.com") + + msg = "Create a domain request and submit it and see if email cc's portfolio admin and members who can view \ + requests." + domain_request = completed_domain_request( + name="test.gov", user=self.dummy_user_2, portfolio=portfolio, organization_name="Test Portfolio" + ) + self.check_email_sent( + domain_request, + msg, + "submit", + 1, + expected_email="intern@igorville.com", + expected_cc=["portfolioadmin@igorville.com"], + ) + + +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 cafaff7b1..4ba5b5bc9 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, @@ -71,8 +71,8 @@ class CsvReportsTest(MockDbForSharedTests): fake_open = mock_open() expected_file_content = [ call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), - call("cdomain1.gov,Federal - Executive,Portfolio 1 Federal Agency,,,,(blank)\r\n"), call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), + call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), ] @@ -93,8 +93,8 @@ class CsvReportsTest(MockDbForSharedTests): fake_open = mock_open() expected_file_content = [ call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), - call("cdomain1.gov,Federal - Executive,Portfolio 1 Federal Agency,,,,(blank)\r\n"), call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), + call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("zdomain12.gov,Interstate,,,,,(blank)\r\n"), @@ -255,10 +255,10 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): "Organization name,City,State,SO,SO email," "Security contact email,Domain managers,Invited domain managers\n" "adomain2.gov,Dns needed,(blank),(blank),Federal - Executive," - "Portfolio 1 Federal Agency,,,, ,,(blank)," + "Portfolio 1 Federal Agency,Portfolio 1 Federal Agency,,, ,,(blank)," "meoward@rocks.com,squeaker@rocks.com\n" "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive," - "Portfolio 1 Federal Agency,,,, ,,(blank)," + "Portfolio 1 Federal Agency,Portfolio 1 Federal Agency,,, ,,(blank)," '"big_lebowski@dude.co, info@example.com, meoward@rocks.com",woofwardthethird@rocks.com\n' "cdomain11.gov,Ready,2024-04-02,(blank),Federal - Executive," "World War I Centennial Commission,,,, ,,(blank)," @@ -280,6 +280,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) @less_console_noise_decorator @@ -316,9 +317,11 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): expected_content = ( "Domain name,Status,First ready on,Expiration date,Domain type,Agency,Organization name," "City,State,SO,SO email,Security contact email,Domain managers,Invited domain managers\n" - "adomain2.gov,Dns needed,(blank),(blank),Federal - Executive,Portfolio 1 Federal Agency,,,, ,,(blank)," + "adomain2.gov,Dns needed,(blank),(blank),Federal - Executive,Portfolio 1 Federal Agency," + "Portfolio 1 Federal Agency,,, ,,(blank)," '"info@example.com, meoward@rocks.com",squeaker@rocks.com\n' - "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,Portfolio 1 Federal Agency,,,, ,,(blank)," + "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,Portfolio 1 Federal Agency," + "Portfolio 1 Federal Agency,,, ,,(blank)," '"big_lebowski@dude.co, info@example.com, meoward@rocks.com",woofwardthethird@rocks.com\n' ) @@ -326,6 +329,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) @less_console_noise_decorator @@ -456,11 +460,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) @@ -493,17 +497,17 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # sorted alphabetially by domain name expected_content = ( "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" - "defaultsecurity.gov,Federal - Executive,Portfolio1FederalAgency,,,,(blank)\n" - "cdomain11.gov,Federal - Executive,WorldWarICentennialCommission,,,,(blank)\n" - "adomain10.gov,Federal,ArmedForcesRetirementHome,,,,(blank)\n" - "ddomain3.gov,Federal,ArmedForcesRetirementHome,,,,security@mail.gov\n" + "cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" + "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" + "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n" + "ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n" "zdomain12.gov,Interstate,,,,,(blank)\n" ) - # Normalize line endings and remove commas, # 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) @less_console_noise_decorator @@ -533,16 +537,16 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # sorted alphabetially by domain name expected_content = ( "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" - "defaultsecurity.gov,Federal - Executive,Portfolio1FederalAgency,,,,(blank)\n" - "cdomain11.gov,Federal - Executive,WorldWarICentennialCommission,,,,(blank)\n" - "adomain10.gov,Federal,ArmedForcesRetirementHome,,,,(blank)\n" - "ddomain3.gov,Federal,ArmedForcesRetirementHome,,,,security@mail.gov\n" + "cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" + "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" + "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n" + "ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n" ) - # Normalize line endings and remove commas, # 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) @less_console_noise_decorator @@ -587,7 +591,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): expected_content = ( "Domain name,Domain type,Agency,Organization name,City," "State,Status,Expiration date, Deleted\n" - "cdomain1.gov,Federal-Executive,Portfolio1FederalAgency,Ready,(blank)\n" + "cdomain1.gov,Federal-Executive,Portfolio1FederalAgency,Portfolio1FederalAgency,Ready,(blank)\n" "adomain10.gov,Federal,ArmedForcesRetirementHome,Ready,(blank)\n" "cdomain11.gov,Federal-Executive,WorldWarICentennialCommission,Ready,(blank)\n" "zdomain12.gov,Interstate,Ready,(blank)\n" @@ -601,6 +605,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): 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) @less_console_noise_decorator @@ -729,6 +734,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # "Submitted at", "Status", "Domain type", + "Portfolio", "Federal type", "Federal agency", "Organization name", @@ -736,6 +742,10 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): "City", "State/territory", "Region", + "Suborganization", + "Requested suborg", + "Suborg city", + "Suborg state/territory", "Creator first name", "Creator last name", "Creator email", @@ -765,35 +775,40 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): expected_content = ( # Header - "Domain request,Status,Domain type,Federal type,Federal agency,Organization name,Election office," - "City,State/territory,Region,Creator first name,Creator last name,Creator email," + "Domain request,Status,Domain type,Portfolio,Federal type,Federal agency,Organization name," + "Election office,City,State/territory,Region,Suborganization,Requested suborg,Suborg city," + "Suborg state/territory,Creator first name,Creator last name,Creator email," "Creator approved domains count,Creator active requests count,Alternative domains,SO first name," "SO last name,SO email,SO title/role,Request purpose,Request additional details,Other contacts," "CISA regional representative,Current websites,Investigator\n" # 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,,,,," - "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," + "city5.gov,Approved,Federal,No,Executive,,Testorg,N/A,,NY,2,requested_suborg,SanFran,CA,,,,,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,Yes,Executive,Portfolio 1 Federal Agency,Portfolio 1 Federal Agency," + "N/A,,,2,SubOrg 1,,,,,,,0,1,city1.gov,,,,,Purpose of the site,There is more," + "Testy Tester testy2@town.com,,city.com,\n" + "city3.gov,Submitted,Federal,Yes,Executive,Portfolio 1 Federal Agency,Portfolio 1 Federal Agency," + "N/A,,,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",' 'test@igorville.com,"city.com, https://www.example2.com, https://www.example.com",\n' - "city4.gov,Submitted,City,Executive,,Testorg,Yes,,NY,2,,,,0,1,city1.gov,Testy," + "city4.gov,Submitted,City,No,Executive,,Testorg,Yes,,NY,2,,,,,,,,0,1,city1.gov,Testy," "Tester,testy@town.com," "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,,,,," - "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,Yes,Executive,Portfolio 1 Federal Agency,Portfolio 1 Federal Agency,N/A," + ",,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" ) # Normalize line endings and remove commas, # 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) @@ -877,7 +892,7 @@ class MemberExportTest(MockDbForIndividualTests, MockEppLib): "big_lebowski@dude.co,False,help@get.gov,2022-04-01,Invalid date,None," "Viewer,True,1,cdomain1.gov\n" "cozy_staffuser@igorville.gov,True,help@get.gov,2022-04-01,2024-02-01," - "Viewer,Viewer,False,0,\n" + "Viewer Requester,Manager,False,0,\n" "icy_superuser@igorville.gov,True,help@get.gov,2022-04-01,2024-02-01," "Viewer Requester,Manager,False,0,\n" "meoward@rocks.com,False,big_lebowski@dude.co,2022-04-01,Invalid date,None," @@ -891,7 +906,7 @@ class MemberExportTest(MockDbForIndividualTests, MockEppLib): "nonexistentmember_4@igorville.gov,True,help@get.gov,Unretrieved,Invited," "Viewer Requester,Manager,False,0,\n" "nonexistentmember_5@igorville.gov,True,help@get.gov,Unretrieved,Invited," - "Viewer,Viewer,False,0,\n" + "Viewer Requester,Manager,False,0,\n" "tired_sleepy@igorville.gov,False,System,2022-04-01,Invalid date,Viewer," "None,False,0,\n" ) @@ -899,6 +914,7 @@ class MemberExportTest(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.py b/src/registrar/tests/test_views.py index f46e417be..2dfead13f 100644 --- a/src/registrar/tests/test_views.py +++ b/src/registrar/tests/test_views.py @@ -214,7 +214,7 @@ class HomeTests(TestWithUser): @less_console_noise_decorator def test_state_help_text_expired(self): """Tests if each domain state has help text when expired""" - expired_text = "This domain has expired, but it is still online. " + expired_text = "This domain has expired. " test_domain, _ = Domain.objects.get_or_create(name="expired.gov", state=Domain.State.READY) test_domain.expiration_date = date(2011, 10, 10) test_domain.save() @@ -240,7 +240,7 @@ class HomeTests(TestWithUser): """Tests if each domain state has help text when expiration date is None""" # == Test a expiration of None for state ready. This should be expired. == # - expired_text = "This domain has expired, but it is still online. " + expired_text = "This domain has expired. " test_domain, _ = Domain.objects.get_or_create(name="imexpired.gov", state=Domain.State.READY) test_domain.expiration_date = None test_domain.save() diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py index 25e8b0fb6..02b61fda9 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -4,10 +4,12 @@ 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 -from .common import MockEppLib, MockSESClient, create_user # type: ignore +from .common import MockEppLib, create_user # type: ignore from django_webtest import WebTest # type: ignore import boto3_mocking # type: ignore @@ -424,6 +426,307 @@ class TestDomainDetail(TestDomainOverview): self.assertContains(detail_page, "invited@example.com") +class TestDomainDetailDomainRenewal(TestDomainOverview): + def setUp(self): + super().setUp() + + self.user = get_user_model().objects.create( + first_name="User", + last_name="Test", + email="bogus@example.gov", + phone="8003111234", + title="test title", + username="usertest", + ) + + self.domain_to_renew, _ = Domain.objects.get_or_create( + name="domainrenewal.gov", + ) + + self.domain_not_expiring, _ = Domain.objects.get_or_create( + name="domainnotexpiring.gov", expiration_date=timezone.now().date() + timedelta(days=65) + ) + + self.domain_no_domain_manager, _ = Domain.objects.get_or_create(name="domainnodomainmanager.gov") + + UserDomainRole.objects.get_or_create( + user=self.user, domain=self.domain_to_renew, role=UserDomainRole.Roles.MANAGER + ) + + DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_to_renew) + + self.portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test org", creator=self.user) + + self.user.save() + + def expiration_date_one_year_out(self): + todays_date = datetime.today() + new_expiration_date = todays_date.replace(year=todays_date.year + 1) + return new_expiration_date + + def custom_is_expired_false(self): + return False + + def custom_is_expired_true(self): + return True + + def custom_is_expiring(self): + return True + + def custom_renew_domain(self): + self.domain_with_ip.expiration_date = self.expiration_date_one_year_out() + self.domain_with_ip.save() + + @override_flag("domain_renewal", active=True) + def test_expiring_domain_on_detail_page_as_domain_manager(self): + """If a user is a domain manager and their domain is expiring soon, + user should be able to see the "Renew to maintain access" link domain overview detail box.""" + self.client.force_login(self.user) + with patch.object(Domain, "is_expiring", self.custom_is_expiring), patch.object( + Domain, "is_expired", self.custom_is_expired_false + ): + self.assertEquals(self.domain_to_renew.state, Domain.State.UNKNOWN) + detail_page = self.client.get( + reverse("domain", kwargs={"pk": self.domain_to_renew.id}), + ) + self.assertContains(detail_page, "Expiring soon") + + self.assertContains(detail_page, "Renew to maintain access") + + self.assertNotContains(detail_page, "DNS needed") + self.assertNotContains(detail_page, "Expired") + + @override_flag("domain_renewal", active=True) + @override_flag("organization_feature", active=True) + def test_expiring_domain_on_detail_page_in_org_model_as_a_non_domain_manager(self): + """In org model: If a user is NOT a domain manager and their domain is expiring soon, + user be notified to contact a domain manager in the domain overview detail box.""" + portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test org", creator=self.user) + non_dom_manage_user = get_user_model().objects.create( + first_name="Non Domain", + last_name="Manager", + email="verybogus@example.gov", + phone="8003111234", + title="test title again", + username="nondomain", + ) + + non_dom_manage_user.save() + UserPortfolioPermission.objects.get_or_create( + user=non_dom_manage_user, + portfolio=portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, + ], + ) + domain_to_renew2, _ = Domain.objects.get_or_create(name="bogusdomain2.gov") + DomainInformation.objects.get_or_create( + creator=non_dom_manage_user, domain=domain_to_renew2, portfolio=self.portfolio + ) + non_dom_manage_user.refresh_from_db() + self.client.force_login(non_dom_manage_user) + with patch.object(Domain, "is_expiring", self.custom_is_expiring), patch.object( + Domain, "is_expired", self.custom_is_expired_false + ): + detail_page = self.client.get( + reverse("domain", kwargs={"pk": domain_to_renew2.id}), + ) + self.assertContains(detail_page, "Contact one of the listed domain managers to renew the domain.") + + @override_flag("domain_renewal", active=True) + @override_flag("organization_feature", active=True) + def test_expiring_domain_on_detail_page_in_org_model_as_a_domain_manager(self): + """Inorg model: If a user is a domain manager and their domain is expiring soon, + user should be able to see the "Renew to maintain access" link domain overview detail box.""" + portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test org2", creator=self.user) + + domain_to_renew3, _ = Domain.objects.get_or_create(name="bogusdomain3.gov") + + UserDomainRole.objects.get_or_create(user=self.user, domain=domain_to_renew3, role=UserDomainRole.Roles.MANAGER) + DomainInformation.objects.get_or_create(creator=self.user, domain=domain_to_renew3, portfolio=portfolio) + self.user.refresh_from_db() + self.client.force_login(self.user) + with patch.object(Domain, "is_expiring", self.custom_is_expiring), patch.object( + Domain, "is_expired", self.custom_is_expired_false + ): + detail_page = self.client.get( + reverse("domain", kwargs={"pk": domain_to_renew3.id}), + ) + self.assertContains(detail_page, "Renew to maintain access") + + @override_flag("domain_renewal", active=True) + def test_domain_renewal_form_and_sidebar_expiring(self): + """If a user is a domain manager and their domain is expiring soon, + user should be able to see Renewal Form on the sidebar.""" + self.client.force_login(self.user) + with patch.object(Domain, "is_expiring", self.custom_is_expiring), patch.object( + Domain, "is_expiring", self.custom_is_expiring + ): + # Grab the detail page + detail_page = self.client.get( + reverse("domain", kwargs={"pk": self.domain_to_renew.id}), + ) + + # Make sure we see the link as a domain manager + self.assertContains(detail_page, "Renew to maintain access") + + # Make sure we can see Renewal form on the sidebar since it's expiring + self.assertContains(detail_page, "Renewal form") + + # Grab link to the renewal page + renewal_form_url = reverse("domain-renewal", kwargs={"pk": self.domain_to_renew.id}) + self.assertContains(detail_page, f'href="{renewal_form_url}"') + + # Simulate clicking the link + response = self.client.get(renewal_form_url) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, f"Renew {self.domain_to_renew.name}") + + @override_flag("domain_renewal", active=True) + def test_domain_renewal_form_and_sidebar_expired(self): + """If a user is a domain manager and their domain is expired, + user should be able to see Renewal Form on the sidebar.""" + self.client.force_login(self.user) + + with patch.object(Domain, "is_expired", self.custom_is_expired_true), patch.object( + Domain, "is_expired", self.custom_is_expired_true + ): + # Grab the detail page + detail_page = self.client.get( + reverse("domain", kwargs={"pk": self.domain_to_renew.id}), + ) + + # Make sure we see the link as a domain manager + self.assertContains(detail_page, "Renew to maintain access") + + # Make sure we can see Renewal form on the sidebar since it's expired + self.assertContains(detail_page, "Renewal form") + + # Grab link to the renewal page + renewal_form_url = reverse("domain-renewal", kwargs={"pk": self.domain_to_renew.id}) + self.assertContains(detail_page, f'href="{renewal_form_url}"') + + # Simulate clicking the link + response = self.client.get(renewal_form_url) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, f"Renew {self.domain_to_renew.name}") + + @override_flag("domain_renewal", active=True) + def test_domain_renewal_form_your_contact_info_edit(self): + """Checking that if a user is a domain manager they can edit the + Your Profile portion of the Renewal Form.""" + with less_console_noise(): + # Start on the Renewal page for the domain + renewal_page = self.app.get(reverse("domain-renewal", kwargs={"pk": self.domain_with_ip.id})) + + # Verify we see "Your contact information" on the renewal form + self.assertContains(renewal_page, "Your contact information") + + # Verify that the "Edit" button for Your contact is there and links to correct URL + edit_button_url = reverse("user-profile") + self.assertContains(renewal_page, f'href="{edit_button_url}"') + + # Simulate clicking on edit button + edit_page = renewal_page.click(href=edit_button_url, index=1) + self.assertEqual(edit_page.status_code, 200) + self.assertContains(edit_page, "Review the details below and update any required information") + + @override_flag("domain_renewal", active=True) + def test_domain_renewal_form_security_email_edit(self): + """Checking that if a user is a domain manager they can edit the + Security Email portion of the Renewal Form.""" + with less_console_noise(): + # Start on the Renewal page for the domain + renewal_page = self.app.get(reverse("domain-renewal", kwargs={"pk": self.domain_with_ip.id})) + + # Verify we see "Security email" on the renewal form + self.assertContains(renewal_page, "Security email") + + # Verify we see "strong recommend" blurb + self.assertContains(renewal_page, "We strongly recommend that you provide a security email.") + + # Verify that the "Edit" button for Security email is there and links to correct URL + edit_button_url = reverse("domain-security-email", kwargs={"pk": self.domain_with_ip.id}) + self.assertContains(renewal_page, f'href="{edit_button_url}"') + + # Simulate clicking on edit button + edit_page = renewal_page.click(href=edit_button_url, index=1) + self.assertEqual(edit_page.status_code, 200) + self.assertContains(edit_page, "A security contact should be capable of evaluating") + + @override_flag("domain_renewal", active=True) + def test_domain_renewal_form_domain_manager_edit(self): + """Checking that if a user is a domain manager they can edit the + Domain Manager portion of the Renewal Form.""" + with less_console_noise(): + # Start on the Renewal page for the domain + renewal_page = self.app.get(reverse("domain-renewal", kwargs={"pk": self.domain_with_ip.id})) + + # Verify we see "Domain managers" on the renewal form + self.assertContains(renewal_page, "Domain managers") + + # Verify that the "Edit" button for Domain managers is there and links to correct URL + edit_button_url = reverse("domain-users", kwargs={"pk": self.domain_with_ip.id}) + self.assertContains(renewal_page, f'href="{edit_button_url}"') + + # Simulate clicking on edit button + edit_page = renewal_page.click(href=edit_button_url, index=1) + self.assertEqual(edit_page.status_code, 200) + self.assertContains(edit_page, "Domain managers can update all information related to a domain") + + @override_flag("domain_renewal", active=True) + def test_domain_renewal_form_not_expired_or_expiring(self): + """Checking that if the user's domain is not expired or expiring that user should not be able + to access /renewal and that it should receive a 403.""" + with less_console_noise(): + # Start on the Renewal page for the domain + renewal_page = self.client.get(reverse("domain-renewal", kwargs={"pk": self.domain_not_expiring.id})) + self.assertEqual(renewal_page.status_code, 403) + + @override_flag("domain_renewal", active=True) + def test_domain_renewal_form_does_not_appear_if_not_domain_manager(self): + """If user is not a domain manager and tries to access /renewal, user should receive a 403.""" + with patch.object(Domain, "is_expired", self.custom_is_expired_true), patch.object( + Domain, "is_expired", self.custom_is_expired_true + ): + renewal_page = self.client.get(reverse("domain-renewal", kwargs={"pk": self.domain_no_domain_manager.id})) + self.assertEqual(renewal_page.status_code, 403) + + @override_flag("domain_renewal", active=True) + def test_ack_checkbox_not_checked(self): + """If user don't check the checkbox, user should receive an error message.""" + # Grab the renewal URL + renewal_url = reverse("domain-renewal", kwargs={"pk": self.domain_with_ip.id}) + + # Test that the checkbox is not checked + response = self.client.post(renewal_url, data={"submit_button": "next"}) + + error_message = "Check the box if you read and agree to the requirements for operating a .gov domain." + self.assertContains(response, error_message) + + @override_flag("domain_renewal", active=True) + def test_ack_checkbox_checked(self): + """If user check the checkbox and submits the form, + user should be redirected Domain Over page with an updated by 1 year expiration date""" + # Grab the renewal URL + with patch.object(Domain, "renew_domain", self.custom_renew_domain): + renewal_url = reverse("domain-renewal", kwargs={"pk": self.domain_with_ip.id}) + + # Click the check, and submit + response = self.client.post(renewal_url, data={"is_policy_acknowledged": "on", "submit_button": "next"}) + + # Check that it redirects after a successfully submits + self.assertRedirects(response, reverse("domain", kwargs={"pk": self.domain_with_ip.id})) + + # Check for the updated expiration + formatted_new_expiration_date = self.expiration_date_one_year_out().strftime("%b. %-d, %Y") + redirect_response = self.client.get(reverse("domain", kwargs={"pk": self.domain_with_ip.id}), follow=True) + self.assertContains(redirect_response, formatted_new_expiration_date) + + class TestDomainManagers(TestDomainOverview): @classmethod def setUpClass(cls): @@ -442,6 +745,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] @@ -454,6 +758,10 @@ class TestDomainManagers(TestDomainOverview): def tearDown(self): """Ensure that the user has its original permissions""" + PortfolioInvitation.objects.all().delete() + UserPortfolioPermission.objects.all().delete() + UserDomainRole.objects.all().delete() + User.objects.exclude(id=self.user.id).delete() super().tearDown() @less_console_noise_decorator @@ -482,11 +790,12 @@ class TestDomainManagers(TestDomainOverview): response = self.client.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) self.assertContains(response, "Add a domain manager") - @boto3_mocking.patching @less_console_noise_decorator - def test_domain_user_add_form(self): + @patch("registrar.views.domain.send_domain_invitation_email") + def test_domain_user_add_form(self, mock_send_domain_email): """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") + user = User.objects.filter(email="mayor@igorville.gov").first() add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] @@ -494,10 +803,15 @@ class TestDomainManagers(TestDomainOverview): self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - mock_client = MockSESClient() - with boto3_mocking.clients.handler_for("sesv2", mock_client): - with less_console_noise(): - success_result = add_page.form.submit() + success_result = add_page.form.submit() + + mock_send_domain_email.assert_called_once_with( + email="mayor@igorville.gov", + requestor=self.user, + domains=self.domain, + is_member_of_different_org=None, + requested_user=user, + ) self.assertEqual(success_result.status_code, 302) self.assertEqual( @@ -510,12 +824,243 @@ class TestDomainManagers(TestDomainOverview): self.assertContains(success_page, "mayor@igorville.gov") @boto3_mocking.patching + @override_flag("organization_feature", active=True) @less_console_noise_decorator - def test_domain_invitation_created(self): + @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["domains"], self.domain) + self.assertIsNone(call_args.get("is_member_of_different_org")) + + # Assert that the PortfolioInvitation is created and retrieved + 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.assertEqual(portfolio_invitation.status, PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED) + + # Assert that the UserPortfolioPermission is created + user_portfolio_permission = UserPortfolioPermission.objects.filter( + user=self.user, portfolio=self.portfolio + ).first() + self.assertIsNotNone(user_portfolio_permission, "User portfolio permission should be created") + + 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_to_new_email( + self, mock_send_domain_email, mock_send_portfolio_email + ): + """Adding an email not associated with a user works and sends portfolio invitation.""" + 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"] = "notauser@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="notauser@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"], "notauser@igorville.gov") + self.assertEqual(call_args["requestor"], self.user) + self.assertEqual(call_args["domains"], self.domain) + self.assertIsNone(call_args.get("is_member_of_different_org")) + + # Assert that the PortfolioInvitation is created + portfolio_invitation = PortfolioInvitation.objects.filter( + email="notauser@igorville.gov", portfolio=self.portfolio + ).first() + self.assertIsNotNone(portfolio_invitation, "Portfolio invitation should be created.") + self.assertEqual(portfolio_invitation.email, "notauser@igorville.gov") + self.assertEqual(portfolio_invitation.portfolio, self.portfolio) + self.assertEqual(portfolio_invitation.status, PortfolioInvitation.PortfolioInvitationStatus.INVITED) + + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + success_page = success_result.follow() + self.assertContains(success_page, "notauser@igorville.gov") + + @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_fails_to_send_to_some_managers( + self, mock_send_domain_email, mock_send_portfolio_email + ): + """Adding an email not associated with a user works and sends portfolio invitation, + and when domain managers email(s) fail to send, assert proper warning displayed.""" + 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"] = "notauser@igorville.gov" + + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + mock_send_domain_email.return_value = False + + 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() + mock_send_domain_email.assert_called_once() + + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + success_page = success_result.follow() + self.assertContains(success_page, "Could not send email confirmation to existing domain managers.") + + @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["domains"], 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, "Failed to send email.") + + @less_console_noise_decorator + @patch("registrar.views.domain.send_domain_invitation_email") + def test_domain_invitation_created(self, mock_send_domain_email): """Add user on a nonexistent email creates an invitation. Adding a non-existent user sends an email as a side-effect, so mock - out the boto3 SES email sending here. + out send_domain_invitation_email here. """ # make sure there is no user with this email email_address = "mayor@igorville.gov" @@ -528,10 +1073,11 @@ class TestDomainManagers(TestDomainOverview): add_page.form["email"] = email_address self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - mock_client = MockSESClient() - with boto3_mocking.clients.handler_for("sesv2", mock_client): - with less_console_noise(): - success_result = add_page.form.submit() + success_result = add_page.form.submit() + + mock_send_domain_email.assert_called_once_with( + email="mayor@igorville.gov", requestor=self.user, domains=self.domain, is_member_of_different_org=None + ) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) success_page = success_result.follow() @@ -540,13 +1086,13 @@ class TestDomainManagers(TestDomainOverview): self.assertContains(success_page, "Cancel") # link to cancel invitation self.assertTrue(DomainInvitation.objects.filter(email=email_address).exists()) - @boto3_mocking.patching @less_console_noise_decorator - def test_domain_invitation_created_for_caps_email(self): + @patch("registrar.views.domain.send_domain_invitation_email") + def test_domain_invitation_created_for_caps_email(self, mock_send_domain_email): """Add user on a nonexistent email with CAPS creates an invitation to lowercase email. Adding a non-existent user sends an email as a side-effect, so mock - out the boto3 SES email sending here. + out send_domain_invitation_email here. """ # make sure there is no user with this email email_address = "mayor@igorville.gov" @@ -560,9 +1106,11 @@ class TestDomainManagers(TestDomainOverview): add_page.form["email"] = caps_email_address self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - mock_client = MockSESClient() - with boto3_mocking.clients.handler_for("sesv2", mock_client): - success_result = add_page.form.submit() + success_result = add_page.form.submit() + + mock_send_domain_email.assert_called_once_with( + email="mayor@igorville.gov", requestor=self.user, domains=self.domain, is_member_of_different_org=None + ) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) success_page = success_result.follow() @@ -721,39 +1269,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""" @@ -764,28 +1293,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): @@ -806,8 +1332,8 @@ class TestDomainManagers(TestDomainOverview): response = self.client.post(reverse("invitation-cancel", kwargs={"pk": invitation.id}), follow=True) # Assert that an error message is displayed to the user self.assertContains(response, f"Invitation to {email_address} has already been retrieved.") - # Assert that the Cancel link is not displayed - self.assertNotContains(response, "Cancel") + # Assert that the Cancel link (form) is not displayed + self.assertNotContains(response, f"/invitation/{invitation.id}/cancel") # Assert that the DomainInvitation is not deleted self.assertTrue(DomainInvitation.objects.filter(id=invitation.id).exists()) DomainInvitation.objects.filter(email=email_address).delete() @@ -865,6 +1391,57 @@ class TestDomainManagers(TestDomainOverview): home_page = self.app.get(reverse("home")) self.assertContains(home_page, self.domain.name) + @less_console_noise_decorator + def test_domain_user_role_delete(self): + """Posting to the delete view deletes a user domain role.""" + # add two managers to the domain so that one can be successfully deleted + email_address = "mayor@igorville.gov" + new_user = User.objects.create(email=email_address, username="mayor") + email_address_2 = "secondmayor@igorville.gov" + new_user_2 = User.objects.create(email=email_address_2, username="secondmayor") + user_domain_role = UserDomainRole.objects.create( + user=new_user, domain=self.domain, role=UserDomainRole.Roles.MANAGER + ) + UserDomainRole.objects.create(user=new_user_2, domain=self.domain, role=UserDomainRole.Roles.MANAGER) + response = self.client.post( + reverse("domain-user-delete", kwargs={"pk": self.domain.id, "user_pk": new_user.id}), follow=True + ) + # Assert that a success message is displayed to the user + self.assertContains(response, f"Removed {email_address} as a manager for this domain.") + # Assert that the second user is displayed + self.assertContains(response, f"{email_address_2}") + # Assert that the UserDomainRole is deleted + self.assertFalse(UserDomainRole.objects.filter(id=user_domain_role.id).exists()) + + @less_console_noise_decorator + def test_domain_user_role_delete_only_manager(self): + """Posting to the delete view attempts to delete a user domain role when there is only one manager.""" + # self.user is the only domain manager, so attempt to delete it + response = self.client.post( + reverse("domain-user-delete", kwargs={"pk": self.domain.id, "user_pk": self.user.id}), follow=True + ) + # Assert that an error message is displayed to the user + self.assertContains(response, "Domains must have at least one domain manager.") + # Assert that the user is still displayed + self.assertContains(response, f"{self.user.email}") + # Assert that the UserDomainRole still exists + self.assertTrue(UserDomainRole.objects.filter(user=self.user, domain=self.domain).exists()) + + @less_console_noise_decorator + def test_domain_user_role_delete_self_delete(self): + """Posting to the delete view attempts to delete a user domain role when there is only one manager.""" + # add one manager, so there are two and the logged in user, self.user, can be deleted + email_address = "mayor@igorville.gov" + new_user = User.objects.create(email=email_address, username="mayor") + UserDomainRole.objects.create(user=new_user, domain=self.domain, role=UserDomainRole.Roles.MANAGER) + response = self.client.post( + reverse("domain-user-delete", kwargs={"pk": self.domain.id, "user_pk": self.user.id}), follow=True + ) + # Assert that a success message is displayed to the user + self.assertContains(response, f"You are no longer managing the domain {self.domain}.") + # Assert that the UserDomainRole no longer exists + self.assertFalse(UserDomainRole.objects.filter(user=self.user, domain=self.domain).exists()) + class TestDomainNameservers(TestDomainOverview, MockEppLib): @less_console_noise_decorator @@ -2348,3 +2925,123 @@ class TestDomainChangeNotifications(TestDomainOverview): # Check that an email was not sent self.assertFalse(self.mock_client.send_email.called) + + +class TestDomainRenewal(TestWithUser): + def setUp(self): + super().setUp() + today = datetime.now() + expiring_date = (today + timedelta(days=30)).strftime("%Y-%m-%d") + expiring_date_current = (today + timedelta(days=70)).strftime("%Y-%m-%d") + expired_date = (today - timedelta(days=30)).strftime("%Y-%m-%d") + + self.domain_with_expiring_soon_date, _ = Domain.objects.get_or_create( + name="igorville.gov", expiration_date=expiring_date + ) + self.domain_with_expired_date, _ = Domain.objects.get_or_create( + name="domainwithexpireddate.gov", expiration_date=expired_date + ) + + self.domain_with_current_date, _ = Domain.objects.get_or_create( + name="domainwithfarexpireddate.gov", expiration_date=expiring_date_current + ) + + UserDomainRole.objects.get_or_create( + user=self.user, domain=self.domain_with_current_date, role=UserDomainRole.Roles.MANAGER + ) + + UserDomainRole.objects.get_or_create( + user=self.user, domain=self.domain_with_expired_date, role=UserDomainRole.Roles.MANAGER + ) + + UserDomainRole.objects.get_or_create( + user=self.user, domain=self.domain_with_expiring_soon_date, role=UserDomainRole.Roles.MANAGER + ) + + def tearDown(self): + try: + UserDomainRole.objects.all().delete() + Domain.objects.all().delete() + except ValueError: + pass + super().tearDown() + + # Remove test_without_domain_renewal_flag when domain renewal is released as a feature + @less_console_noise_decorator + @override_flag("domain_renewal", active=False) + def test_without_domain_renewal_flag(self): + self.client.force_login(self.user) + domains_page = self.client.get("/") + self.assertNotContains(domains_page, "will expire soon") + self.assertNotContains(domains_page, "Expiring soon") + + @less_console_noise_decorator + @override_flag("domain_renewal", active=True) + def test_domain_renewal_flag_single_domain(self): + self.client.force_login(self.user) + domains_page = self.client.get("/") + self.assertContains(domains_page, "One domain will expire soon") + self.assertContains(domains_page, "Expiring soon") + + @less_console_noise_decorator + @override_flag("domain_renewal", active=True) + def test_with_domain_renewal_flag_mulitple_domains(self): + today = datetime.now() + expiring_date = (today + timedelta(days=30)).strftime("%Y-%m-%d") + self.domain_with_another_expiring, _ = Domain.objects.get_or_create( + name="domainwithanotherexpiringdate.gov", expiration_date=expiring_date + ) + + UserDomainRole.objects.get_or_create( + user=self.user, domain=self.domain_with_another_expiring, role=UserDomainRole.Roles.MANAGER + ) + self.client.force_login(self.user) + domains_page = self.client.get("/") + self.assertContains(domains_page, "Multiple domains will expire soon") + self.assertContains(domains_page, "Expiring soon") + + @less_console_noise_decorator + @override_flag("domain_renewal", active=True) + def test_with_domain_renewal_flag_no_expiring_domains(self): + UserDomainRole.objects.filter(user=self.user, domain=self.domain_with_expired_date).delete() + UserDomainRole.objects.filter(user=self.user, domain=self.domain_with_expiring_soon_date).delete() + self.client.force_login(self.user) + domains_page = self.client.get("/") + self.assertNotContains(domains_page, "will expire soon") + + @less_console_noise_decorator + @override_flag("domain_renewal", active=True) + @override_flag("organization_feature", active=True) + def test_domain_renewal_flag_single_domain_w_org_feature_flag(self): + self.client.force_login(self.user) + domains_page = self.client.get("/") + self.assertContains(domains_page, "One domain will expire soon") + self.assertContains(domains_page, "Expiring soon") + + @less_console_noise_decorator + @override_flag("domain_renewal", active=True) + @override_flag("organization_feature", active=True) + def test_with_domain_renewal_flag_mulitple_domains_w_org_feature_flag(self): + today = datetime.now() + expiring_date = (today + timedelta(days=31)).strftime("%Y-%m-%d") + self.domain_with_another_expiring_org_model, _ = Domain.objects.get_or_create( + name="domainwithanotherexpiringdate_orgmodel.gov", expiration_date=expiring_date + ) + + UserDomainRole.objects.get_or_create( + user=self.user, domain=self.domain_with_another_expiring_org_model, role=UserDomainRole.Roles.MANAGER + ) + self.client.force_login(self.user) + domains_page = self.client.get("/") + self.assertContains(domains_page, "Multiple domains will expire soon") + self.assertContains(domains_page, "Expiring soon") + + @less_console_noise_decorator + @override_flag("domain_renewal", active=True) + @override_flag("organization_feature", active=True) + def test_with_domain_renewal_flag_no_expiring_domains_w_org_feature_flag(self): + UserDomainRole.objects.filter(user=self.user, domain=self.domain_with_expired_date).delete() + UserDomainRole.objects.filter(user=self.user, domain=self.domain_with_expiring_soon_date).delete() + self.client.force_login(self.user) + domains_page = self.client.get("/") + self.assertNotContains(domains_page, "will expire soon") diff --git a/src/registrar/tests/test_views_domains_json.py b/src/registrar/tests/test_views_domains_json.py index c4e5832c0..fe63f27de 100644 --- a/src/registrar/tests/test_views_domains_json.py +++ b/src/registrar/tests/test_views_domains_json.py @@ -8,24 +8,34 @@ from django_webtest import WebTest # type: ignore from django.utils.dateparse import parse_date from api.tests.common import less_console_noise_decorator from waffle.testutils import override_flag +from datetime import datetime, timedelta class GetDomainsJsonTest(TestWithUser, WebTest): def setUp(self): super().setUp() self.app.set_user(self.user.username) + today = datetime.now() + expiring_date = (today + timedelta(days=30)).strftime("%Y-%m-%d") + expiring_date_2 = (today + timedelta(days=31)).strftime("%Y-%m-%d") # Create test domains self.domain1 = Domain.objects.create(name="example1.com", expiration_date="2024-01-01", state="unknown") self.domain2 = Domain.objects.create(name="example2.com", expiration_date="2024-02-01", state="dns needed") self.domain3 = Domain.objects.create(name="example3.com", expiration_date="2024-03-01", state="ready") self.domain4 = Domain.objects.create(name="example4.com", expiration_date="2024-03-01", state="ready") - + self.domain5 = Domain.objects.create(name="example5.com", expiration_date=expiring_date, state="expiring soon") + self.domain6 = Domain.objects.create( + name="example6.com", expiration_date=expiring_date_2, state="expiring soon" + ) # Create UserDomainRoles UserDomainRole.objects.create(user=self.user, domain=self.domain1) UserDomainRole.objects.create(user=self.user, domain=self.domain2) UserDomainRole.objects.create(user=self.user, domain=self.domain3) + UserDomainRole.objects.create(user=self.user, domain=self.domain5) + UserDomainRole.objects.create(user=self.user, domain=self.domain6) + # Create Portfolio self.portfolio = Portfolio.objects.create(creator=self.user, organization_name="Example org") @@ -63,7 +73,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest): self.assertEqual(data["num_pages"], 1) # Check the number of domains - self.assertEqual(len(data["domains"]), 3) + self.assertEqual(len(data["domains"]), 5) # Expected domains expected_domains = [self.domain1, self.domain2, self.domain3] @@ -310,7 +320,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest): self.assertFalse(data["has_previous"]) self.assertEqual(data["num_pages"], 1) self.assertEqual(data["total"], 1) - self.assertEqual(data["unfiltered_total"], 3) + self.assertEqual(data["unfiltered_total"], 5) # Check the number of domain requests self.assertEqual(len(data["domains"]), 1) @@ -377,14 +387,15 @@ class GetDomainsJsonTest(TestWithUser, WebTest): @less_console_noise_decorator def test_state_filtering(self): """Test that different states in request get expected responses.""" - expected_values = [ ("unknown", 1), ("ready", 0), ("expired", 2), ("ready,expired", 2), ("unknown,expired", 3), + ("expiring", 2), ] + for state, num_domains in expected_values: with self.subTest(state=state, num_domains=num_domains): response = self.app.get(reverse("get_domains_json"), {"status": state}) 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..ceae1e35f 100644 --- a/src/registrar/tests/test_views_members_json.py +++ b/src/registrar/tests/test_views_members_json.py @@ -54,6 +54,7 @@ class GetPortfolioMembersJsonTest(MockEppLib, WebTest): title="Admin", ) self.email6 = "fifth@example.com" + self.email7 = "sixth@example.com" # Create Portfolio self.portfolio = Portfolio.objects.create(creator=self.user, organization_name="Test Portfolio") @@ -157,7 +158,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 +259,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 +274,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,14 +295,15 @@ 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) @override_flag("organization_members", active=True) def test_get_portfolio_invited_json_with_domains(self): """Test that portfolio invited members are returned properly for an authenticated user and the response includes - the domains that the member manages..""" + the domains that the member manages. Test also verifies that retrieved invitations are not included.""" UserPortfolioPermission.objects.create( user=self.user, portfolio=self.portfolio, @@ -303,6 +320,16 @@ class GetPortfolioMembersJsonTest(MockEppLib, WebTest): UserPortfolioPermissionChoices.EDIT_MEMBERS, ], ) + PortfolioInvitation.objects.create( + email=self.email7, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, + ], + status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED, + ) # create a domain in the portfolio domain = Domain.objects.create( @@ -318,19 +345,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 +379,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 de27b7059..33f334f7f 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__) @@ -207,11 +211,11 @@ class TestPortfolio(WebTest): # Assert the response is a 200 self.assertEqual(response.status_code, 200) # The label for Federal agency will always be a h4 - self.assertContains(response, '

    Organization name

    ') + self.assertContains(response, '

    Organization name

    ') # The read only label for city will be a h4 - self.assertContains(response, '

    City

    ') + self.assertContains(response, '

    City

    ') self.assertNotContains(response, 'for="id_city"') - self.assertContains(response, '

    Los Angeles

    ') + self.assertContains(response, '

    Los Angeles

    ') @less_console_noise_decorator def test_portfolio_organization_page_edit_access(self): @@ -232,10 +236,10 @@ class TestPortfolio(WebTest): # Assert the response is a 200 self.assertEqual(response.status_code, 200) # The label for Federal agency will always be a h4 - self.assertContains(response, '

    Organization name

    ') + self.assertContains(response, '

    Organization name

    ') # The read only label for city will be a h4 - self.assertNotContains(response, '

    City

    ') - self.assertNotContains(response, '

    Los Angeles

    ') + self.assertNotContains(response, '

    City

    ') + self.assertNotContains(response, '

    Los Angeles

    ') self.assertContains(response, 'for="id_city"') @less_console_noise_decorator @@ -911,9 +915,9 @@ class TestPortfolio(WebTest): # Assert text within the page is correct self.assertContains(response, "First Last") self.assertContains(response, self.user.email) - self.assertContains(response, "Basic access") + self.assertContains(response, "Basic") self.assertContains(response, "No access") - self.assertContains(response, "View all members") + self.assertContains(response, "Viewer") self.assertContains(response, "This member does not manage any domains.") # Assert buttons and links within the page are correct @@ -929,15 +933,11 @@ class TestPortfolio(WebTest): """Test that user can access the member page with edit_members permission""" # Arrange - # give user permissions to view AND manage members + # give user admin role, which includes edit_members permission_obj, _ = UserPortfolioPermission.objects.get_or_create( user=self.user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], - additional_permissions=[ - UserPortfolioPermissionChoices.EDIT_REQUESTS, - UserPortfolioPermissionChoices.EDIT_MEMBERS, - ], ) # Verify the page can be accessed @@ -948,9 +948,9 @@ class TestPortfolio(WebTest): # Assert text within the page is correct self.assertContains(response, "First Last") self.assertContains(response, self.user.email) - self.assertContains(response, "Admin access") - self.assertContains(response, "View all requests plus create requests") - self.assertContains(response, "View all members plus manage members") + self.assertContains(response, "Admin") + self.assertContains(response, "Creator") + self.assertContains(response, "Manager") self.assertContains( response, 'This member does not manage any domains. To assign this member a domain, click "Manage"' ) @@ -1024,9 +1024,9 @@ class TestPortfolio(WebTest): # Assert text within the page is correct self.assertContains(response, "Invited") self.assertContains(response, portfolio_invitation.email) - self.assertContains(response, "Basic access") + self.assertContains(response, "Basic") self.assertContains(response, "No access") - self.assertContains(response, "View all members") + self.assertContains(response, "Viewer") self.assertContains(response, "This member does not manage any domains.") # Assert buttons and links within the page are correct @@ -1039,27 +1039,19 @@ class TestPortfolio(WebTest): @override_flag("organization_feature", active=True) @override_flag("organization_members", active=True) def test_can_view_invitedmember_page_when_user_has_edit_members(self): - """Test that user can access the invitedmember page with edit_members permission""" + """Test that user can access the invitedmember page with org admin role""" # Arrange - # give user permissions to view AND manage members + # give user admin role permission_obj, _ = UserPortfolioPermission.objects.get_or_create( user=self.user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], - additional_permissions=[ - UserPortfolioPermissionChoices.EDIT_REQUESTS, - UserPortfolioPermissionChoices.EDIT_MEMBERS, - ], ) portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create( email="info@example.com", portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], - additional_permissions=[ - UserPortfolioPermissionChoices.EDIT_REQUESTS, - UserPortfolioPermissionChoices.EDIT_MEMBERS, - ], ) # Verify the page can be accessed @@ -1070,9 +1062,10 @@ class TestPortfolio(WebTest): # Assert text within the page is correct self.assertContains(response, "Invited") self.assertContains(response, portfolio_invitation.email) - self.assertContains(response, "Admin access") - self.assertContains(response, "View all requests plus create requests") - self.assertContains(response, "View all members plus manage members") + self.assertContains(response, "Admin") + self.assertContains(response, "Viewer, all") + self.assertContains(response, "Creator") + self.assertContains(response, "Manager") self.assertContains( response, 'This member does not manage any domains. To assign this member a domain, click "Manage"' ) @@ -1400,15 +1393,11 @@ class TestPortfolio(WebTest): # In the members_table.html we use data-has-edit-permission as a boolean # to indicate if a user has permission to edit members in the specific portfolio - # 1. User w/ edit permission + # 1. User w/ edit permission. This permission is included in Organization admin role UserPortfolioPermission.objects.get_or_create( user=self.user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], - additional_permissions=[ - UserPortfolioPermissionChoices.VIEW_MEMBERS, - UserPortfolioPermissionChoices.EDIT_MEMBERS, - ], ) # Create a member under same portfolio @@ -1429,12 +1418,13 @@ class TestPortfolio(WebTest): self.assertContains(response, 'data-has-edit-permission="True"') - # 2. User w/o edit permission (additional permission of EDIT_MEMBERS removed) + # 2. User w/o edit permission. permission = UserPortfolioPermission.objects.get(user=self.user, portfolio=self.portfolio) - # Remove the EDIT_MEMBERS additional permission + # Update to basic member with view members permission + permission.roles = [UserPortfolioRoleChoices.ORGANIZATION_MEMBER] permission.additional_permissions = [ - perm for perm in permission.additional_permissions if perm != UserPortfolioPermissionChoices.EDIT_MEMBERS + UserPortfolioPermissionChoices.VIEW_MEMBERS, ] # Save the updated permissions list @@ -1464,7 +1454,9 @@ class TestPortfolio(WebTest): # Create a member under same portfolio member_email = "a_member@example.com" - member, _ = User.objects.get_or_create(username="a_member", email=member_email) + member, _ = User.objects.get_or_create( + username="a_member", email=member_email, first_name="First", last_name="Last" + ) upp, _ = UserPortfolioPermission.objects.get_or_create( user=member, @@ -1481,7 +1473,8 @@ class TestPortfolio(WebTest): self.assertEqual(response.status_code, 200) # Check for email AND member type (which here is just member) - self.assertContains(response, f'data-member-name="{member_email}"') + self.assertContains(response, f'data-member-email="{member_email}"') + self.assertContains(response, 'data-member-name="First Last"') self.assertContains(response, 'data-member-type="member"') @less_console_noise_decorator @@ -1672,8 +1665,9 @@ class TestPortfolio(WebTest): self.assertEqual(response.status_code, 400) # Bad request due to active requests support_url = "https://get.gov/contact/" expected_error_message = ( - f"This member has an active domain request and can't be removed from the organization. " - f"Contact the .gov team to remove them." + "This member can't be removed from the organization because they have an active domain request. " + f"Please contact us " + "to remove this member." ) self.assertContains(response, expected_error_message, status_code=400) @@ -1795,8 +1789,9 @@ class TestPortfolio(WebTest): support_url = "https://get.gov/contact/" expected_error_message = ( - f"This member has an active domain request and can't be removed from the organization. " - f"Contact the .gov team to remove them." + "This member can't be removed from the organization because they have an active domain request. " + f"Please contact us " + "to remove this member." ) args, kwargs = mock_error.call_args @@ -1927,7 +1922,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], @@ -2102,14 +2097,75 @@ class TestPortfolioInvitedMemberDomainsView(TestWithUser, WebTest): self.assertEqual(response.status_code, 404) -class TestPortfolioMemberDomainsEditView(TestPortfolioMemberDomainsView): +class TestPortfolioMemberDomainsEditView(TestWithUser, WebTest): @classmethod def setUpClass(cls): super().setUpClass() + # Create Portfolio + cls.portfolio = Portfolio.objects.create(creator=cls.user, organization_name="Test Portfolio") + # Create domains for testing + cls.domain1 = Domain.objects.create(name="1.gov") + cls.domain2 = Domain.objects.create(name="2.gov") + cls.domain3 = Domain.objects.create(name="3.gov") @classmethod def tearDownClass(cls): super().tearDownClass() + Portfolio.objects.all().delete() + User.objects.all().delete() + Domain.objects.all().delete() + + def setUp(self): + super().setUp() + # Create test member + self.user_member = User.objects.create( + username="test_member", + first_name="Second", + last_name="User", + email="second@example.com", + phone="8003112345", + title="Member", + ) + # Create test user with no perms + self.user_no_perms = User.objects.create( + username="test_user_no_perms", + first_name="No", + last_name="Permissions", + email="user_no_perms@example.com", + phone="8003112345", + title="No Permissions", + ) + # Assign permissions to the user making requests + self.portfolio_permission = UserPortfolioPermission.objects.create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, + ], + ) + # Assign permissions to test member + self.permission = UserPortfolioPermission.objects.create( + user=self.user_member, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, + ], + ) + # Create url to be used in all tests + self.url = reverse("member-domains-edit", kwargs={"pk": self.portfolio_permission.pk}) + + def tearDown(self): + super().tearDown() + UserDomainRole.objects.all().delete() + DomainInvitation.objects.all().delete() + UserPortfolioPermission.objects.all().delete() + PortfolioInvitation.objects.all().delete() + Portfolio.objects.exclude(id=self.portfolio.id).delete() + User.objects.exclude(id=self.user.id).delete() @less_console_noise_decorator @override_flag("organization_feature", active=True) @@ -2162,15 +2218,219 @@ 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) + @patch("registrar.views.portfolios.send_domain_invitation_email") + def test_post_with_valid_added_domains(self, mock_send_domain_email): + """Test that domains can be successfully added.""" + self.client.force_login(self.user) -class TestPortfolioInvitedMemberEditDomainsView(TestPortfolioInvitedMemberDomainsView): + data = { + "added_domains": json.dumps([self.domain1.id, self.domain2.id, self.domain3.id]), # 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.") + + expected_domains = [self.domain1, self.domain2, self.domain3] + # Verify that the invitation email was sent + mock_send_domain_email.assert_called_once() + call_args = mock_send_domain_email.call_args.kwargs + self.assertEqual(call_args["email"], "info@example.com") + self.assertEqual(call_args["requestor"], self.user) + self.assertEqual(list(call_args["domains"]), list(expected_domains)) + self.assertIsNone(call_args.get("is_member_of_different_org")) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + @patch("registrar.views.portfolios.send_domain_invitation_email") + def test_post_with_valid_removed_domains(self, mock_send_domain_email): + """Test that domains can be successfully removed.""" + self.client.force_login(self.user) + + # Create some UserDomainRole objects + domains = [self.domain1, self.domain2, self.domain3] + UserDomainRole.objects.bulk_create([UserDomainRole(domain=domain, user=self.user) for domain in domains]) + + data = { + "removed_domains": json.dumps([self.domain1.id, self.domain2.id]), + } + 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=self.domain3, 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.") + # assert that send_domain_invitation_email is not called + mock_send_domain_email.assert_not_called() + + 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.") + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + @patch("registrar.views.portfolios.send_domain_invitation_email") + def test_post_when_send_domain_email_raises_exception(self, mock_send_domain_email): + """Test attempt to add new domains when an EmailSendingError raised.""" + self.client.force_login(self.user) + + data = { + "added_domains": json.dumps([self.domain1.id, self.domain2.id, self.domain3.id]), # Mock domain IDs + } + mock_send_domain_email.side_effect = EmailSendingError("Failed to send email") + response = self.client.post(self.url, data) + + # Check that the UserDomainRole objects were not created + self.assertEqual(UserDomainRole.objects.filter(user=self.user, role=UserDomainRole.Roles.MANAGER).count(), 0) + + # Check for an error message and a redirect to edit form + self.assertRedirects(response, reverse("member-domains-edit", kwargs={"pk": self.portfolio_permission.pk})) + messages = list(response.wsgi_request._messages) + self.assertEqual(len(messages), 1) + self.assertEqual( + str(messages[0]), + "An unexpected error occurred: Failed to send email. If the issue persists, please contact help@get.gov.", + ) + + +class TestPortfolioInvitedMemberEditDomainsView(TestWithUser, WebTest): @classmethod def setUpClass(cls): super().setUpClass() + # Create Portfolio + cls.portfolio = Portfolio.objects.create(creator=cls.user, organization_name="Test Portfolio") + # Create domains for testing + cls.domain1 = Domain.objects.create(name="1.gov") + cls.domain2 = Domain.objects.create(name="2.gov") + cls.domain3 = Domain.objects.create(name="3.gov") @classmethod def tearDownClass(cls): super().tearDownClass() + Portfolio.objects.all().delete() + User.objects.all().delete() + Domain.objects.all().delete() + + def setUp(self): + super().setUp() + # Add a user with no permissions + self.user_no_perms = User.objects.create( + username="test_user_no_perms", + first_name="No", + last_name="Permissions", + email="user_no_perms@example.com", + phone="8003112345", + title="No Permissions", + ) + # Add an invited member who has been invited to manage domains + self.invited_member_email = "invited@example.com" + self.invitation = PortfolioInvitation.objects.create( + email=self.invited_member_email, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + ], + ) + + # Assign permissions to the user making requests + UserPortfolioPermission.objects.create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, + ], + ) + self.url = reverse("invitedmember-domains-edit", kwargs={"pk": self.invitation.pk}) + + def tearDown(self): + super().tearDown() + Domain.objects.all().delete() + DomainInvitation.objects.all().delete() + UserPortfolioPermission.objects.all().delete() + PortfolioInvitation.objects.all().delete() + Portfolio.objects.exclude(id=self.portfolio.id).delete() + User.objects.exclude(id=self.user.id).delete() @less_console_noise_decorator @override_flag("organization_feature", active=True) @@ -2222,6 +2482,228 @@ 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) + @patch("registrar.views.portfolios.send_domain_invitation_email") + def test_post_with_valid_added_domains(self, mock_send_domain_email): + """Test adding new domains successfully.""" + self.client.force_login(self.user) + + data = { + "added_domains": json.dumps([self.domain1.id, self.domain2.id, self.domain3.id]), + } + 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.") + + expected_domains = [self.domain1, self.domain2, self.domain3] + # Verify that the invitation email was sent + mock_send_domain_email.assert_called_once() + call_args = mock_send_domain_email.call_args.kwargs + self.assertEqual(call_args["email"], "invited@example.com") + self.assertEqual(call_args["requestor"], self.user) + self.assertEqual(list(call_args["domains"]), list(expected_domains)) + self.assertFalse(call_args.get("is_member_of_different_org")) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + @patch("registrar.views.portfolios.send_domain_invitation_email") + 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=self.domain1, + email="invited@example.com", + status=DomainInvitation.DomainInvitationStatus.CANCELED, + ), + DomainInvitation( + domain=self.domain2, + email="invited@example.com", + status=DomainInvitation.DomainInvitationStatus.INVITED, + ), + ] + ) + + data = { + "added_domains": json.dumps([self.domain1.id, self.domain2.id, self.domain3.id]), + } + response = self.client.post(self.url, data) + + # Check that status for domain_id=1 was updated to INVITED + self.assertEqual( + DomainInvitation.objects.get(domain=self.domain1, email="invited@example.com").status, + DomainInvitation.DomainInvitationStatus.INVITED, + ) + + # Check that domain_id=3 was created as INVITED + self.assertTrue( + DomainInvitation.objects.filter( + domain=self.domain3, 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) + @patch("registrar.views.portfolios.send_domain_invitation_email") + def test_post_with_valid_removed_domains(self, mock_send_domain_email): + """Test removing domains successfully.""" + self.client.force_login(self.user) + + # Create existing invitations + DomainInvitation.objects.bulk_create( + [ + DomainInvitation( + domain=self.domain1, + email="invited@example.com", + status=DomainInvitation.DomainInvitationStatus.INVITED, + ), + DomainInvitation( + domain=self.domain2, + email="invited@example.com", + status=DomainInvitation.DomainInvitationStatus.INVITED, + ), + ] + ) + + data = { + "removed_domains": json.dumps([self.domain1.id]), + } + 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=self.domain1, email="invited@example.com").status, + DomainInvitation.DomainInvitationStatus.CANCELED, + ) + + # Check that domain_id=2 remains INVITED + self.assertEqual( + DomainInvitation.objects.get(domain=self.domain2, 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})) + # assert that send_domain_invitation_email is not called + mock_send_domain_email.assert_not_called() + + @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.") + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + @patch("registrar.views.portfolios.send_domain_invitation_email") + def test_post_when_send_domain_email_raises_exception(self, mock_send_domain_email): + """Test attempt to add new domains when an EmailSendingError raised.""" + self.client.force_login(self.user) + + data = { + "added_domains": json.dumps([self.domain1.id, self.domain2.id, self.domain3.id]), + } + mock_send_domain_email.side_effect = EmailSendingError("Failed to send email") + response = self.client.post(self.url, data) + + # Check that the DomainInvitation objects were not created + self.assertEqual( + DomainInvitation.objects.filter( + email="invited@example.com", status=DomainInvitation.DomainInvitationStatus.INVITED + ).count(), + 0, + ) + + # Check for an error message and a redirect to edit form + self.assertRedirects(response, reverse("invitedmember-domains-edit", kwargs={"pk": self.invitation.pk})) + messages = list(response.wsgi_request._messages) + self.assertEqual(len(messages), 1) + self.assertEqual( + str(messages[0]), + "An unexpected error occurred: Failed to send email. If the issue persists, please contact help@get.gov.", + ) + class TestRequestingEntity(WebTest): """The requesting entity page is a domain request form that only exists @@ -2268,6 +2750,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 @@ -2348,7 +2870,7 @@ class TestRequestingEntity(WebTest): form["portfolio_requesting_entity-requesting_entity_is_suborganization"] = True form["portfolio_requesting_entity-is_requesting_new_suborganization"] = True - form["portfolio_requesting_entity-sub_organization"] = "" + form["portfolio_requesting_entity-sub_organization"] = "other" form["portfolio_requesting_entity-requested_suborganization"] = "moon" form["portfolio_requesting_entity-suborganization_city"] = "kepler" @@ -2411,18 +2933,34 @@ class TestRequestingEntity(WebTest): session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + # For 2 the tests below, it is required to submit a form without submitting a value + # for the select/combobox. WebTest will not do this; by default, WebTest will submit + # the first choice in a select. So, need to manipulate the form to remove the + # particular select/combobox that will not be submitted, and then post the form. + form_action = f"/request/{domain_request.pk}/portfolio_requesting_entity/" + # Test missing suborganization selection form["portfolio_requesting_entity-requesting_entity_is_suborganization"] = True - form["portfolio_requesting_entity-sub_organization"] = "" - - response = form.submit() + form["portfolio_requesting_entity-is_requesting_new_suborganization"] = False + # remove sub_organization from the form submission + form_data = form.submit_fields() + form_data = [(key, value) for key, value in form_data if key != "portfolio_requesting_entity-sub_organization"] + response = self.app.post(form_action, dict(form_data)) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.assertContains(response, "Suborganization is required.", status_code=200) # Test missing custom suborganization details + form["portfolio_requesting_entity-requesting_entity_is_suborganization"] = True form["portfolio_requesting_entity-is_requesting_new_suborganization"] = True - response = form.submit() - self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + form["portfolio_requesting_entity-sub_organization"] = "other" + # remove suborganization_state_territory from the form submission + form_data = form.submit_fields() + form_data = [ + (key, value) + for key, value in form_data + if key != "portfolio_requesting_entity-suborganization_state_territory" + ] + response = self.app.post(form_action, dict(form_data)) self.assertContains(response, "Enter the name of your suborganization.", status_code=200) self.assertContains(response, "Enter the city where your suborganization is located.", status_code=200) self.assertContains( @@ -2531,7 +3069,9 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest): ], ) - cls.new_member_email = "new_user@example.com" + cls.new_member_email = "newmember@example.com" + + AllowedEmail.objects.get_or_create(email=cls.new_member_email) # Assign permissions to the user making requests UserPortfolioPermission.objects.create( @@ -2550,8 +3090,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 +3105,252 @@ 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_permissions": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, + "domain_permissions": UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value, + "member_permissions": "no_access", + "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 + # Validate that portfolio invitation was created but not retrieved + 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) + self.assertEqual(portfolio_invite.status, PortfolioInvitation.PortfolioInvitationStatus.INVITED) + + # 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_permissions": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, + "domain_permissions": UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value, + "member_permissions": "no_access", + "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_permissions": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, + "domain_permissions": UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value, + "member_permissions": "no_access", + "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 portfolio 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_permissions": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, + "domain_permissions": UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value, + "member_permissions": "no_access", + "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_permissions": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, + "domain_permissions": UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value, + "member_permissions": "no_access", + "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 portfolio 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 +3364,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,15 +3406,220 @@ 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() + + @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_existing_user_who_is_not_a_member(self, mock_send_email): + """Tests the member invitation flow for existing user who is not a 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) + + new_user = User.objects.create(email="newuser@example.com") + + # Simulate submission of member invite for the newly created user + response = self.client.post( + reverse("new-member"), + { + "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, + "domain_request_permissions": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, + "domain_permissions": UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value, + "member_permissions": "no_access", + "email": "newuser@example.com", + }, + ) + self.assertEqual(response.status_code, 302) + + # Validate Database Changes + # Validate that portfolio invitation was created and retrieved + portfolio_invite = PortfolioInvitation.objects.filter( + email="newuser@example.com", portfolio=self.portfolio + ).first() + self.assertIsNotNone(portfolio_invite) + self.assertEqual(portfolio_invite.email, "newuser@example.com") + self.assertEqual(portfolio_invite.status, PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED) + # Validate UserPortfolioPermission + user_portfolio_permission = UserPortfolioPermission.objects.filter( + user=new_user, portfolio=self.portfolio + ).first() + self.assertIsNotNone(user_portfolio_permission) + + # assert that send_portfolio_invitation_email is called + mock_send_email.assert_called_once() + call_args = mock_send_email.call_args.kwargs + self.assertEqual(call_args["email"], "newuser@example.com") + self.assertEqual(call_args["requestor"], self.user) + self.assertIsNone(call_args.get("is_member_of_different_org")) + + +class TestEditPortfolioMemberView(WebTest): + """Tests for the edit member page on portfolios""" + + def setUp(self): + self.user = create_user() + # Create Portfolio + self.portfolio = Portfolio.objects.create(creator=self.user, organization_name="Test Portfolio") + + # Add an invited member who has been invited to manage domains + self.invited_member_email = "invited@example.com" + self.invitation = PortfolioInvitation.objects.create( + email=self.invited_member_email, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + ], + ) + + # Assign permissions to the user making requests + UserPortfolioPermission.objects.create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, + ], + ) + + def tearDown(self): + PortfolioInvitation.objects.all().delete() + UserPortfolioPermission.objects.all().delete() + Portfolio.objects.all().delete() + User.objects.all().delete() + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_edit_member_permissions_basic_to_admin(self): + """Tests converting a basic member to admin with full permissions.""" + self.client.force_login(self.user) + + # Create a basic member to edit + basic_member = create_test_user() + basic_permission = UserPortfolioPermission.objects.create( + user=basic_member, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS], + ) + + response = self.client.post( + reverse("member-permissions", kwargs={"pk": basic_permission.id}), + { + "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN, + }, + ) + + # Verify redirect and success message + self.assertEqual(response.status_code, 302) + + # Verify database changes + basic_permission.refresh_from_db() + self.assertEqual(basic_permission.roles, [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_edit_member_permissions_validation(self): + """Tests form validation for required fields based on role.""" + self.client.force_login(self.user) + + member = create_test_user() + permission = UserPortfolioPermission.objects.create( + user=member, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER] + ) + + # Test missing required admin permissions + response = self.client.post( + reverse("member-permissions", kwargs={"pk": permission.id}), + { + "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER, + # Missing required admin fields + }, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.context["form"].errors["domain_request_permissions"][0], + "Domain request permission is required.", + ) + self.assertEqual(response.context["form"].errors["member_permissions"][0], "Member permission is required.") + self.assertEqual(response.context["form"].errors["domain_permissions"][0], "Domain permission is required.") + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_edit_invited_member_permissions(self): + """Tests editing permissions for an invited (but not yet joined) member.""" + self.client.force_login(self.user) + + # Test updating invitation permissions + response = self.client.post( + reverse("invitedmember-permissions", kwargs={"pk": self.invitation.id}), + { + "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN, + }, + ) + + self.assertEqual(response.status_code, 302) + + # Verify invitation was updated + updated_invitation = PortfolioInvitation.objects.get(pk=self.invitation.id) + self.assertEqual(updated_invitation.roles, [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]) + + @less_console_noise_decorator + @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. + + 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 + admin_permission = UserPortfolioPermission.objects.get(user=self.user, portfolio=self.portfolio) + + response = self.client.post( + reverse("member-permissions", kwargs={"pk": admin_permission.id}), + { + "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER, + "domain_permissions": UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS, + "member_permissions": "no_access", + "domain_request_permissions": "no_access", + }, + ) + + self.assertEqual(response.status_code, 302) + self.assertEqual(response["Location"], reverse("home")) diff --git a/src/registrar/tests/test_views_requests_json.py b/src/registrar/tests/test_views_requests_json.py index 28e2a9407..e25d76bf7 100644 --- a/src/registrar/tests/test_views_requests_json.py +++ b/src/registrar/tests/test_views_requests_json.py @@ -338,7 +338,7 @@ class GetRequestsJsonTest(TestWithUser, WebTest): self.assertEqual(expected_domain_request.creator.email, creator[i]) # Check action url, action label and svg icon # Example domain requests will test each of below three scenarios - if creator[i] != self.user.email: + if creator[i] != self.user.email or not self.user.has_edit_request_portfolio_permission(self.portfolio): # Test case where action is View self.assertEqual("View", action_labels[i]) self.assertEqual( diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index 97feae20c..1bb53a7a3 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) @@ -744,30 +743,45 @@ class DomainExport(BaseExport): ): security_contact_email = "(blank)" + model["status"] = human_readable_status + model["first_ready_on"] = first_ready_on + model["expiration_date"] = expiration_date + model["domain_type"] = domain_type + model["security_contact_email"] = security_contact_email # create a dictionary of fields which can be included in output. # "extra_fields" are precomputed fields (generated in the DB or parsed). + FIELDS = cls.get_fields(model) + + row = [FIELDS.get(column, "") for column in columns] + + return row + + # NOTE - this override is temporary. + # We are running into a problem where DomainDataFull and DomainDataFederal are + # pulling the wrong data. + # For example, the portfolio name, rather than the suborganization name. + # This can be removed after that gets fixed. + @classmethod + def get_fields(cls, model): FIELDS = { "Domain name": model.get("domain__name"), - "Status": human_readable_status, - "First ready on": first_ready_on, - "Expiration date": expiration_date, - "Domain type": domain_type, + "Status": model.get("status"), + "First ready on": model.get("first_ready_on"), + "Expiration date": model.get("expiration_date"), + "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": security_contact_email, + "Security contact email": model.get("security_contact_email"), "Created at": model.get("domain__created_at"), "Deleted": model.get("domain__deleted"), "Domain managers": model.get("managers"), "Invited domain managers": model.get("invited_users"), } - - row = [FIELDS.get(column, "") for column in columns] - - return row + return FIELDS def get_filtered_domain_infos_by_org(domain_infos_to_filter, org_to_filter_by): """Returns a list of Domain Requests that has been filtered by the given organization value.""" @@ -893,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", @@ -972,111 +986,45 @@ 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 Inherits from BaseExport -> DomainExport """ + # NOTE - this override is temporary. + # We are running into a problem where DomainDataFull is + # pulling the wrong data. + # For example, the portfolio name, rather than the suborganization name. + # This can be removed after that gets fixed. + # The following fields are changed from DomainExport: + # converted_organization_name => organization_name + # converted_city => city + # converted_state_territory => state_territory + # converted_so_name => so_name + # converted_so_email => senior_official__email + @classmethod + def get_fields(cls, model): + FIELDS = { + "Domain name": model.get("domain__name"), + "Status": model.get("status"), + "First ready on": model.get("first_ready_on"), + "Expiration date": model.get("expiration_date"), + "Domain type": model.get("domain_type"), + "Agency": model.get("federal_agency__agency"), + "Organization name": model.get("organization_name"), + "City": model.get("city"), + "State": model.get("state_territory"), + "SO": model.get("so_name"), + "SO email": model.get("senior_official__email"), + "Security contact email": model.get("security_contact_email"), + "Created at": model.get("domain__created_at"), + "Deleted": model.get("domain__deleted"), + "Domain managers": model.get("managers"), + "Invited domain managers": model.get("invited_users"), + } + return FIELDS + @classmethod def get_columns(cls): """ @@ -1106,9 +1054,9 @@ class DomainDataFull(DomainExport): """ # Coalesce is used to replace federal_type of None with ZZZZZ return [ - "converted_generic_org_type", - Coalesce("converted_federal_type", Value("ZZZZZ")), - "converted_federal_agency", + "organization_type", + Coalesce("federal_type", Value("ZZZZZ")), + "federal_agency", "domain__name", ] @@ -1164,6 +1112,39 @@ class DomainDataFederal(DomainExport): Inherits from BaseExport -> DomainExport """ + # NOTE - this override is temporary. + # We are running into a problem where DomainDataFull is + # pulling the wrong data. + # For example, the portfolio name, rather than the suborganization name. + # This can be removed after that gets fixed. + # The following fields are changed from DomainExport: + # converted_organization_name => organization_name + # converted_city => city + # converted_state_territory => state_territory + # converted_so_name => so_name + # converted_so_email => senior_official__email + @classmethod + def get_fields(cls, model): + FIELDS = { + "Domain name": model.get("domain__name"), + "Status": model.get("status"), + "First ready on": model.get("first_ready_on"), + "Expiration date": model.get("expiration_date"), + "Domain type": model.get("domain_type"), + "Agency": model.get("federal_agency__agency"), + "Organization name": model.get("organization_name"), + "City": model.get("city"), + "State": model.get("state_territory"), + "SO": model.get("so_name"), + "SO email": model.get("senior_official__email"), + "Security contact email": model.get("security_contact_email"), + "Created at": model.get("domain__created_at"), + "Deleted": model.get("domain__deleted"), + "Domain managers": model.get("managers"), + "Invited domain managers": model.get("invited_users"), + } + return FIELDS + @classmethod def get_columns(cls): """ @@ -1193,9 +1174,9 @@ class DomainDataFederal(DomainExport): """ # Coalesce is used to replace federal_type of None with ZZZZZ return [ - "converted_generic_org_type", - Coalesce("converted_federal_type", Value("ZZZZZ")), - "converted_federal_agency", + "organization_type", + Coalesce("federal_type", Value("ZZZZZ")), + "federal_agency", "domain__name", ] @@ -1693,6 +1674,13 @@ class DomainRequestExport(BaseExport): default=F("state_territory"), output_field=CharField(), ), + "converted_suborganization_name": Case( + # When sub_organization is present, use its name + When(sub_organization__isnull=False, then=F("sub_organization__name")), + # Otherwise, return empty string + default=Value(""), + 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")), @@ -1819,6 +1807,10 @@ class DomainRequestExport(BaseExport): status = model.get("status") status_display = DomainRequest.DomainRequestStatus.get_status_label(status) if status else None + # Handle the portfolio field. Display as a Yes/No + portfolio = model.get("portfolio") + portfolio_display = "Yes" if portfolio is not None else "No" + # Handle the region field. state_territory = model.get("state_territory") region = get_region(state_territory) if state_territory else None @@ -1852,6 +1844,7 @@ class DomainRequestExport(BaseExport): "Election office": human_readable_election_board, "Federal type": human_readable_federal_type, "Domain type": human_readable_org_type, + "Portfolio": portfolio_display, "Request additional details": additional_details, # Annotated fields - passed into the request dict. "Creator approved domains count": model.get("creator_approved_domains_count", 0), @@ -1860,6 +1853,10 @@ class DomainRequestExport(BaseExport): "Other contacts": model.get("all_other_contacts"), "Current websites": model.get("all_current_websites"), # Untouched FK fields - passed into the request dict. + "Suborganization": model.get("converted_suborganization_name"), + "Requested suborg": model.get("requested_suborganization"), + "Suborg city": model.get("suborganization_city"), + "Suborg state/territory": model.get("suborganization_state_territory"), "Federal agency": model.get("converted_federal_agency"), "SO first name": model.get("converted_senior_official_first_name"), "SO last name": model.get("converted_senior_official_last_name"), @@ -1884,6 +1881,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 @@ -1899,7 +1982,7 @@ class DomainRequestGrowth(DomainRequestExport): "Domain request", "Domain type", "Federal type", - "Submitted at", + "First submitted date", ] @classmethod @@ -1923,7 +2006,6 @@ class DomainRequestGrowth(DomainRequestExport): start_date_formatted = format_start_date(start_date) end_date_formatted = format_end_date(end_date) return Q( - status=DomainRequest.DomainRequestStatus.SUBMITTED, last_submitted_date__lte=end_date_formatted, last_submitted_date__gte=start_date_formatted, ) @@ -1954,6 +2036,7 @@ class DomainRequestDataFull(DomainRequestExport): "Last status update", "Status", "Domain type", + "Portfolio", "Federal type", "Federal agency", "Organization name", @@ -1961,6 +2044,10 @@ class DomainRequestDataFull(DomainRequestExport): "City", "State/territory", "Region", + "Suborganization", + "Requested suborg", + "Suborg city", + "Suborg state/territory", "Creator first name", "Creator last name", "Creator email", diff --git a/src/registrar/utility/db_helpers.py b/src/registrar/utility/db_helpers.py new file mode 100644 index 000000000..5b7e0392c --- /dev/null +++ b/src/registrar/utility/db_helpers.py @@ -0,0 +1,20 @@ +from contextlib import contextmanager +from django.db import transaction, IntegrityError +from psycopg2 import errorcodes + + +@contextmanager +def ignore_unique_violation(): + """ + Execute within an atomic transaction so that if a unique constraint violation occurs, + the individual transaction is rolled back without invalidating any larger transaction. + """ + with transaction.atomic(): + try: + yield + except IntegrityError as e: + if e.__cause__.pgcode == errorcodes.UNIQUE_VIOLATION: + # roll back to the savepoint, effectively ignoring this transaction + pass + else: + raise e diff --git a/src/registrar/utility/email.py b/src/registrar/utility/email.py index 2a99267a5..40601cdc7 100644 --- a/src/registrar/utility/email.py +++ b/src/registrar/utility/email.py @@ -36,7 +36,7 @@ def send_templated_email( # noqa to_address and bcc_address currently only support single addresses. - cc_address is a list and can contain many addresses. Emails not in the + cc_addresses is a list and can contain many addresses. Emails not in the whitelist (if applicable) will be filtered out before sending. template_name and subject_template_name are relative to the same template diff --git a/src/registrar/utility/email_invitations.py b/src/registrar/utility/email_invitations.py new file mode 100644 index 000000000..f9c3b89b2 --- /dev/null +++ b/src/registrar/utility/email_invitations.py @@ -0,0 +1,212 @@ +from datetime import date +from django.conf import settings +from registrar.models import Domain, DomainInvitation, UserDomainRole +from registrar.utility.errors import ( + AlreadyDomainInvitedError, + AlreadyDomainManagerError, + MissingEmailError, + OutsideOrgMemberError, +) +from registrar.utility.waffle import flag_is_active_for_user +from registrar.utility.email import EmailSendingError, send_templated_email +import logging + +logger = logging.getLogger(__name__) + + +def send_domain_invitation_email( + email: str, requestor, domains: Domain | list[Domain], is_member_of_different_org, requested_user=None +): + """ + Sends a domain invitation email to the specified address. + + Args: + email (str): Email address of the recipient. + requestor (User): The user initiating the invitation. + domains (Domain or list of Domain): The domain objects for which the invitation is being sent. + is_member_of_different_org (bool): if an email belongs to a different org + requested_user (User | None): The recipient if the email belongs to a user in the registrar + + Returns: + Boolean indicating if all messages were sent successfully. + + 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. + """ + domains = normalize_domains(domains) + requestor_email = get_requestor_email(requestor, domains) + + _validate_invitation(email, requested_user, domains, requestor, is_member_of_different_org) + + send_invitation_email(email, requestor_email, domains, requested_user) + + all_manager_emails_sent = True + # send emails to domain managers + for domain in domains: + if not send_emails_to_domain_managers( + email=email, + requestor_email=requestor_email, + domain=domain, + requested_user=requested_user, + ): + all_manager_emails_sent = False + + return all_manager_emails_sent + + +def send_emails_to_domain_managers(email: str, requestor_email, domain: Domain, requested_user=None): + """ + Notifies all domain managers of the provided domain of a change + + Returns: + Boolean indicating if all messages were sent successfully. + """ + all_emails_sent = True + # Get each domain manager from list + user_domain_roles = UserDomainRole.objects.filter(domain=domain) + for user_domain_role in user_domain_roles: + # Send email to each domain manager + user = user_domain_role.user + try: + send_templated_email( + "emails/domain_manager_notification.txt", + "emails/domain_manager_notification_subject.txt", + to_address=user.email, + context={ + "domain": domain, + "requestor_email": requestor_email, + "invited_email_address": email, + "domain_manager": user, + "date": date.today(), + }, + ) + except EmailSendingError: + logger.warning( + f"Could not send email manager notification to {user.email} for domain: {domain.name}", exc_info=True + ) + all_emails_sent = False + return all_emails_sent + + +def normalize_domains(domains: Domain | list[Domain]) -> list[Domain]: + """Ensures domains is always a list.""" + return [domains] if isinstance(domains, Domain) else domains + + +def get_requestor_email(requestor, domains): + """Get the requestor's email or raise an error if it's missing. + + If the requestor is staff, default email is returned. + """ + if requestor.is_staff: + return settings.DEFAULT_FROM_EMAIL + + if not requestor.email or requestor.email.strip() == "": + domain_names = ", ".join([domain.name for domain in domains]) + raise MissingEmailError(email=requestor.email, domain=domain_names) + + return requestor.email + + +def _validate_invitation(email, user, domains, requestor, is_member_of_different_org): + """Validate the invitation conditions.""" + check_outside_org_membership(email, requestor, is_member_of_different_org) + + for domain in domains: + _validate_existing_invitation(email, user, domain) + + # NOTE: should we also be validating against existing user_domain_roles + + +def check_outside_org_membership(email, requestor, is_member_of_different_org): + """Raise an error if the email belongs to a different organization.""" + 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(email=email) + + +def _validate_existing_invitation(email, user, domain): + """Check for existing invitations and handle their status.""" + 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 + if user: + if UserDomainRole.objects.filter(user=user, domain=domain).exists(): + raise AlreadyDomainManagerError(email) + + +def send_invitation_email(email, requestor_email, domains, requested_user): + """Send the invitation email.""" + try: + send_templated_email( + "emails/domain_invitation.txt", + "emails/domain_invitation_subject.txt", + to_address=email, + context={ + "domains": domains, + "requestor_email": requestor_email, + "invitee_email_address": email, + "requested_user": requested_user, + }, + ) + except EmailSendingError as err: + domain_names = ", ".join([domain.name for domain in domains]) + raise EmailSendingError(f"Could not send email invitation to {email} for domains: {domain_names}") from err + + +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(email=email, portfolio=portfolio) + else: + requestor_email = requestor.email + + try: + send_templated_email( + "emails/portfolio_invitation.txt", + "emails/portfolio_invitation_subject.txt", + to_address=email, + context={ + "portfolio": portfolio, + "requestor_email": requestor_email, + "email": email, + }, + ) + except EmailSendingError as err: + raise EmailSendingError( + f"Could not sent email invitation to {email} for portfolio {portfolio}. Portfolio invitation not saved." + ) from err diff --git a/src/registrar/utility/errors.py b/src/registrar/utility/errors.py index e70f06d1e..0a6f00c36 100644 --- a/src/registrar/utility/errors.py +++ b/src/registrar/utility/errors.py @@ -23,13 +23,54 @@ class InvalidDomainError(ValueError): pass -class OutsideOrgMemberError(ValueError): +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, email=None, domain=None, portfolio=None): + # Default message if no additional info is provided + message = "Can't send invitation email. No email is associated with your user account." + + # Customize message based on provided arguments + if email and domain: + message = f"Can't send email to '{email}' on domain '{domain}'. No email exists for the requestor." + elif email and portfolio: + message = f"Can't send email to '{email}' for portfolio '{portfolio}'. No email exists for the requestor." + + super().__init__(message) + + +class OutsideOrgMemberError(InvitationError): """ Error raised when an org member tries adding a user from a different .gov org. To be deleted when users can be members of multiple orgs. """ - pass + def __init__(self, email=None): + # Default message if no additional info is provided + message = "Can not invite member of a .gov organization to a different organization." + if email: + message = f"{email} is already a member of another .gov organization." + super().__init__(message) class ActionNotAllowed(Exception): diff --git a/src/registrar/views/__init__.py b/src/registrar/views/__init__.py index a80b16b1a..4e3faced1 100644 --- a/src/registrar/views/__init__.py +++ b/src/registrar/views/__init__.py @@ -14,6 +14,7 @@ from .domain import ( DomainInvitationCancelView, DomainDeleteUserView, PrototypeDomainDNSRecordView, + DomainRenewalView, ) from .user_profile import UserProfileView, FinishProfileSetupView from .health import * diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index cb3da1f83..297cb689a 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -10,13 +10,12 @@ import logging import requests from django.contrib import messages from django.contrib.messages.views import SuccessMessageMixin -from django.db import IntegrityError from django.http import HttpResponseRedirect -from django.shortcuts import redirect +from django.shortcuts import redirect, render, get_object_or_404 from django.urls import reverse from django.views.generic.edit import FormMixin from django.conf import settings -from registrar.forms.domain import DomainSuborganizationForm +from registrar.forms.domain import DomainSuborganizationForm, DomainRenewalForm from registrar.models import ( Domain, DomainRequest, @@ -25,9 +24,9 @@ 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 ( @@ -39,11 +38,15 @@ from registrar.utility.errors import ( DsDataErrorCodes, SecurityEmailError, SecurityEmailErrorCodes, - OutsideOrgMemberError, ) from registrar.models.utility.contact_error import ContactError from registrar.views.utility.permission_views import UserDomainRolePermissionDeleteView from registrar.utility.waffle import flag_is_active_for_user +from registrar.views.utility.invitation_helper import ( + get_org_membership, + get_requested_user, + handle_invitation_exceptions, +) from ..forms import ( SeniorOfficialContactForm, @@ -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 @@ -307,6 +311,75 @@ class DomainView(DomainBaseView): self._update_session_with_domain() +class DomainRenewalView(DomainBaseView): + """Domain detail overview page.""" + + template_name = "domain_renewal.html" + + def get_context_data(self, **kwargs): + """Grabs the security email information and adds security_email to the renewal form context + sets it to None if it uses a default email""" + + context = super().get_context_data(**kwargs) + + default_emails = [DefaultEmail.PUBLIC_CONTACT_DEFAULT.value, DefaultEmail.LEGACY_DEFAULT.value] + + context["hidden_security_emails"] = default_emails + + security_email = self.object.get_security_email() + context["security_email"] = security_email + return context + + def in_editable_state(self, pk): + """Override in_editable_state from DomainPermission + Allow renewal form to be accessed + returns boolean""" + requested_domain = None + if Domain.objects.filter(id=pk).exists(): + requested_domain = Domain.objects.get(id=pk) + + return ( + requested_domain + and requested_domain.is_editable() + and (requested_domain.is_expiring() or requested_domain.is_expired()) + ) + + def post(self, request, pk): + + domain = get_object_or_404(Domain, id=pk) + + form = DomainRenewalForm(request.POST) + + if form.is_valid(): + + # check for key in the post request data + if "submit_button" in request.POST: + try: + domain.renew_domain() + messages.success(request, "This domain has been renewed for one year.") + except Exception: + messages.error( + request, + "This domain has not been renewed for one year, " + "please email help@get.gov if this problem persists.", + ) + return HttpResponseRedirect(reverse("domain", kwargs={"pk": pk})) + + # if not valid, render the template with error messages + # passing editable, has_domain_renewal_flag, and is_editable for re-render + return render( + request, + "domain_renewal.html", + { + "domain": domain, + "form": form, + "is_editable": True, + "has_domain_renewal_flag": True, + "is_domain_manager": True, + }, + ) + + class DomainOrgNameAddressView(DomainFormBaseView): """Organization view""" @@ -805,15 +878,6 @@ class DomainDNSSECView(DomainFormBaseView): context = super().get_context_data(**kwargs) has_dnssec_records = self.object.dnssecdata is not None - - # Create HTML for the modal button - modal_button = ( - '' - ) - - context["modal_button"] = modal_button context["has_dnssec_records"] = has_dnssec_records context["dnssec_enabled"] = self.request.session.pop("dnssec_enabled", False) @@ -906,15 +970,6 @@ class DomainDsDataView(DomainFormBaseView): # to preserve the context["form"] context = super().get_context_data(form=formset) context["trigger_modal"] = True - # Create HTML for the modal button - modal_button = ( - '' - ) - - # context to back out of a broken form on all fields delete - context["modal_button"] = modal_button return self.render_to_response(context) if formset.is_valid() or override: @@ -1047,12 +1102,6 @@ class DomainUsersView(DomainBaseView): """The initial value for the form (which is a formset here).""" context = super().get_context_data(**kwargs) - # Add conditionals to the context (such as "can_delete_users") - context = self._add_booleans_to_context(context) - - # Add modal buttons to the context (such as for delete) - context = self._add_modal_buttons_to_context(context) - # Get portfolio from session (if set) portfolio = self.request.session.get("portfolio") @@ -1121,7 +1170,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 @@ -1135,40 +1187,6 @@ class DomainUsersView(DomainBaseView): return context - def _add_booleans_to_context(self, context): - # Determine if the current user can delete managers - domain_pk = None - can_delete_users = False - - if self.kwargs is not None and "pk" in self.kwargs: - domain_pk = self.kwargs["pk"] - # Prevent the end user from deleting themselves as a manager if they are the - # only manager that exists on a domain. - can_delete_users = UserDomainRole.objects.filter(domain__id=domain_pk).count() > 1 - - context["can_delete_users"] = can_delete_users - return context - - def _add_modal_buttons_to_context(self, context): - """Adds modal buttons (and their HTML) to the context""" - # Create HTML for the modal button - modal_button = ( - '' - ) - context["modal_button"] = modal_button - - # Create HTML for the modal button when deleting yourself - modal_button_self = ( - '' - ) - context["modal_button_self"] = modal_button_self - - return context - class DomainAddUserView(DomainFormBaseView): """Inside of a domain's user management, a form for adding users. @@ -1183,171 +1201,87 @@ 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 _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 - """ - - # 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 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 - - # 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") - - 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", - 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.") - 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()) - def form_valid(self, form): - """Add the specified user on this domain. - Throws EmailSendingError.""" + """Add the specified user to this domain.""" requested_email = form.cleaned_data["email"] requestor = self.request.user - email_success = False - # look up a user with that email + + # Look up a user with that email + requested_user = 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 = get_org_membership(domain_org, requested_email, requested_user) 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) - 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 + # COMMENT: this code does not take into account multiple portfolios flag being set to TRUE + + # 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 + # 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 + ): + send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=domain_org) + portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create( + email=requested_email, portfolio=domain_org, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER] ) - 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") + # if user exists for email, immediately retrieve portfolio invitation upon creation + if requested_user is not None: + portfolio_invitation.retrieve() + portfolio_invitation.save() + messages.success(self.request, f"{requested_email} has been invited to the organization: {domain_org}") + + 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: + handle_invitation_exceptions(self.request, e, requested_email) return redirect(self.get_success_url()) + 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.""" + if not send_domain_invitation_email( + email=email, + requestor=requestor, + domains=self.object, + is_member_of_different_org=member_of_different_org, + ): + messages.warning(self.request, "Could not send email confirmation to existing domain managers.") + DomainInvitation.objects.get_or_create(email=email, domain=self.object) + messages.success(self.request, f"{email} has been invited to the domain: {self.object}") + + def _handle_existing_user(self, email, requestor, requested_user, member_of_different_org): + """Handle adding an existing user to the domain.""" + if not send_domain_invitation_email( + email=email, + requestor=requestor, + domains=self.object, + is_member_of_different_org=member_of_different_org, + requested_user=requested_user, + ): + messages.warning(self.request, "Could not send email confirmation to existing domain managers.") + UserDomainRole.objects.create( + user=requested_user, + domain=self.object, + role=UserDomainRole.Roles.MANAGER, + ) + messages.success(self.request, f"Added user {email}.") + class DomainInvitationCancelView(SuccessMessageMixin, DomainInvitationPermissionCancelView): object: DomainInvitation @@ -1389,7 +1323,7 @@ class DomainDeleteUserView(UserDomainRolePermissionDeleteView): """Refreshes the page after a delete is successful""" return reverse("domain-users", kwargs={"pk": self.object.domain.id}) - def get_success_message(self, delete_self=False): + def get_success_message(self): """Returns confirmation content for the deletion event""" # Grab the text representation of the user we want to delete @@ -1399,7 +1333,7 @@ class DomainDeleteUserView(UserDomainRolePermissionDeleteView): # If the user is deleting themselves, return a specific message. # If not, return something more generic. - if delete_self: + if self.delete_self: message = f"You are no longer managing the domain {self.object.domain}." else: message = f"Removed {email_or_name} as a manager for this domain." @@ -1412,20 +1346,35 @@ class DomainDeleteUserView(UserDomainRolePermissionDeleteView): # Delete the object super().form_valid(form) - # Is the user deleting themselves? If so, display a different message - delete_self = self.request.user == self.object.user - # Add a success message - messages.success(self.request, self.get_success_message(delete_self)) + messages.success(self.request, self.get_success_message()) return redirect(self.get_success_url()) def post(self, request, *args, **kwargs): - """Custom post implementation to redirect to home in the event that the user deletes themselves""" + """Custom post implementation to ensure last userdomainrole is not removed and to + redirect to home in the event that the user deletes themselves""" + self.object = self.get_object() # Retrieve the UserDomainRole to delete + + # Is the user deleting themselves? + self.delete_self = self.request.user == self.object.user + + # Check if this is the only UserDomainRole for the domain + if not len(UserDomainRole.objects.filter(domain=self.object.domain)) > 1: + if self.delete_self: + messages.error( + request, + "Domains must have at least one domain manager. " + "To remove yourself, the domain needs another domain manager.", + ) + else: + messages.error(request, "Domains must have at least one domain manager.") + return redirect(self.get_success_url()) + + # normal delete processing in the event that the above condition not reached response = super().post(request, *args, **kwargs) # If the user is deleting themselves, redirect to home - delete_self = self.request.user == self.object.user - if delete_self: + if self.delete_self: return redirect(reverse("home")) return response diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py index 85f7576d0..9754b0d0c 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -448,34 +448,21 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView): non_org_steps_complete = DomainRequest._form_complete(self.domain_request, self.request) org_steps_complete = len(self.db_check_for_unlocking_steps()) == len(self.steps) if (not self.is_portfolio and non_org_steps_complete) or (self.is_portfolio and org_steps_complete): - modal_button = '" context = { - "not_form": False, "form_titles": self.titles, "steps": self.steps, "visited": self.storage.get("step_history", []), "is_federal": self.domain_request.is_federal(), - "modal_button": modal_button, - "modal_heading": "You are about to submit a domain request for ", - "domain_name_modal": str(self.domain_request.requested_domain), - "modal_description": "Once you submit this request, you won’t be able to edit it until we review it.\ - You’ll only be able to withdraw your request.", "review_form_is_complete": True, "user": self.request.user, "requested_domain__name": requested_domain_name, } else: # form is not complete - modal_button = '' context = { - "not_form": True, "form_titles": self.titles, "steps": self.steps, "visited": self.storage.get("step_history", []), "is_federal": self.domain_request.is_federal(), - "modal_button": modal_button, - "modal_heading": "Your request form is incomplete", - "modal_description": 'This request cannot be submitted yet.\ - Return to the request and visit the steps that are marked as "incomplete."', "review_form_is_complete": False, "user": self.request.user, "requested_domain__name": requested_domain_name, diff --git a/src/registrar/views/domain_requests_json.py b/src/registrar/views/domain_requests_json.py index d0e673adf..88590010e 100644 --- a/src/registrar/views/domain_requests_json.py +++ b/src/registrar/views/domain_requests_json.py @@ -109,6 +109,10 @@ def apply_sorting(queryset, request): sort_by = request.GET.get("sort_by", "id") # Default to 'id' order = request.GET.get("order", "asc") # Default to 'asc' + # Handle special case for 'creator' + if sort_by == "creator": + sort_by = "creator__email" + if order == "desc": sort_by = f"-{sort_by}" return queryset.order_by(sort_by) @@ -121,15 +125,6 @@ def serialize_domain_request(request, domain_request, user): DomainRequest.DomainRequestStatus.WITHDRAWN, ] - # Determine if the request is deletable - if not user.is_org_user(request): - is_deletable = domain_request.status in deletable_statuses - else: - portfolio = request.session.get("portfolio") - is_deletable = ( - domain_request.status in deletable_statuses and user.has_edit_request_portfolio_permission(portfolio) - ) and domain_request.creator == user - # Determine action label based on user permissions and request status editable_statuses = [ DomainRequest.DomainRequestStatus.STARTED, @@ -137,11 +132,26 @@ def serialize_domain_request(request, domain_request, user): DomainRequest.DomainRequestStatus.WITHDRAWN, ] - if user.has_edit_request_portfolio_permission and domain_request.creator == user: + # No portfolio action_label + if domain_request.creator == user: action_label = "Edit" if domain_request.status in editable_statuses else "Manage" else: action_label = "View" + # No portfolio deletable + is_deletable = domain_request.status in deletable_statuses + + # If we're working with a portfolio + if user.is_org_user(request): + portfolio = request.session.get("portfolio") + is_deletable = ( + domain_request.status in deletable_statuses and user.has_edit_request_portfolio_permission(portfolio) + ) and domain_request.creator == user + if user.has_edit_request_portfolio_permission(portfolio) and domain_request.creator == user: + action_label = "Edit" if domain_request.status in editable_statuses else "Manage" + else: + action_label = "View" + # Map the action label to corresponding URLs and icons action_url_map = { "Edit": reverse("edit-domain-request", kwargs={"id": domain_request.id}), diff --git a/src/registrar/views/domains_json.py b/src/registrar/views/domains_json.py index f7c8b4637..8734ef89c 100644 --- a/src/registrar/views/domains_json.py +++ b/src/registrar/views/domains_json.py @@ -27,7 +27,7 @@ def get_domains_json(request): page_number = request.GET.get("page") page_obj = paginator.get_page(page_number) - domains = [serialize_domain(domain, request.user) for domain in page_obj.object_list] + domains = [serialize_domain(domain, request) for domain in page_obj.object_list] return JsonResponse( { @@ -80,21 +80,27 @@ def apply_state_filter(queryset, request): status_list.append("dns needed") # Split the status list into normal states and custom states normal_states = [state for state in status_list if state in Domain.State.values] - custom_states = [state for state in status_list if state == "expired"] + custom_states = [state for state in status_list if (state == "expired" or state == "expiring")] # Construct Q objects for normal states that can be queried through ORM state_query = Q() if normal_states: state_query |= Q(state__in=normal_states) # Handle custom states in Python, as expired can not be queried through ORM if "expired" in custom_states: - expired_domain_ids = [domain.id for domain in queryset if domain.state_display() == "Expired"] + expired_domain_ids = [domain.id for domain in queryset if domain.state_display(request) == "Expired"] state_query |= Q(id__in=expired_domain_ids) + if "expiring" in custom_states: + expiring_domain_ids = [domain.id for domain in queryset if domain.state_display(request) == "Expiring soon"] + state_query |= Q(id__in=expiring_domain_ids) # Apply the combined query queryset = queryset.filter(state_query) # If there are filtered states, and expired is not one of them, domains with # state_display of 'Expired' must be removed if "expired" not in custom_states: - expired_domain_ids = [domain.id for domain in queryset if domain.state_display() == "Expired"] + expired_domain_ids = [domain.id for domain in queryset if domain.state_display(request) == "Expired"] + queryset = queryset.exclude(id__in=expired_domain_ids) + if "expiring" not in custom_states: + expired_domain_ids = [domain.id for domain in queryset if domain.state_display(request) == "Expiring soon"] queryset = queryset.exclude(id__in=expired_domain_ids) return queryset @@ -105,7 +111,7 @@ def apply_sorting(queryset, request): order = request.GET.get("order", "asc") if sort_by == "state_display": objects = list(queryset) - objects.sort(key=lambda domain: domain.state_display(), reverse=(order == "desc")) + objects.sort(key=lambda domain: domain.state_display(request), reverse=(order == "desc")) return objects else: if order == "desc": @@ -113,7 +119,8 @@ def apply_sorting(queryset, request): return queryset.order_by(sort_by) -def serialize_domain(domain, user): +def serialize_domain(domain, request): + user = request.user suborganization_name = None try: domain_info = domain.domain_info @@ -133,7 +140,7 @@ def serialize_domain(domain, user): "name": domain.name, "expiration_date": domain.expiration_date, "state": domain.state, - "state_display": domain.state_display(), + "state_display": domain.state_display(request), "get_state_help_text": domain.get_state_help_text(), "action_url": reverse("domain", kwargs={"pk": domain.id}), "action_label": ("View" if view_only else "Manage"), diff --git a/src/registrar/views/index.py b/src/registrar/views/index.py index 7019c8db3..be7149018 100644 --- a/src/registrar/views/index.py +++ b/src/registrar/views/index.py @@ -8,5 +8,6 @@ def index(request): if request and request.user and request.user.is_authenticated: # This controls the creation of a new domain request in the wizard context["user_domain_count"] = request.user.get_user_domain_ids(request).count() + context["num_expiring_domains"] = request.user.get_num_expiring_domains(request) return render(request, "home.html", context) 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..a45ad66e9 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,13 +120,26 @@ 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 = PortfolioInvitation.objects.filter( + portfolio=portfolio, status=PortfolioInvitation.PortfolioInvitationStatus.INVITED + ) invitations = invitations.annotate( first_name=Value(None, output_field=CharField()), last_name=Value(None, output_field=CharField()), @@ -136,7 +150,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 +172,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View): "domain_info", "type", ) + return invitations def apply_search_term(self, queryset, request): @@ -190,10 +210,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 +232,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 90313339b..212ce089d 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -1,18 +1,23 @@ +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 from django.urls import reverse 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 import Domain +from registrar.models.domain_invitation import DomainInvitation from registrar.models.portfolio_invitation import PortfolioInvitation +from registrar.models.user_domain_role import UserDomainRole from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.utility.email import EmailSendingError +from registrar.utility.email_invitations import send_domain_invitation_email, 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, @@ -27,6 +32,9 @@ 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 + +from registrar.views.utility.invitation_helper import get_org_membership logger = logging.getLogger(__name__) @@ -40,6 +48,8 @@ class PortfolioDomainsView(PortfolioDomainsPermissionView, View): context = {} if self.request and self.request.user and self.request.user.is_authenticated: context["user_domain_count"] = self.request.user.get_user_domain_ids(request).count() + context["num_expiring_domains"] = request.user.get_num_expiring_domains(request) + return render(request, "portfolio_domains.html", context) @@ -72,6 +82,9 @@ class PortfolioMemberView(PortfolioMemberPermissionView, View): member_has_edit_members_portfolio_permission = member.has_edit_members_portfolio_permission( portfolio_permission.portfolio ) + member_has_view_all_domains_portfolio_permission = member.has_view_all_domains_portfolio_permission( + portfolio_permission.portfolio + ) return render( request, @@ -85,6 +98,7 @@ class PortfolioMemberView(PortfolioMemberPermissionView, View): "member_has_edit_request_portfolio_permission": member_has_edit_request_portfolio_permission, "member_has_view_members_portfolio_permission": member_has_view_members_portfolio_permission, "member_has_edit_members_portfolio_permission": member_has_edit_members_portfolio_permission, + "member_has_view_all_domains_portfolio_permission": member_has_view_all_domains_portfolio_permission, }, ) @@ -108,8 +122,8 @@ class PortfolioMemberDeleteView(PortfolioMemberPermission, View): if active_requests_count > 0: # If they have any in progress requests error_message = mark_safe( # nosec - f"This member has an active domain request and can't be removed from the organization. " - f"Contact the .gov team to remove them." + "This member can't be removed from the organization because they have an active domain request. " + f"Please contact us to remove this member." ) elif member.is_only_admin_of_portfolio(portfolio_member_permission.portfolio): # If they are the last manager of a domain @@ -163,13 +177,19 @@ 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 user_initially_is_admin + and UserPortfolioRoleChoices.ORGANIZATION_ADMIN not in form.cleaned_data.get("role", []) + ) form.save() - return redirect("member", pk=pk) + messages.success(self.request, "The member access and permission changes have been saved.") + return redirect("member", pk=pk) if not removing_admin_role_on_self else redirect("home") return render( request, @@ -216,6 +236,98 @@ 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 + portfolio = portfolio_permission.portfolio + + 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, request.user, portfolio) + 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.", exc_info=True) + return redirect(reverse("member-domains-edit", kwargs={"pk": pk})) + except Exception as e: + messages.error( + request, + f"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)}", exc_info=True) + 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, requestor, portfolio): + """ + Processes added domains by bulk creating UserDomainRole instances. + """ + if added_domain_ids: + # get added_domains from ids to pass to send email method and bulk create + added_domains = Domain.objects.filter(id__in=added_domain_ids) + member_of_a_different_org, _ = get_org_membership(portfolio, member.email, member) + if not send_domain_invitation_email( + email=member.email, + requestor=requestor, + domains=added_domains, + is_member_of_different_org=member_of_a_different_org, + requested_user=member, + ): + messages.warning(self.request, "Could not send email confirmation to existing domain managers.") + # Bulk create UserDomainRole instances for added domains + UserDomainRole.objects.bulk_create( + [ + UserDomainRole(domain=domain, user=member, role=UserDomainRole.Roles.MANAGER) + for domain in added_domains + ], + 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): @@ -239,6 +351,9 @@ class PortfolioInvitedMemberView(PortfolioMemberPermissionView, View): member_has_edit_members_portfolio_permission = ( UserPortfolioPermissionChoices.EDIT_MEMBERS in portfolio_invitation.get_portfolio_permissions() ) + member_has_view_all_domains_portfolio_permission = ( + UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS in portfolio_invitation.get_portfolio_permissions() + ) return render( request, @@ -251,6 +366,7 @@ class PortfolioInvitedMemberView(PortfolioMemberPermissionView, View): "member_has_edit_request_portfolio_permission": member_has_edit_request_portfolio_permission, "member_has_view_members_portfolio_permission": member_has_view_members_portfolio_permission, "member_has_edit_members_portfolio_permission": member_has_edit_members_portfolio_permission, + "member_has_view_all_domains_portfolio_permission": member_has_view_all_domains_portfolio_permission, }, ) @@ -298,6 +414,7 @@ class PortfolioInvitedMemberEditView(PortfolioMemberEditPermissionView, View): form = self.form_class(request.POST, instance=portfolio_invitation) if form.is_valid(): form.save() + messages.success(self.request, "The member access and permission changes have been saved.") return redirect("invitedmember", pk=pk) return render( @@ -341,6 +458,116 @@ 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 + portfolio = portfolio_invitation.portfolio + + 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, request.user, portfolio) + 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.", exc_info=True) + return redirect(reverse("invitedmember-domains-edit", kwargs={"pk": pk})) + except Exception as e: + messages.error( + request, + f"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)}.", exc_info=True) + 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, requestor, portfolio): + """ + Processes added domain invitations by updating existing invitations + or creating new ones. + """ + if added_domain_ids: + # get added_domains from ids to pass to send email method and bulk create + added_domains = Domain.objects.filter(id__in=added_domain_ids) + member_of_a_different_org, _ = get_org_membership(portfolio, email, None) + if not send_domain_invitation_email( + email=email, + requestor=requestor, + domains=added_domains, + is_member_of_different_org=member_of_a_different_org, + ): + messages.warning(self.request, "Could not send email confirmation to existing domain managers.") + + # Update existing invitations from CANCELED to INVITED + existing_invitations = DomainInvitation.objects.filter(domain__in=added_domains, 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. @@ -502,34 +729,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) @@ -546,7 +766,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: @@ -556,108 +776,46 @@ 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) + portfolio_invitation = form.save() + # if user exists for email, immediately retrieve portfolio invitation upon creation + if requested_user is not None: + portfolio_invitation.retrieve() + portfolio_invitation.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 portfolio 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 portfolio 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/registrar/views/transfer_user.py b/src/registrar/views/transfer_user.py index 69a0f4997..f574b76d9 100644 --- a/src/registrar/views/transfer_user.py +++ b/src/registrar/views/transfer_user.py @@ -1,19 +1,19 @@ import logging +from django.db import transaction +from django.db.models import ForeignKey, OneToOneField, ManyToManyField, ManyToOneRel, ManyToManyRel, OneToOneRel from django.shortcuts import render, get_object_or_404, redirect from django.views import View from registrar.models.domain import Domain -from registrar.models.domain_information import DomainInformation from registrar.models.domain_request import DomainRequest -from registrar.models.portfolio import Portfolio from registrar.models.user import User from django.contrib.admin import site from django.contrib import messages from registrar.models.user_domain_role import UserDomainRole from registrar.models.user_portfolio_permission import UserPortfolioPermission -from registrar.models.verified_by_staff import VerifiedByStaff -from typing import Any, List + +from registrar.utility.db_helpers import ignore_unique_violation logger = logging.getLogger(__name__) @@ -21,22 +21,8 @@ logger = logging.getLogger(__name__) class TransferUserView(View): """Transfer user methods that set up the transfer_user template and handle the forms on it.""" - JOINS = [ - (DomainRequest, "creator"), - (DomainInformation, "creator"), - (Portfolio, "creator"), - (DomainRequest, "investigator"), - (UserDomainRole, "user"), - (VerifiedByStaff, "requestor"), - (UserPortfolioPermission, "user"), - ] - - # Future-proofing in case joined fields get added on the user model side - # This was tested in the first portfolio model iteration and works - USER_FIELDS: List[Any] = [] - def get(self, request, user_id): - """current_user referes to the 'source' user where the button that redirects to this view was clicked. + """current_user refers to the 'source' user where the button that redirects to this view was clicked. other_users exclude current_user and populate a dropdown, selected_user is the selection in the dropdown. This also querries the relevant domains and domain requests, and the admin context needed for the sidenav.""" @@ -70,86 +56,122 @@ class TransferUserView(View): return render(request, "admin/transfer_user.html", context) def post(self, request, user_id): - """This handles the transfer from selected_user to current_user then deletes selected_user. - - NOTE: We have a ticket to refactor this into a more solid lookup for related fields in #2645""" - + """This handles the transfer from selected_user to current_user then deletes selected_user.""" current_user = get_object_or_404(User, pk=user_id) selected_user_id = request.POST.get("selected_user") selected_user = get_object_or_404(User, pk=selected_user_id) try: - change_logs = [] + # Make this atomic so that we don't get any partial transfers + with transaction.atomic(): + change_logs = [] - # Transfer specific fields - self.transfer_user_fields_and_log(selected_user, current_user, change_logs) + # Dynamically handle related fields + self.transfer_related_fields_and_log(selected_user, current_user, change_logs) - # Perform the updates and log the changes - for model_class, field_name in self.JOINS: - self.update_joins_and_log(model_class, field_name, selected_user, current_user, change_logs) - - # Success message if any related objects were updated - if change_logs: - success_message = f"Data transferred successfully for the following objects: {change_logs}" - messages.success(request, success_message) - - selected_user.delete() - messages.success(request, f"Deleted {selected_user} {selected_user.username}") + # Success message if any related objects were updated + if change_logs: + success_message = f"Data transferred successfully for the following objects: {change_logs}" + messages.success(request, success_message) + selected_user.delete() + messages.success(request, f"Deleted {selected_user} {selected_user.username}") except Exception as e: messages.error(request, f"An error occurred during the transfer: {e}") + logger.error(f"An error occurred during the transfer: {e}", exc_info=True) return redirect("admin:registrar_user_change", object_id=user_id) - @classmethod - def update_joins_and_log(cls, model_class, field_name, selected_user, current_user, change_logs): + def transfer_related_fields_and_log(self, selected_user, current_user, change_logs): """ - Helper function to update the user join fields for a given model and log the changes. + Dynamically find all related fields to the User model and transfer them from selected_user to current_user. + Handles ForeignKey, OneToOneField, ManyToManyField, and ManyToOneRel relationships. """ + user_model = User - filter_kwargs = {field_name: selected_user} - updated_objects = model_class.objects.filter(**filter_kwargs) + for related_field in user_model._meta.get_fields(): + if related_field.is_relation: + # Field objects represent forward relationships + if isinstance(related_field, OneToOneField): + self._handle_one_to_one(related_field, selected_user, current_user, change_logs) + elif isinstance(related_field, ManyToManyField): + self._handle_many_to_many(related_field, selected_user, current_user, change_logs) + elif isinstance(related_field, ForeignKey): + self._handle_foreign_key(related_field, selected_user, current_user, change_logs) + # Relationship objects represent reverse relationships + elif isinstance(related_field, ManyToOneRel): + # ManyToOneRel is a reverse ForeignKey + self._handle_foreign_key_reverse(related_field, selected_user, current_user, change_logs) + elif isinstance(related_field, OneToOneRel): + self._handle_one_to_one_reverse(related_field, selected_user, current_user, change_logs) + elif isinstance(related_field, ManyToManyRel): + self._handle_many_to_many_reverse(related_field, selected_user, current_user, change_logs) + else: + logger.error(f"Unknown relationship type for field {related_field}") + raise ValueError(f"Unknown relationship type for field {related_field}") - for obj in updated_objects: - # Check for duplicate UserDomainRole before updating - if model_class == UserDomainRole: - if model_class.objects.filter(user=current_user, domain=obj.domain).exists(): - continue # Skip the update to avoid a duplicate + def _handle_foreign_key_reverse(self, related_field: ManyToOneRel, selected_user, current_user, change_logs): + # Handle reverse ForeignKey relationships + related_manager = getattr(selected_user, related_field.get_accessor_name(), None) + if related_manager and related_manager.exists(): + for related_object in related_manager.all(): + with ignore_unique_violation(): + setattr(related_object, related_field.field.name, current_user) + related_object.save() + self.log_change(related_object, selected_user, current_user, related_field.field.name, change_logs) - if model_class == UserPortfolioPermission: - if model_class.objects.filter(user=current_user, portfolio=obj.portfolio).exists(): - continue # Skip the update to avoid a duplicate + def _handle_foreign_key(self, related_field: ForeignKey, selected_user, current_user, change_logs): + # Handle ForeignKey relationships + related_object = getattr(selected_user, related_field.name, None) + if related_object: + setattr(current_user, related_field.name, related_object) + current_user.save() + self.log_change(related_object, selected_user, current_user, related_field.name, change_logs) - # Update the field on the object and save it - setattr(obj, field_name, current_user) - obj.save() + def _handle_one_to_one(self, related_field: OneToOneField, selected_user, current_user, change_logs): + # Handle OneToOne relationship + related_object = getattr(selected_user, related_field.name, None) + if related_object: + with ignore_unique_violation(): + setattr(current_user, related_field.name, related_object) + current_user.save() + self.log_change(related_object, selected_user, current_user, related_field.name, change_logs) - # Log the change - cls.log_change(obj, field_name, selected_user, current_user, change_logs) + def _handle_many_to_many(self, related_field: ManyToManyField, selected_user, current_user, change_logs): + # Handle ManyToMany relationship + related_name = related_field.remote_field.name + related_manager = getattr(selected_user, related_name, None) + if related_manager and related_manager.exists(): + for instance in related_manager.all(): + with ignore_unique_violation(): + getattr(instance, related_name).remove(selected_user) + getattr(instance, related_name).add(current_user) + self.log_change(instance, selected_user, current_user, related_name, change_logs) + + def _handle_many_to_many_reverse(self, related_field: ManyToManyRel, selected_user, current_user, change_logs): + # Handle reverse relationship + related_name = related_field.field.name + related_manager = getattr(selected_user, related_name, None) + if related_manager and related_manager.exists(): + for instance in related_manager.all(): + with ignore_unique_violation(): + getattr(instance, related_name).remove(selected_user) + getattr(instance, related_name).add(current_user) + self.log_change(instance, selected_user, current_user, related_name, change_logs) + + def _handle_one_to_one_reverse(self, related_field: OneToOneRel, selected_user, current_user, change_logs): + # Handle reverse relationship + field_name = related_field.get_accessor_name() + related_instance = getattr(selected_user, field_name, None) + if related_instance: + setattr(related_instance, field_name, current_user) + related_instance.save() + self.log_change(related_instance, selected_user, current_user, field_name, change_logs) @classmethod - def transfer_user_fields_and_log(cls, selected_user, current_user, change_logs): - """ - Transfers portfolio fields from the selected_user to the current_user. - Logs the changes for each transferred field. - """ - for field in cls.USER_FIELDS: - field_value = getattr(selected_user, field, None) - - if field_value: - setattr(current_user, field, field_value) - cls.log_change(current_user, field, field_value, field_value, change_logs) - - current_user.save() - - @classmethod - def log_change(cls, obj, field_name, field_value, new_value, change_logs): - """Logs the change for a specific field on an object""" - log_entry = f'Changed {field_name} from "{field_value}" to "{new_value}" on {obj}' - + def log_change(cls, obj, selected_user, current_user, field_name, change_logs): + log_entry = f"Changed {field_name} from {selected_user} to {current_user} on {obj}" logger.info(log_entry) - - # Collect the related object for the success message change_logs.append(log_entry) @classmethod diff --git a/src/registrar/views/utility/invitation_helper.py b/src/registrar/views/utility/invitation_helper.py new file mode 100644 index 000000000..18c427940 --- /dev/null +++ b/src/registrar/views/utility/invitation_helper.py @@ -0,0 +1,78 @@ +from django.contrib import messages +from django.db import IntegrityError +from registrar.models import PortfolioInvitation, User, UserPortfolioPermission +from registrar.utility.email import EmailSendingError +import logging +from registrar.utility.errors import ( + AlreadyDomainInvitedError, + AlreadyDomainManagerError, + MissingEmailError, + OutsideOrgMemberError, +) + +logger = logging.getLogger(__name__) + +# These methods are used by multiple views which share similar logic and function +# when creating invitations and sending associated emails. These can be reused in +# any view, and were initially developed for domain.py, portfolios.py and admin.py + + +def get_org_membership(org, email, user): + """ + Determines if an email/user belongs to a different organization or this organization + as either a member or an invited member. + + This function returns a tuple (member_of_a_different_org, member_of_this_org), + which provides: + - member_of_a_different_org: True if the user/email is associated with an organization other than the given org. + - member_of_this_org: True if the user/email is associated with the given org. + + Note: This implementation assumes single portfolio ownership for a user. + If the "multiple portfolios" feature is enabled, this logic may not account for + situations where a user or email belongs to multiple organizations. + """ + + # Check for existing permissions or invitations for the user + existing_org_permission = UserPortfolioPermission.objects.filter(user=user).first() + existing_org_invitation = PortfolioInvitation.objects.filter(email=email).first() + + # Determine membership in a different organization + member_of_a_different_org = (existing_org_permission and existing_org_permission.portfolio != org) or ( + existing_org_invitation and existing_org_invitation.portfolio != org + ) + + # Determine membership in the same organization + member_of_this_org = (existing_org_permission and existing_org_permission.portfolio == org) or ( + existing_org_invitation and existing_org_invitation.portfolio == org + ) + + return member_of_a_different_org, member_of_this_org + + +def get_requested_user(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 + + +def handle_invitation_exceptions(request, exception, email): + """Handle exceptions raised during the process.""" + if isinstance(exception, EmailSendingError): + logger.warning(exception, exc_info=True) + messages.error(request, str(exception)) + elif isinstance(exception, MissingEmailError): + messages.error(request, str(exception)) + logger.error(exception, exc_info=True) + elif isinstance(exception, OutsideOrgMemberError): + messages.error(request, str(exception)) + elif isinstance(exception, AlreadyDomainManagerError): + messages.error(request, str(exception)) + elif isinstance(exception, AlreadyDomainInvitedError): + messages.error(request, str(exception)) + elif isinstance(exception, IntegrityError): + messages.error(request, f"{email} is already a manager for this domain") + else: + logger.warning("Could not send email invitation (Other Exception)", exc_info=True) + messages.error(request, "Could not send email invitation.") diff --git a/src/registrar/views/utility/mixins.py b/src/registrar/views/utility/mixins.py index 11384ca09..08212088b 100644 --- a/src/registrar/views/utility/mixins.py +++ b/src/registrar/views/utility/mixins.py @@ -192,7 +192,8 @@ class DomainPermission(PermissionsLoginMixin): def can_access_domain_via_portfolio(self, pk): """Most views should not allow permission to portfolio users. If particular views allow access to the domain pages, they will need to override - this function.""" + this function. + """ return False def in_editable_state(self, pk): @@ -343,12 +344,6 @@ class UserDeleteDomainRolePermission(PermissionsLoginMixin): if not (has_delete_permission or user_is_analyst_or_superuser): return False - # Check if more than one manager exists on the domain. - # If only one exists, prevent this from happening - has_multiple_managers = len(UserDomainRole.objects.filter(domain=domain_pk)) > 1 - if not has_multiple_managers: - return False - return True diff --git a/src/registrar/views/utility/permission_views.py b/src/registrar/views/utility/permission_views.py index a3067d3a2..3234ea701 100644 --- a/src/registrar/views/utility/permission_views.py +++ b/src/registrar/views/utility/permission_views.py @@ -65,7 +65,6 @@ class DomainPermissionView(DomainPermission, DetailView, abc.ABC): def is_editable(self): """Returns whether domain is editable in the context of the view""" - logger.info("checking if is_editable") domain_editable = self.object.is_editable() if not domain_editable: return False diff --git a/src/requirements.txt b/src/requirements.txt index 52c601b55..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.10; python_version >= '3.8' +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' diff --git a/src/zap.conf b/src/zap.conf index 65468773a..a0a60bdc7 100644 --- a/src/zap.conf +++ b/src/zap.conf @@ -70,6 +70,7 @@ 10038 OUTOFSCOPE http://app:8080/org-name-address 10038 OUTOFSCOPE http://app:8080/domain_requests/ 10038 OUTOFSCOPE http://app:8080/domains/ +10038 OUTOFSCOPE http://app:8080/domains/edit 10038 OUTOFSCOPE http://app:8080/organization/ 10038 OUTOFSCOPE http://app:8080/permissions 10038 OUTOFSCOPE http://app:8080/suborganization/