mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-05-18 18:39:21 +02:00
Merge remote-tracking branch 'origin' into es/1793-link-federal-agency-table
This commit is contained in:
commit
dfda5b37d4
42 changed files with 1601 additions and 164 deletions
23
docs/developer/adding-feature-flags.md
Normal file
23
docs/developer/adding-feature-flags.md
Normal file
|
@ -0,0 +1,23 @@
|
|||
# Adding feature flags
|
||||
Feature flags are booleans (stored in our DB as the `WaffleFlag` object) that programmatically disable/enable "features" (such as DNS hosting) for a specified set of users.
|
||||
|
||||
We use [django-waffle](https://waffle.readthedocs.io/en/stable/) for our feature flags. Waffle makes using flags fairly straight forward.
|
||||
|
||||
## Adding feature flags through django admin
|
||||
1. On the app, navigate to `\admin`.
|
||||
2. Under models, click `Waffle flags`.
|
||||
3. Click `Add waffle flag`.
|
||||
4. Add the model as you would normally. Refer to waffle's documentation [regarding attributes](https://waffle.readthedocs.io/en/stable/types/flag.html#flag-attributes) for more information on them.
|
||||
|
||||
### Enabling the profile_feature flag
|
||||
1. On the app, navigate to `\admin`.
|
||||
2. Under models, click `Waffle flags`.
|
||||
3. Click the `profile_feature` record. This should exist by default, if not - create one with that name.
|
||||
4. (Important) Set the field `Everyone` to `Unknown`. This field overrides all other settings when set to anything else.
|
||||
5. Configure the settings as you see fit.
|
||||
|
||||
## Using feature flags as boolean values
|
||||
Waffle [provides a boolean](https://waffle.readthedocs.io/en/stable/usage/views.html) called `flag_is_active` that you can use as you otherwise would a boolean. This boolean requires a request object and the flag name.
|
||||
|
||||
## Using feature flags to disable/enable views
|
||||
Waffle [provides a decorator](https://waffle.readthedocs.io/en/stable/usage/decorators.html) that you can use to enable/disable views. When disabled, the view will return a 404 if said user tries to navigate to it.
|
|
@ -32,6 +32,7 @@ fred-epplib = {git = "https://github.com/cisagov/epplib.git", ref = "master"}
|
|||
pyzipper="*"
|
||||
tblib = "*"
|
||||
django-admin-multiple-choice-list-filter = "*"
|
||||
django-waffle = "*"
|
||||
|
||||
[dev-packages]
|
||||
django-debug-toolbar = "*"
|
||||
|
|
190
src/Pipfile.lock
generated
190
src/Pipfile.lock
generated
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "16a0db98015509322cf1d27f06fced5b7635057c4eb98921a9419d63d51925ab"
|
||||
"sha256": "9095c4f98f58a9502444584067a63f329d5a5fc4b49454c4e129bda09552d19d"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {},
|
||||
|
@ -32,20 +32,20 @@
|
|||
},
|
||||
"boto3": {
|
||||
"hashes": [
|
||||
"sha256:2824e3dd18743ca50e5b10439d20e74647b1416e8a94509cb30beac92d27a18d",
|
||||
"sha256:b2e5cb5b95efcc881e25a3bc872d7a24e75ff4e76f368138e4baf7b9d6ee3422"
|
||||
"sha256:decf52f8d5d8a1b10c9ff2a0e96ee207ed79e33d2e53fdf0880a5cbef70785e0",
|
||||
"sha256:e836b71d79671270fccac0a4d4c8ec239a6b82ea47c399b64675aa597d0ee63b"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==1.34.90"
|
||||
"version": "==1.34.95"
|
||||
},
|
||||
"botocore": {
|
||||
"hashes": [
|
||||
"sha256:113cd4c0cb63e13163ccbc2bb13d551be314ba7f8ba5bfab1c51a19ca01aa133",
|
||||
"sha256:d48f152498e2c60b43ce25b579d26642346a327b6fb2c632d57219e0a4f63392"
|
||||
"sha256:6bd76a2eadb42b91fa3528392e981ad5b4dfdee3968fa5b904278acf6cbf15ff",
|
||||
"sha256:ead5823e0dd6751ece5498cb979fd9abf190e691c8833bcac6876fd6ca261fa7"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==1.34.90"
|
||||
"version": "==1.34.95"
|
||||
},
|
||||
"cachetools": {
|
||||
"hashes": [
|
||||
|
@ -370,6 +370,15 @@
|
|||
"markers": "python_version >= '3.8'",
|
||||
"version": "==7.3.0"
|
||||
},
|
||||
"django-waffle": {
|
||||
"hashes": [
|
||||
"sha256:5979a2f3dd674ef7086480525b39651fc2045427f6d8e6a614192656d3402c5b",
|
||||
"sha256:e49d7d461d89f3bd8e53f20efe39310acca8f275c9888495e68e195345bf18b1"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==4.1.0"
|
||||
},
|
||||
"django-widget-tweaks": {
|
||||
"hashes": [
|
||||
"sha256:1c2180681ebb994e922c754804c7ffebbe1245014777ac47897a81f57cc629c7",
|
||||
|
@ -392,12 +401,12 @@
|
|||
},
|
||||
"faker": {
|
||||
"hashes": [
|
||||
"sha256:34b947581c2bced340c39b35f89dbfac4f356932cfff8fe893bde854903f0e6e",
|
||||
"sha256:adb98e771073a06bdc5d2d6710d8af07ac5da64c8dc2ae3b17bb32319e66fd82"
|
||||
"sha256:87ef41e24b39a5be66ecd874af86f77eebd26782a2681200e86c5326340a95d3",
|
||||
"sha256:e23a2b74888885c3d23a9237bacb823041291c03d609a39acb9ebe6c123f3986"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==24.11.0"
|
||||
"version": "==25.0.0"
|
||||
},
|
||||
"fred-epplib": {
|
||||
"git": "https://github.com/cisagov/epplib.git",
|
||||
|
@ -588,7 +597,6 @@
|
|||
"sha256:3d0c3dd24bb4605439bf91068598d00c6370684f8de4a67c2992683f6c309d6b",
|
||||
"sha256:3dbe858ee582cbb2c6294dc85f55b5f19c918c2597855e950f34b660f1a5ede6",
|
||||
"sha256:3dc773b2861b37b41a6136e0b72a1a44689a9c4c101e0cddb6b854016acc0aa8",
|
||||
"sha256:3e183c6e3298a2ed5af9d7a356ea823bccaab4ec2349dc9ed83999fd289d14d5",
|
||||
"sha256:3f7765e69bbce0906a7c74d5fe46d2c7a7596147318dbc08e4a2431f3060e306",
|
||||
"sha256:417d14450f06d51f363e41cace6488519038f940676ce9664b34ebf5653433a5",
|
||||
"sha256:44f6c7caff88d988db017b9b0e4ab04934f11e3e72d478031efc7edcac6c622f",
|
||||
|
@ -801,12 +809,12 @@
|
|||
},
|
||||
"oic": {
|
||||
"hashes": [
|
||||
"sha256:385a1f64bb59519df1e23840530921bf416740240f505ea6d161e331d3d39fad",
|
||||
"sha256:fcbf948a22e4d4df66f6bf57d327933f32a7b539640d9b42883457634360ba78"
|
||||
"sha256:b74bd06c7de1ab4f8e798f714062e6a68f68ad9cdbed1f1c30a7fb887602f321",
|
||||
"sha256:e51705d0c14c97e9ca594374bfb54269a72c9b489e0e979598344c0189bfcb64"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version ~= '3.7'",
|
||||
"version": "==1.6.1"
|
||||
"markers": "python_version ~= '3.8'",
|
||||
"version": "==1.7.0"
|
||||
},
|
||||
"orderedmultidict": {
|
||||
"hashes": [
|
||||
|
@ -1244,49 +1252,49 @@
|
|||
},
|
||||
"black": {
|
||||
"hashes": [
|
||||
"sha256:1bb9ca06e556a09f7f7177bc7cb604e5ed2d2df1e9119e4f7d2f1f7071c32e5d",
|
||||
"sha256:21f9407063ec71c5580b8ad975653c66508d6a9f57bd008bb8691d273705adcd",
|
||||
"sha256:4396ca365a4310beef84d446ca5016f671b10f07abdba3e4e4304218d2c71d33",
|
||||
"sha256:44d99dfdf37a2a00a6f7a8dcbd19edf361d056ee51093b2445de7ca09adac965",
|
||||
"sha256:5cd5b4f76056cecce3e69b0d4c228326d2595f506797f40b9233424e2524c070",
|
||||
"sha256:64578cf99b6b46a6301bc28bdb89f9d6f9b592b1c5837818a177c98525dbe397",
|
||||
"sha256:64e60a7edd71fd542a10a9643bf369bfd2644de95ec71e86790b063aa02ff745",
|
||||
"sha256:652e55bb722ca026299eb74e53880ee2315b181dfdd44dca98e43448620ddec1",
|
||||
"sha256:6644f97a7ef6f401a150cca551a1ff97e03c25d8519ee0bbc9b0058772882665",
|
||||
"sha256:6ad001a9ddd9b8dfd1b434d566be39b1cd502802c8d38bbb1ba612afda2ef436",
|
||||
"sha256:71d998b73c957444fb7c52096c3843875f4b6b47a54972598741fe9a7f737fcb",
|
||||
"sha256:74eb9b5420e26b42c00a3ff470dc0cd144b80a766128b1771d07643165e08d0e",
|
||||
"sha256:75a2d0b4f5eb81f7eebc31f788f9830a6ce10a68c91fbe0fade34fff7a2836e6",
|
||||
"sha256:7852b05d02b5b9a8c893ab95863ef8986e4dda29af80bbbda94d7aee1abf8702",
|
||||
"sha256:7f2966b9b2b3b7104fca9d75b2ee856fe3fdd7ed9e47c753a4bb1a675f2caab8",
|
||||
"sha256:8e5537f456a22cf5cfcb2707803431d2feeb82ab3748ade280d6ccd0b40ed2e8",
|
||||
"sha256:d4e71cdebdc8efeb6deaf5f2deb28325f8614d48426bed118ecc2dcaefb9ebf3",
|
||||
"sha256:dae79397f367ac8d7adb6c779813328f6d690943f64b32983e896bcccd18cbad",
|
||||
"sha256:e3a3a092b8b756c643fe45f4624dbd5a389f770a4ac294cf4d0fce6af86addaf",
|
||||
"sha256:eb949f56a63c5e134dfdca12091e98ffb5fd446293ebae123d10fc1abad00b9e",
|
||||
"sha256:f07b69fda20578367eaebbd670ff8fc653ab181e1ff95d84497f9fa20e7d0641",
|
||||
"sha256:f95cece33329dc4aa3b0e1a771c41075812e46cf3d6e3f1dfe3d91ff09826ed2"
|
||||
"sha256:257d724c2c9b1660f353b36c802ccece186a30accc7742c176d29c146df6e474",
|
||||
"sha256:37aae07b029fa0174d39daf02748b379399b909652a806e5708199bd93899da1",
|
||||
"sha256:415e686e87dbbe6f4cd5ef0fbf764af7b89f9057b97c908742b6008cc554b9c0",
|
||||
"sha256:48a85f2cb5e6799a9ef05347b476cce6c182d6c71ee36925a6c194d074336ef8",
|
||||
"sha256:7768a0dbf16a39aa5e9a3ded568bb545c8c2727396d063bbaf847df05b08cd96",
|
||||
"sha256:7e122b1c4fb252fd85df3ca93578732b4749d9be076593076ef4d07a0233c3e1",
|
||||
"sha256:88c57dc656038f1ab9f92b3eb5335ee9b021412feaa46330d5eba4e51fe49b04",
|
||||
"sha256:8e537d281831ad0e71007dcdcbe50a71470b978c453fa41ce77186bbe0ed6021",
|
||||
"sha256:98e123f1d5cfd42f886624d84464f7756f60ff6eab89ae845210631714f6db94",
|
||||
"sha256:accf49e151c8ed2c0cdc528691838afd217c50412534e876a19270fea1e28e2d",
|
||||
"sha256:b1530ae42e9d6d5b670a34db49a94115a64596bc77710b1d05e9801e62ca0a7c",
|
||||
"sha256:b9176b9832e84308818a99a561e90aa479e73c523b3f77afd07913380ae2eab7",
|
||||
"sha256:bdde6f877a18f24844e381d45e9947a49e97933573ac9d4345399be37621e26c",
|
||||
"sha256:be8bef99eb46d5021bf053114442914baeb3649a89dc5f3a555c88737e5e98fc",
|
||||
"sha256:bf10f7310db693bb62692609b397e8d67257c55f949abde4c67f9cc574492cc7",
|
||||
"sha256:c872b53057f000085da66a19c55d68f6f8ddcac2642392ad3a355878406fbd4d",
|
||||
"sha256:d36ed1124bb81b32f8614555b34cc4259c3fbc7eec17870e8ff8ded335b58d8c",
|
||||
"sha256:da33a1a5e49c4122ccdfd56cd021ff1ebc4a1ec4e2d01594fef9b6f267a9e741",
|
||||
"sha256:dd1b5a14e417189db4c7b64a6540f31730713d173f0b63e55fabd52d61d8fdce",
|
||||
"sha256:e151054aa00bad1f4e1f04919542885f89f5f7d086b8a59e5000e6c616896ffb",
|
||||
"sha256:eaea3008c281f1038edb473c1aa8ed8143a5535ff18f978a318f10302b254063",
|
||||
"sha256:ef703f83fc32e131e9bcc0a5094cfe85599e7109f896fe8bc96cc402f3eb4b6e"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==24.4.0"
|
||||
"version": "==24.4.2"
|
||||
},
|
||||
"blinker": {
|
||||
"hashes": [
|
||||
"sha256:c3f865d4d54db7abc53758a01601cf343fe55b84c1de4e3fa910e420b438d5b9",
|
||||
"sha256:e6820ff6fa4e4d1d8e2747c2283749c3f547e4fee112b98555cdcdae32996182"
|
||||
"sha256:5f1cdeff423b77c31b89de0565cd03e5275a03028f44b2b15f912632a58cced6",
|
||||
"sha256:da44ec748222dcd0105ef975eed946da197d5bdf8bafb6aa92f5bc89da63fa25"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==1.7.0"
|
||||
"version": "==1.8.1"
|
||||
},
|
||||
"boto3": {
|
||||
"hashes": [
|
||||
"sha256:2824e3dd18743ca50e5b10439d20e74647b1416e8a94509cb30beac92d27a18d",
|
||||
"sha256:b2e5cb5b95efcc881e25a3bc872d7a24e75ff4e76f368138e4baf7b9d6ee3422"
|
||||
"sha256:decf52f8d5d8a1b10c9ff2a0e96ee207ed79e33d2e53fdf0880a5cbef70785e0",
|
||||
"sha256:e836b71d79671270fccac0a4d4c8ec239a6b82ea47c399b64675aa597d0ee63b"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==1.34.90"
|
||||
"version": "==1.34.95"
|
||||
},
|
||||
"boto3-mocking": {
|
||||
"hashes": [
|
||||
|
@ -1299,28 +1307,28 @@
|
|||
},
|
||||
"boto3-stubs": {
|
||||
"hashes": [
|
||||
"sha256:7361f162523168ddcfb3e0cc70e5208e78f95b9f1f2553032036a2b67ab33355",
|
||||
"sha256:c82f3db8558e28f766361ba1eea7c77dff735f72fef2a0b9dffaa9c0d9ae76a3"
|
||||
"sha256:412006b27ee707e9b51a084b02ac92b143af8a3b56727582afec2a76ce93c3b6",
|
||||
"sha256:4fb5830626de42446c238ca72ca1a53e461281396007fb900edf50ceeb044a10"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==1.34.90"
|
||||
"version": "==1.34.95"
|
||||
},
|
||||
"botocore": {
|
||||
"hashes": [
|
||||
"sha256:113cd4c0cb63e13163ccbc2bb13d551be314ba7f8ba5bfab1c51a19ca01aa133",
|
||||
"sha256:d48f152498e2c60b43ce25b579d26642346a327b6fb2c632d57219e0a4f63392"
|
||||
"sha256:6bd76a2eadb42b91fa3528392e981ad5b4dfdee3968fa5b904278acf6cbf15ff",
|
||||
"sha256:ead5823e0dd6751ece5498cb979fd9abf190e691c8833bcac6876fd6ca261fa7"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==1.34.90"
|
||||
"version": "==1.34.95"
|
||||
},
|
||||
"botocore-stubs": {
|
||||
"hashes": [
|
||||
"sha256:b2d7416b524bce7325aa5fe09bb5e0b6bc9531d4136f4407fa39b6bc58507f34",
|
||||
"sha256:d9b66542cbb8fbe28eef3c22caf941ae22d36cc1ef55b93fc0b52239457cab57"
|
||||
"sha256:64d80a3467e3b19939e9c2750af33328b3087f8f524998dbdf7ed168227f507d",
|
||||
"sha256:b0345f55babd8b901c53804fc5c326a4a0bd2e23e3b71f9ea5d9f7663466e6ba"
|
||||
],
|
||||
"markers": "python_version >= '3.8' and python_version < '4.0'",
|
||||
"version": "==1.34.89"
|
||||
"version": "==1.34.94"
|
||||
},
|
||||
"click": {
|
||||
"hashes": [
|
||||
|
@ -1357,20 +1365,20 @@
|
|||
},
|
||||
"django-stubs": {
|
||||
"hashes": [
|
||||
"sha256:4cf4de258fa71adc6f2799e983091b9d46cfc67c6eebc68fe111218c9a62b3b8",
|
||||
"sha256:8ccd2ff4ee5adf22b9e3b7b1a516d2e1c2191e9d94e672c35cc2bc3dd61e0f6b"
|
||||
"sha256:084484cbe16a6d388e80ec687e46f529d67a232f3befaf55c936b3b476be289d",
|
||||
"sha256:b8a792bee526d6cab31e197cb414ee7fa218abd931a50948c66a80b3a2548621"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==4.2.7"
|
||||
"version": "==5.0.0"
|
||||
},
|
||||
"django-stubs-ext": {
|
||||
"hashes": [
|
||||
"sha256:45a5d102417a412e3606e3c358adb4744988a92b7b58ccf3fd64bddd5d04d14c",
|
||||
"sha256:519342ac0849cda1559746c9a563f03ff99f636b0ebe7c14b75e816a00dfddc3"
|
||||
"sha256:5bacfbb498a206d5938454222b843d81da79ea8b6fcd1a59003f529e775bc115",
|
||||
"sha256:8e1334fdf0c8bff87e25d593b33d4247487338aaed943037826244ff788b56a8"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==4.2.7"
|
||||
"version": "==5.0.0"
|
||||
},
|
||||
"django-webtest": {
|
||||
"hashes": [
|
||||
|
@ -1423,37 +1431,37 @@
|
|||
},
|
||||
"mypy": {
|
||||
"hashes": [
|
||||
"sha256:0235391f1c6f6ce487b23b9dbd1327b4ec33bb93934aa986efe8a9563d9349e6",
|
||||
"sha256:190da1ee69b427d7efa8aa0d5e5ccd67a4fb04038c380237a0d96829cb157913",
|
||||
"sha256:2418488264eb41f69cc64a69a745fad4a8f86649af4b1041a4c64ee61fc61129",
|
||||
"sha256:3a3c007ff3ee90f69cf0a15cbcdf0995749569b86b6d2f327af01fd1b8aee9dc",
|
||||
"sha256:3cc5da0127e6a478cddd906068496a97a7618a21ce9b54bde5bf7e539c7af974",
|
||||
"sha256:48533cdd345c3c2e5ef48ba3b0d3880b257b423e7995dada04248725c6f77374",
|
||||
"sha256:49c87c15aed320de9b438ae7b00c1ac91cd393c1b854c2ce538e2a72d55df150",
|
||||
"sha256:4d3dbd346cfec7cb98e6cbb6e0f3c23618af826316188d587d1c1bc34f0ede03",
|
||||
"sha256:571741dc4194b4f82d344b15e8837e8c5fcc462d66d076748142327626a1b6e9",
|
||||
"sha256:587ce887f75dd9700252a3abbc9c97bbe165a4a630597845c61279cf32dfbf02",
|
||||
"sha256:5d741d3fc7c4da608764073089e5f58ef6352bedc223ff58f2f038c2c4698a89",
|
||||
"sha256:5e6061f44f2313b94f920e91b204ec600982961e07a17e0f6cd83371cb23f5c2",
|
||||
"sha256:61758fabd58ce4b0720ae1e2fea5cfd4431591d6d590b197775329264f86311d",
|
||||
"sha256:653265f9a2784db65bfca694d1edd23093ce49740b2244cde583aeb134c008f3",
|
||||
"sha256:68edad3dc7d70f2f17ae4c6c1b9471a56138ca22722487eebacfd1eb5321d612",
|
||||
"sha256:81a10926e5473c5fc3da8abb04119a1f5811a236dc3a38d92015cb1e6ba4cb9e",
|
||||
"sha256:85ca5fcc24f0b4aeedc1d02f93707bccc04733f21d41c88334c5482219b1ccb3",
|
||||
"sha256:a260627a570559181a9ea5de61ac6297aa5af202f06fd7ab093ce74e7181e43e",
|
||||
"sha256:aceb1db093b04db5cd390821464504111b8ec3e351eb85afd1433490163d60cd",
|
||||
"sha256:b685154e22e4e9199fc95f298661deea28aaede5ae16ccc8cbb1045e716b3e04",
|
||||
"sha256:d357423fa57a489e8c47b7c85dfb96698caba13d66e086b412298a1a0ea3b0ed",
|
||||
"sha256:d4d5ddc13421ba3e2e082a6c2d74c2ddb3979c39b582dacd53dd5d9431237185",
|
||||
"sha256:e49499be624dead83927e70c756970a0bc8240e9f769389cdf5714b0784ca6bf",
|
||||
"sha256:e54396d70be04b34f31d2edf3362c1edd023246c82f1730bbf8768c28db5361b",
|
||||
"sha256:f88566144752999351725ac623471661c9d1cd8caa0134ff98cceeea181789f4",
|
||||
"sha256:f8a67616990062232ee4c3952f41c779afac41405806042a8126fe96e098419f",
|
||||
"sha256:fe28657de3bfec596bbeef01cb219833ad9d38dd5393fc649f4b366840baefe6"
|
||||
"sha256:075cbf81f3e134eadaf247de187bd604748171d6b79736fa9b6c9685b4083061",
|
||||
"sha256:12b6bfc1b1a66095ab413160a6e520e1dc076a28f3e22f7fb25ba3b000b4ef99",
|
||||
"sha256:1ec404a7cbe9fc0e92cb0e67f55ce0c025014e26d33e54d9e506a0f2d07fe5de",
|
||||
"sha256:28d0e038361b45f099cc086d9dd99c15ff14d0188f44ac883010e172ce86c38a",
|
||||
"sha256:2b0695d605ddcd3eb2f736cd8b4e388288c21e7de85001e9f85df9187f2b50f9",
|
||||
"sha256:3236a4c8f535a0631f85f5fcdffba71c7feeef76a6002fcba7c1a8e57c8be1ec",
|
||||
"sha256:3be66771aa5c97602f382230165b856c231d1277c511c9a8dd058be4784472e1",
|
||||
"sha256:3d087fcbec056c4ee34974da493a826ce316947485cef3901f511848e687c131",
|
||||
"sha256:3f298531bca95ff615b6e9f2fc0333aae27fa48052903a0ac90215021cdcfa4f",
|
||||
"sha256:4a2b5cdbb5dd35aa08ea9114436e0d79aceb2f38e32c21684dcf8e24e1e92821",
|
||||
"sha256:4cf18f9d0efa1b16478c4c129eabec36148032575391095f73cae2e722fcf9d5",
|
||||
"sha256:8b2cbaca148d0754a54d44121b5825ae71868c7592a53b7292eeb0f3fdae95ee",
|
||||
"sha256:8f55583b12156c399dce2df7d16f8a5095291354f1e839c252ec6c0611e86e2e",
|
||||
"sha256:92f93b21c0fe73dc00abf91022234c79d793318b8a96faac147cd579c1671746",
|
||||
"sha256:9e36fb078cce9904c7989b9693e41cb9711e0600139ce3970c6ef814b6ebc2b2",
|
||||
"sha256:9fd50226364cd2737351c79807775136b0abe084433b55b2e29181a4c3c878c0",
|
||||
"sha256:a781f6ad4bab20eef8b65174a57e5203f4be627b46291f4589879bf4e257b97b",
|
||||
"sha256:a87dbfa85971e8d59c9cc1fcf534efe664d8949e4c0b6b44e8ca548e746a8d53",
|
||||
"sha256:b808e12113505b97d9023b0b5e0c0705a90571c6feefc6f215c1df9381256e30",
|
||||
"sha256:bc6ac273b23c6b82da3bb25f4136c4fd42665f17f2cd850771cb600bdd2ebeda",
|
||||
"sha256:cd777b780312ddb135bceb9bc8722a73ec95e042f911cc279e2ec3c667076051",
|
||||
"sha256:da1cbf08fb3b851ab3b9523a884c232774008267b1f83371ace57f412fe308c2",
|
||||
"sha256:e22e1527dc3d4aa94311d246b59e47f6455b8729f4968765ac1eacf9a4760bc7",
|
||||
"sha256:f8c083976eb530019175aabadb60921e73b4f45736760826aa1689dda8208aee",
|
||||
"sha256:f90cff89eea89273727d8783fef5d4a934be2fdca11b47def50cf5d311aff727",
|
||||
"sha256:fa7ef5244615a2523b56c034becde4e9e3f9b034854c93639adb667ec9ec2976",
|
||||
"sha256:fcfc70599efde5c67862a07a1aaf50e55bce629ace26bb19dc17cece5dd31ca4"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==1.9.0"
|
||||
"version": "==1.10.0"
|
||||
},
|
||||
"mypy-extensions": {
|
||||
"hashes": [
|
||||
|
@ -1665,14 +1673,6 @@
|
|||
"markers": "python_version >= '3.7'",
|
||||
"version": "==5.3.0.7"
|
||||
},
|
||||
"types-pytz": {
|
||||
"hashes": [
|
||||
"sha256:6810c8a1f68f21fdf0f4f374a432487c77645a0ac0b31de4bf4690cf21ad3981",
|
||||
"sha256:8335d443310e2db7b74e007414e74c4f53b67452c0cb0d228ca359ccfba59659"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==2024.1.0.20240417"
|
||||
},
|
||||
"types-pyyaml": {
|
||||
"hashes": [
|
||||
"sha256:a9e0f0f88dc835739b0c1ca51ee90d04ca2a897a71af79de9aec5f38cb0a5342",
|
||||
|
|
|
@ -65,13 +65,31 @@ class OpenIdConnectBackend(ModelBackend):
|
|||
return user
|
||||
|
||||
def update_existing_user(self, user, kwargs):
|
||||
"""Update other fields without overwriting first_name and last_name.
|
||||
Overwrite first_name and last_name if not empty string"""
|
||||
"""
|
||||
Update user fields without overwriting certain fields.
|
||||
|
||||
Args:
|
||||
user: User object to be updated.
|
||||
kwargs: Dictionary containing fields to update and their new values.
|
||||
|
||||
Note:
|
||||
This method updates user fields while preserving the values of 'first_name',
|
||||
'last_name', and 'phone' fields, unless specific conditions are met.
|
||||
|
||||
- 'first_name', 'last_name' or 'phone' will be updated if the provided value is not empty.
|
||||
"""
|
||||
|
||||
fields_to_check = ["first_name", "last_name", "phone"]
|
||||
|
||||
# Iterate over fields to update
|
||||
for key, value in kwargs.items():
|
||||
# Check if the key is not first_name or last_name or value is not empty string
|
||||
if key not in ["first_name", "last_name"] or value:
|
||||
# Check if the field is not 'first_name', 'last_name', or 'phone',
|
||||
# or if it's 'first_name' or 'last_name' or 'phone' and the provided value is not empty
|
||||
if key not in fields_to_check or (key in fields_to_check and value):
|
||||
# Update the corresponding attribute of the user object
|
||||
setattr(user, key, value)
|
||||
|
||||
# Save the user object with the updated fields
|
||||
user.save()
|
||||
|
||||
def clean_username(self, username):
|
||||
|
|
|
@ -50,15 +50,21 @@ class OpenIdConnectBackendTestCase(TestCase):
|
|||
self.assertEqual(user.email, "john.doe@example.com")
|
||||
self.assertEqual(user.phone, "123456789")
|
||||
|
||||
def test_authenticate_with_existing_user_no_name(self):
|
||||
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"""
|
||||
For this test, given_name and family_name are not supplied.
|
||||
|
||||
The existing user's first and last name are not overwritten.
|
||||
The existing user's phone number is not overwritten"""
|
||||
# Create an existing user with the same username and with first and last names
|
||||
existing_user = User.objects.create_user(username="test_user", first_name="John", last_name="Doe")
|
||||
existing_user = User.objects.create_user(
|
||||
username="test_user", first_name="WillNotBe", last_name="Replaced", phone="9999999999"
|
||||
)
|
||||
|
||||
# Remove given_name and family_name from the input, self.kwargs
|
||||
self.kwargs.pop("given_name", None)
|
||||
self.kwargs.pop("family_name", None)
|
||||
self.kwargs.pop("phone", None)
|
||||
|
||||
# Ensure that the authenticate method updates the existing user
|
||||
# and preserves existing first and last names
|
||||
|
@ -68,16 +74,18 @@ class OpenIdConnectBackendTestCase(TestCase):
|
|||
self.assertEqual(user, existing_user) # The same user instance should be returned
|
||||
|
||||
# Verify that user fields are correctly updated
|
||||
self.assertEqual(user.first_name, "John")
|
||||
self.assertEqual(user.last_name, "Doe")
|
||||
self.assertEqual(user.first_name, "WillNotBe")
|
||||
self.assertEqual(user.last_name, "Replaced")
|
||||
self.assertEqual(user.email, "john.doe@example.com")
|
||||
self.assertEqual(user.phone, "123456789")
|
||||
self.assertEqual(user.phone, "9999999999")
|
||||
|
||||
def test_authenticate_with_existing_user_different_name(self):
|
||||
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"""
|
||||
# Create an existing user with the same username and with first and last names
|
||||
existing_user = User.objects.create_user(username="test_user", first_name="WillBe", last_name="Replaced")
|
||||
existing_user = User.objects.create_user(
|
||||
username="test_user", first_name="WillBe", last_name="Replaced", phone="987654321"
|
||||
)
|
||||
|
||||
# Ensure that the authenticate method updates the existing user
|
||||
# and preserves existing first and last names
|
||||
|
|
|
@ -15,6 +15,8 @@ from django.contrib.contenttypes.models import ContentType
|
|||
from django.urls import reverse
|
||||
from dateutil.relativedelta import relativedelta # type: ignore
|
||||
from epplibwrapper.errors import ErrorCode, RegistryError
|
||||
from waffle.admin import FlagAdmin
|
||||
from waffle.models import Sample, Switch
|
||||
from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website
|
||||
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
|
||||
from registrar.views.utility.mixins import OrderableFieldsMixin
|
||||
|
@ -1013,7 +1015,7 @@ class DomainInformationAdmin(ListHeaderAdmin):
|
|||
(
|
||||
"Show details",
|
||||
{
|
||||
"classes": ["collapse--dotgov"],
|
||||
"classes": ["collapse--dgfieldset"],
|
||||
"description": "Extends type of organization",
|
||||
"fields": [
|
||||
"federal_type",
|
||||
|
@ -1037,7 +1039,7 @@ class DomainInformationAdmin(ListHeaderAdmin):
|
|||
(
|
||||
"Show details",
|
||||
{
|
||||
"classes": ["collapse--dotgov"],
|
||||
"classes": ["collapse--dgfieldset"],
|
||||
"description": "Extends organization name and mailing address",
|
||||
"fields": [
|
||||
"address_line1",
|
||||
|
@ -1264,7 +1266,7 @@ class DomainRequestAdmin(ListHeaderAdmin):
|
|||
(
|
||||
"Show details",
|
||||
{
|
||||
"classes": ["collapse--dotgov"],
|
||||
"classes": ["collapse--dgfieldset"],
|
||||
"description": "Extends type of organization",
|
||||
"fields": [
|
||||
"federal_type",
|
||||
|
@ -1288,7 +1290,7 @@ class DomainRequestAdmin(ListHeaderAdmin):
|
|||
(
|
||||
"Show details",
|
||||
{
|
||||
"classes": ["collapse--dotgov"],
|
||||
"classes": ["collapse--dgfieldset"],
|
||||
"description": "Extends organization name and mailing address",
|
||||
"fields": [
|
||||
"address_line1",
|
||||
|
@ -2154,7 +2156,16 @@ class UserGroupAdmin(AuditedAdmin):
|
|||
return obj.name
|
||||
|
||||
|
||||
class WaffleFlagAdmin(FlagAdmin):
|
||||
class Meta:
|
||||
"""Contains meta information about this class"""
|
||||
|
||||
model = models.WaffleFlag
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
admin.site.unregister(LogEntry) # Unregister the default registration
|
||||
|
||||
admin.site.register(LogEntry, CustomLogEntryAdmin)
|
||||
admin.site.register(models.User, MyUserAdmin)
|
||||
# Unregister the built-in Group model
|
||||
|
@ -2176,3 +2187,10 @@ admin.site.register(models.PublicContact, PublicContactAdmin)
|
|||
admin.site.register(models.DomainRequest, DomainRequestAdmin)
|
||||
admin.site.register(models.TransitionDomain, TransitionDomainAdmin)
|
||||
admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin)
|
||||
|
||||
# Register our custom waffle implementations
|
||||
admin.site.register(models.WaffleFlag, WaffleFlagAdmin)
|
||||
|
||||
# Unregister Sample and Switch from the waffle library
|
||||
admin.site.unregister(Sample)
|
||||
admin.site.unregister(Switch)
|
||||
|
|
76
src/registrar/assets/js/dja-collapse.js
Normal file
76
src/registrar/assets/js/dja-collapse.js
Normal file
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
* We will run our own version of
|
||||
* https://github.com/django/django/blob/195d885ca01b14e3ce9a1881c3b8f7074f953736/django/contrib/admin/static/admin/js/collapse.js
|
||||
* Works with our fieldset override
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
{
|
||||
window.addEventListener('load', function() {
|
||||
// Add anchor tag for Show/Hide link
|
||||
const fieldsets = document.querySelectorAll('fieldset.collapse--dgfieldset');
|
||||
for (const [i, elem] of fieldsets.entries()) {
|
||||
// Don't hide if fields in this fieldset have errors
|
||||
if (elem.querySelectorAll('div.errors, ul.errorlist').length === 0) {
|
||||
elem.classList.add('collapsed');
|
||||
const button = elem.querySelector('button');
|
||||
button.id = 'fieldsetcollapser' + i;
|
||||
button.className = 'collapse-toggle--dgfieldset usa-button usa-button--unstyled';
|
||||
}
|
||||
}
|
||||
// Add toggle to hide/show anchor tag
|
||||
const toggleFuncDotgov = function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const fieldset = this.closest('fieldset');
|
||||
const spanElement = this.querySelector('span');
|
||||
const useElement = this.querySelector('use');
|
||||
if (fieldset.classList.contains('collapsed')) {
|
||||
// Show
|
||||
spanElement.textContent = 'Hide details';
|
||||
useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_less');
|
||||
fieldset.classList.remove('collapsed');
|
||||
} else {
|
||||
// Hide
|
||||
spanElement.textContent = 'Show details';
|
||||
useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_more');
|
||||
fieldset.classList.add('collapsed');
|
||||
}
|
||||
};
|
||||
document.querySelectorAll('.collapse-toggle--dgfieldset').forEach(function(el) {
|
||||
el.addEventListener('click', toggleFuncDotgov);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
'use strict';
|
||||
{
|
||||
window.addEventListener('load', function() {
|
||||
// Add anchor tag for Show/Hide link
|
||||
const collapsibleContent = document.querySelectorAll('fieldset.collapse--dgsimple');
|
||||
for (const [i, elem] of collapsibleContent.entries()) {
|
||||
const button = elem.closest('div').querySelector('button');
|
||||
button.id = 'simplecollapser' + i;
|
||||
}
|
||||
// Add toggle to hide/show anchor tag
|
||||
const toggleFuncDotgovSimple = function(e) {
|
||||
const fieldset = this.closest('div').querySelector('.collapse--dgsimple');
|
||||
const spanElement = this.querySelector('span');
|
||||
const useElement = this.querySelector('use');
|
||||
if (fieldset.classList.contains('collapsed')) {
|
||||
// Show
|
||||
spanElement.textContent = 'Hide details';
|
||||
useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_less');
|
||||
fieldset.classList.remove('collapsed');
|
||||
} else {
|
||||
// Hide
|
||||
spanElement.textContent = 'Show details';
|
||||
useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_more');
|
||||
fieldset.classList.add('collapsed');
|
||||
}
|
||||
};
|
||||
document.querySelectorAll('.collapse-toggle--dgsimple').forEach(function(el) {
|
||||
el.addEventListener('click', toggleFuncDotgovSimple);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -394,3 +394,38 @@ function initializeWidgetOnList(list, parentId) {
|
|||
observer.observe(targetElement);
|
||||
}
|
||||
})();
|
||||
|
||||
/** An IIFE for toggling the overflow styles on django-admin__model-description (the show more / show less button) */
|
||||
(function () {
|
||||
function handleShowMoreButton(toggleButton, descriptionDiv){
|
||||
// Check the length of the text content in the description div
|
||||
if (descriptionDiv.textContent.length < 200) {
|
||||
// Hide the toggle button if text content is less than 200 characters
|
||||
// This is a little over 160 characters to give us some wiggle room if we
|
||||
// change the font size marginally.
|
||||
toggleButton.classList.add('display-none');
|
||||
} else {
|
||||
toggleButton.addEventListener('click', function() {
|
||||
toggleShowMoreButton(toggleButton, descriptionDiv, 'dja__model-description--no-overflow')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toggleShowMoreButton(toggleButton, descriptionDiv, showMoreClassName){
|
||||
// Toggle the class on the description div
|
||||
descriptionDiv.classList.toggle(showMoreClassName);
|
||||
|
||||
// Change the button text based on the presence of the class
|
||||
if (descriptionDiv.classList.contains(showMoreClassName)) {
|
||||
toggleButton.textContent = 'Show less';
|
||||
} else {
|
||||
toggleButton.textContent = 'Show more';
|
||||
}
|
||||
}
|
||||
|
||||
let toggleButton = document.getElementById('dja-show-more-model-description');
|
||||
let descriptionDiv = document.querySelector('.dja__model-description');
|
||||
if (toggleButton && descriptionDiv) {
|
||||
handleShowMoreButton(toggleButton, descriptionDiv)
|
||||
}
|
||||
})();
|
||||
|
|
|
@ -112,12 +112,20 @@ html[data-theme="light"] {
|
|||
.change-list .usa-table--borderless thead th,
|
||||
.change-list .usa-table thead td,
|
||||
.change-list .usa-table thead th,
|
||||
.change-form .usa-table,
|
||||
.change-form .usa-table--striped tbody tr:nth-child(odd) td,
|
||||
.change-form .usa-table--borderless thead th,
|
||||
.change-form .usa-table thead td,
|
||||
.change-form .usa-table thead th,
|
||||
body.dashboard,
|
||||
body.change-list,
|
||||
body.change-form,
|
||||
.analytics {
|
||||
color: var(--body-fg);
|
||||
}
|
||||
.usa-table td {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
// Firefox needs this to be specifically set
|
||||
|
@ -127,11 +135,20 @@ html[data-theme="dark"] {
|
|||
.change-list .usa-table--borderless thead th,
|
||||
.change-list .usa-table thead td,
|
||||
.change-list .usa-table thead th,
|
||||
.change-form .usa-table,
|
||||
.change-form .usa-table--striped tbody tr:nth-child(odd) td,
|
||||
.change-form .usa-table--borderless thead th,
|
||||
.change-form .usa-table thead td,
|
||||
.change-form .usa-table thead th,
|
||||
body.dashboard,
|
||||
body.change-list,
|
||||
body.change-form {
|
||||
body.change-form,
|
||||
.analytics {
|
||||
color: var(--body-fg);
|
||||
}
|
||||
.usa-table td {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
#branding h1 a:link, #branding h1 a:visited {
|
||||
|
@ -226,7 +243,7 @@ div#content > h2 {
|
|||
// in the future
|
||||
.object-tools li a,
|
||||
.object-tools p a {
|
||||
font-family: "Source Sans Pro Web", "Helvetica Neue", Helvetica, Roboto, Arial, sans-serif;
|
||||
font-family: family('sans');
|
||||
text-transform: none!important;
|
||||
font-size: 14px!important;
|
||||
}
|
||||
|
@ -276,7 +293,7 @@ div#content > h2 {
|
|||
.messagelist_content-list--unstyled {
|
||||
padding-left: 0;
|
||||
li {
|
||||
font-family: "Source Sans Pro Web", "Helvetica Neue", Helvetica, Roboto, Arial, sans-serif;
|
||||
font-family: family('sans');
|
||||
font-size: 13.92px!important;
|
||||
background: none!important;
|
||||
padding: 0!important;
|
||||
|
@ -378,7 +395,6 @@ details.dja-detail-table {
|
|||
border-top: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
@ -525,11 +541,13 @@ address.dja-address-contact-list {
|
|||
}
|
||||
|
||||
// Collapse button styles for fieldsets
|
||||
.module.collapse--dotgov {
|
||||
.module.collapse--dgfieldset {
|
||||
margin-top: -35px;
|
||||
padding-top: 0;
|
||||
border: none;
|
||||
button {
|
||||
}
|
||||
.collapse-toggle--dgsimple,
|
||||
.module.collapse--dgfieldset button {
|
||||
background: none;
|
||||
text-transform: none;
|
||||
color: var(--link-fg);
|
||||
|
@ -541,16 +559,24 @@ address.dja-address-contact-list {
|
|||
font-feature-settings: "kern";
|
||||
font-kerning: normal;
|
||||
line-height: 13px;
|
||||
font-family: -apple-system, "system-ui", "Segoe UI", system-ui, Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
font-family: family('sans');
|
||||
}
|
||||
&:hover {
|
||||
color: var(--link-fg);
|
||||
svg {
|
||||
color: var(--link-fg);
|
||||
}
|
||||
}
|
||||
}
|
||||
.collapse--dotgov.collapsed .collapse-toggle--dotgov {
|
||||
.collapse--dgfieldset.collapsed .collapse-toggle--dgfieldset {
|
||||
display: inline-block!important;
|
||||
* {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
.collapse--dgsimple.collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dja-status-list {
|
||||
border-top: solid 1px var(--border-color);
|
||||
|
@ -559,7 +585,7 @@ address.dja-address-contact-list {
|
|||
padding-top: 10px;
|
||||
li {
|
||||
line-height: 1.5;
|
||||
font-family: "Source Sans Pro Web", "Helvetica Neue", Helvetica, Roboto, Arial, sans-serif !important;
|
||||
font-family: family('sans');
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
@ -616,13 +642,16 @@ address.dja-address-contact-list {
|
|||
display: inline-flex;
|
||||
padding-top: 4px;
|
||||
line-height: 14px;
|
||||
color: var(--link-fg);
|
||||
width: max-content;
|
||||
font-size: unset;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
button.usa-button__clipboard {
|
||||
color: var(--link-fg);
|
||||
}
|
||||
|
||||
.no-outline-on-click:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
|
@ -659,3 +688,35 @@ form .aligned p.help, form .aligned div.help {
|
|||
background: var(--primary);
|
||||
color: var(--header-link-color);
|
||||
}
|
||||
|
||||
div.dja__model-description{
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
|
||||
p, li {
|
||||
font-size: medium;
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
li {
|
||||
list-style-type: disc;
|
||||
font-family: Source Sans Pro Web,Helvetica Neue,Helvetica,Roboto,Arial,sans-serif;
|
||||
}
|
||||
|
||||
a, a:link, a:visited {
|
||||
font-size: medium;
|
||||
color: #005288 !important;
|
||||
}
|
||||
|
||||
&.dja__model-description--no-overflow {
|
||||
display: block;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.text-underline {
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
|
|
@ -22,7 +22,6 @@ from base64 import b64decode
|
|||
from cfenv import AppEnv # type: ignore
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
from botocore.config import Config
|
||||
|
||||
# # # ###
|
||||
|
@ -148,6 +147,8 @@ INSTALLED_APPS = [
|
|||
"corsheaders",
|
||||
# library for multiple choice filters in django admin
|
||||
"django_admin_multiple_choice_list_filter",
|
||||
# Waffle feature flags
|
||||
"waffle",
|
||||
]
|
||||
|
||||
# Middleware are routines for processing web requests.
|
||||
|
@ -183,6 +184,8 @@ MIDDLEWARE = [
|
|||
"csp.middleware.CSPMiddleware",
|
||||
# django-auditlog: obtain the request User for use in logging
|
||||
"auditlog.middleware.AuditlogMiddleware",
|
||||
# Used for waffle feature flags
|
||||
"waffle.middleware.WaffleMiddleware",
|
||||
]
|
||||
|
||||
# application object used by Django’s built-in servers (e.g. `runserver`)
|
||||
|
@ -319,6 +322,17 @@ EMAIL_TIMEOUT = 30
|
|||
SERVER_EMAIL = "root@get.gov"
|
||||
|
||||
# endregion
|
||||
|
||||
# region: Waffle feature flags-----------------------------------------------------------###
|
||||
# If Waffle encounters a reference to a flag that is not in the database, should Waffle create the flag?
|
||||
WAFFLE_CREATE_MISSING_FLAGS = True
|
||||
|
||||
# The model that will be used to keep track of flags. Extends AbstractUserFlag.
|
||||
# Used to replace the default flag class (for customization purposes).
|
||||
WAFFLE_FLAG_MODEL = "registrar.WaffleFlag"
|
||||
|
||||
# endregion
|
||||
|
||||
# region: Headers-----------------------------------------------------------###
|
||||
|
||||
# Content-Security-Policy configuration
|
||||
|
|
|
@ -196,12 +196,12 @@ class UserFixture:
|
|||
},
|
||||
]
|
||||
|
||||
def load_users(cls, users, group_name):
|
||||
def load_users(cls, users, group_name, are_superusers=False):
|
||||
logger.info(f"Going to load {len(users)} users in group {group_name}")
|
||||
for user_data in users:
|
||||
try:
|
||||
user, _ = User.objects.get_or_create(username=user_data["username"])
|
||||
user.is_superuser = False
|
||||
user.is_superuser = are_superusers
|
||||
user.first_name = user_data["first_name"]
|
||||
user.last_name = user_data["last_name"]
|
||||
if "email" in user_data:
|
||||
|
@ -229,5 +229,5 @@ class UserFixture:
|
|||
# steps now do not need to close/reopen a db connection,
|
||||
# instead they share one.
|
||||
with transaction.atomic():
|
||||
cls.load_users(cls, cls.ADMINS, "full_access_group")
|
||||
cls.load_users(cls, cls.ADMINS, "full_access_group", are_superusers=True)
|
||||
cls.load_users(cls, cls.STAFF, "cisa_analysts_group")
|
||||
|
|
127
src/registrar/migrations/0090_waffleflag.py
Normal file
127
src/registrar/migrations/0090_waffleflag.py
Normal file
|
@ -0,0 +1,127 @@
|
|||
# Generated by Django 4.2.10 on 2024-05-02 17:47
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("auth", "0012_alter_user_first_name_max_length"),
|
||||
("registrar", "0089_user_verification_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="WaffleFlag",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
(
|
||||
"name",
|
||||
models.CharField(
|
||||
help_text="The human/computer readable name.", max_length=100, unique=True, verbose_name="Name"
|
||||
),
|
||||
),
|
||||
(
|
||||
"everyone",
|
||||
models.BooleanField(
|
||||
blank=True,
|
||||
help_text="Flip this flag on (Yes) or off (No) for everyone, overriding all other settings. Leave as Unknown to use normally.",
|
||||
null=True,
|
||||
verbose_name="Everyone",
|
||||
),
|
||||
),
|
||||
(
|
||||
"percent",
|
||||
models.DecimalField(
|
||||
blank=True,
|
||||
decimal_places=1,
|
||||
help_text="A number between 0.0 and 99.9 to indicate a percentage of users for whom this flag will be active.",
|
||||
max_digits=3,
|
||||
null=True,
|
||||
verbose_name="Percent",
|
||||
),
|
||||
),
|
||||
(
|
||||
"testing",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Allow this flag to be set for a session for user testing",
|
||||
verbose_name="Testing",
|
||||
),
|
||||
),
|
||||
(
|
||||
"superusers",
|
||||
models.BooleanField(
|
||||
default=True, help_text="Flag always active for superusers?", verbose_name="Superusers"
|
||||
),
|
||||
),
|
||||
(
|
||||
"staff",
|
||||
models.BooleanField(default=False, help_text="Flag always active for staff?", verbose_name="Staff"),
|
||||
),
|
||||
(
|
||||
"authenticated",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Flag always active for authenticated users?",
|
||||
verbose_name="Authenticated",
|
||||
),
|
||||
),
|
||||
(
|
||||
"languages",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="Activate this flag for users with one of these languages (comma-separated list)",
|
||||
verbose_name="Languages",
|
||||
),
|
||||
),
|
||||
(
|
||||
"rollout",
|
||||
models.BooleanField(default=False, help_text="Activate roll-out mode?", verbose_name="Rollout"),
|
||||
),
|
||||
("note", models.TextField(blank=True, help_text="Note where this Flag is used.", verbose_name="Note")),
|
||||
(
|
||||
"created",
|
||||
models.DateTimeField(
|
||||
db_index=True,
|
||||
default=django.utils.timezone.now,
|
||||
help_text="Date when this Flag was created.",
|
||||
verbose_name="Created",
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
models.DateTimeField(
|
||||
default=django.utils.timezone.now,
|
||||
help_text="Date when this Flag was last modified.",
|
||||
verbose_name="Modified",
|
||||
),
|
||||
),
|
||||
(
|
||||
"groups",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="Activate this flag for these user groups.",
|
||||
to="auth.group",
|
||||
verbose_name="Groups",
|
||||
),
|
||||
),
|
||||
(
|
||||
"users",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="Activate this flag for these users.",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Users",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "waffle flag",
|
||||
"verbose_name_plural": "Waffle flags",
|
||||
},
|
||||
),
|
||||
]
|
|
@ -15,6 +15,8 @@ from .user_group import UserGroup
|
|||
from .website import Website
|
||||
from .transition_domain import TransitionDomain
|
||||
from .verified_by_staff import VerifiedByStaff
|
||||
from .waffle_flag import WaffleFlag
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Contact",
|
||||
|
@ -33,6 +35,7 @@ __all__ = [
|
|||
"Website",
|
||||
"TransitionDomain",
|
||||
"VerifiedByStaff",
|
||||
"WaffleFlag",
|
||||
]
|
||||
|
||||
auditlog.register(Contact)
|
||||
|
@ -51,3 +54,4 @@ auditlog.register(UserGroup, m2m_fields=["permissions"])
|
|||
auditlog.register(Website)
|
||||
auditlog.register(TransitionDomain)
|
||||
auditlog.register(VerifiedByStaff)
|
||||
auditlog.register(WaffleFlag)
|
||||
|
|
|
@ -101,10 +101,22 @@ class Contact(TimeStampedModel):
|
|||
# Call the parent class's save method to perform the actual save
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Update the related User object's first_name and last_name
|
||||
if self.user and (not self.user.first_name or not self.user.last_name):
|
||||
if self.user:
|
||||
updated = False
|
||||
|
||||
# Update first name and last name if necessary
|
||||
if not self.user.first_name or not self.user.last_name:
|
||||
self.user.first_name = self.first_name
|
||||
self.user.last_name = self.last_name
|
||||
updated = True
|
||||
|
||||
# Update phone if necessary
|
||||
if not self.user.phone:
|
||||
self.user.phone = self.phone
|
||||
updated = True
|
||||
|
||||
# Save user if any updates were made
|
||||
if updated:
|
||||
self.user.save()
|
||||
|
||||
def __str__(self):
|
||||
|
|
|
@ -16,12 +16,21 @@ from .utility.time_stamped_model import TimeStampedModel
|
|||
from ..utility.email import send_templated_email, EmailSendingError
|
||||
from itertools import chain
|
||||
|
||||
from auditlog.models import AuditlogHistoryField # type: ignore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DomainRequest(TimeStampedModel):
|
||||
"""A registrant's domain request for a new domain."""
|
||||
|
||||
# https://django-auditlog.readthedocs.io/en/latest/usage.html#object-history
|
||||
# If we note any performace degradation due to this addition,
|
||||
# we can query the auditlogs table in admin.py and add the results to
|
||||
# extra_context in the change_view method for DomainRequestAdmin.
|
||||
# This is the more straightforward way so trying it first.
|
||||
history = AuditlogHistoryField()
|
||||
|
||||
# Constants for choice fields
|
||||
class DomainRequestStatus(models.TextChoices):
|
||||
STARTED = "started", "Started"
|
||||
|
|
19
src/registrar/models/waffle_flag.py
Normal file
19
src/registrar/models/waffle_flag.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
from waffle.models import AbstractUserFlag
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WaffleFlag(AbstractUserFlag):
|
||||
"""
|
||||
Custom implementation of django-waffles 'Flag' object.
|
||||
Read more here: https://waffle.readthedocs.io/en/stable/types/flag.html
|
||||
|
||||
Use this class when dealing with feature flags, such as profile_feature.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Contains meta information about this class"""
|
||||
|
||||
verbose_name = "waffle flag"
|
||||
verbose_name_plural = "Waffle flags"
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
{% block content_title %}
|
||||
<h1>{{ title }}</h1>
|
||||
|
||||
{# Adds a model description #}
|
||||
{% include "admin/model_descriptions.html" %}
|
||||
|
||||
<h2>
|
||||
{{ cl.result_count }}
|
||||
{% if cl.get_ordering_field_columns %}
|
||||
|
|
|
@ -8,7 +8,7 @@ https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/
|
|||
<fieldset class="module aligned {{ fieldset.classes }}">
|
||||
{% if fieldset.name %}
|
||||
{# Customize the markup for the collapse toggle #}
|
||||
{% if 'collapse--dotgov' in fieldset.classes %}
|
||||
{% if 'collapse--dgfieldset' in fieldset.classes %}
|
||||
<button type="button">
|
||||
<span>{{ fieldset.name }}</span>
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
|
@ -22,7 +22,7 @@ https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/
|
|||
{% endif %}
|
||||
|
||||
{# Customize the markup for the collapse toggle: Do not show a description for the collapse fieldsets, instead we're using the description as a screen reader only legend #}
|
||||
{% if fieldset.description and 'collapse--dotgov' not in fieldset.classes %}
|
||||
{% if fieldset.description and 'collapse--dgfieldset' not in fieldset.classes %}
|
||||
<div class="description">{{ fieldset.description|safe }}</div>
|
||||
{% endif %}
|
||||
|
||||
|
|
37
src/registrar/templates/admin/model_descriptions.html
Normal file
37
src/registrar/templates/admin/model_descriptions.html
Normal file
|
@ -0,0 +1,37 @@
|
|||
|
||||
<div class="dja__model-description text-normal">
|
||||
{% if opts.model_name == 'domainrequest' %}
|
||||
{% include "django/admin/includes/descriptions/domain_request_description.html" %}
|
||||
{% elif opts.model_name == 'domaininformation' %}
|
||||
{% include "django/admin/includes/descriptions/domain_information_description.html" %}
|
||||
{% elif opts.model_name == 'domaininvitation' %}
|
||||
{% include "django/admin/includes/descriptions/domain_invitation_description.html" %}
|
||||
{% elif opts.model_name == 'contact' %}
|
||||
{% include "django/admin/includes/descriptions/contact_description.html" %}
|
||||
{% elif opts.model_name == 'logentry' %}
|
||||
{% include "django/admin/includes/descriptions/logentry_description.html" %}
|
||||
{% elif opts.model_name == 'domain'%}
|
||||
{% include "django/admin/includes/descriptions/domain_description.html" %}
|
||||
{% elif opts.model_name == 'draftdomain' %}
|
||||
{% include "django/admin/includes/descriptions/draft_domain_description.html" %}
|
||||
{% elif opts.model_name == 'host' %}
|
||||
{% include "django/admin/includes/descriptions/host_description.html" %}
|
||||
{% elif opts.model_name == 'publiccontact' %}
|
||||
{% include "django/admin/includes/descriptions/public_contact_description.html" %}
|
||||
{% elif opts.model_name == 'transitiondomain' %}
|
||||
{% include "django/admin/includes/descriptions/transition_domain_description.html" %}
|
||||
{% elif opts.model_name == 'userdomainrole' %}
|
||||
{% include "django/admin/includes/descriptions/user_domain_role_description.html" %}
|
||||
{% elif opts.model_name == 'usergroup' %}
|
||||
{% include "django/admin/includes/descriptions/user_group_description.html" %}
|
||||
{% elif opts.model_name == 'user' %}
|
||||
{% include "django/admin/includes/descriptions/user_description.html" %}
|
||||
{% elif opts.model_name == 'verifiedbystaff' %}
|
||||
{% include "django/admin/includes/descriptions/verified_by_staff_description.html" %}
|
||||
{% elif opts.model_name == 'website' %}
|
||||
{% include "django/admin/includes/descriptions/website_description.html" %}
|
||||
{% else %}
|
||||
<p>This table does not have a description yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<button id="dja-show-more-model-description" class="usa-button usa-button--unstyled text-no-underline margin-top-1" type="button">Show more</button>
|
|
@ -0,0 +1,10 @@
|
|||
<p>
|
||||
Contacts include anyone who has access to the registrar (known as “users”) and anyone listed in a domain request,
|
||||
including other employees and authorizing officials.
|
||||
Only contacts who have access to the registrar will have
|
||||
a corresponding record within the <a class="text-underline" href="{% url 'admin:registrar_user_changelist' %}">Users</a> table.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Updating someone’s contact information here will not affect that person’s Login.gov information.
|
||||
</p>
|
|
@ -0,0 +1,25 @@
|
|||
<p>
|
||||
This table contains all approved domains in the .gov registrar.
|
||||
In other words, with the exception of domains in the “Unknown”, ”On hold”, and “Deleted” states,
|
||||
this table matches the list of domains in the .gov zone file.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Individual domain pages allow you to view registry-related details and edit the associated domain information.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Other actions available on the domain page include the ability to:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Extend a domain’s expiration date</li>
|
||||
<li>Place a domain “On hold”</li>
|
||||
<li>Remove a domain from the registry</li>
|
||||
<li>View the domain from a domain manager’s perspective
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
To view a domain from a domain manager’s perspective, click the "Manage domain" button.
|
||||
That will allow you to make changes (e.g., update DNS settings, invite people to manage the domain)
|
||||
directly inside the registrar.
|
||||
</p>
|
|
@ -0,0 +1,19 @@
|
|||
<p>
|
||||
Domain information represents the basic metadata for an approved domain and the organization that manages it.
|
||||
It does not include any information specific to the registry (DNS name servers, security email).
|
||||
Registry-related information
|
||||
can be managed within the <a class="text-underline" href="{% url 'admin:registrar_domain_changelist' %}">Domains</a> table.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Updating values here will immediately update the corresponding values that users see in the registrar.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Domain information is similar to <a class="text-underline" href="{% url 'admin:registrar_domainrequest_changelist' %}">Domain requests</a>,
|
||||
and the fields are nearly identical,
|
||||
but edits made to one are not made to the other.
|
||||
Domain information exists so we don’t modify details of an approved request after adjudication
|
||||
(since a domain request should be maintained as-adjudicated for records retention purposes).
|
||||
Entries are created here upon approval of a domain request.
|
||||
</p>
|
|
@ -0,0 +1,16 @@
|
|||
<p>
|
||||
Domain invitations contain all individuals who have been invited to manage a .gov domain.
|
||||
Invitations are sent via email, and the recipient must log in to the registrar to officially
|
||||
accept and become a domain manager.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
An “invited” status indicates that the recipient has not logged in to the registrar since the invitation was sent.
|
||||
A “received” status indicates that the recipient has logged in.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
If an invitation is created in this table, an email will not be sent.
|
||||
To have an email sent, go to the domain in <a class="text-underline" href="{% url 'admin:registrar_domain_changelist' %}">Domains</a>,
|
||||
click the “Manage domain” button, and add a domain manager.
|
||||
</p>
|
|
@ -0,0 +1,11 @@
|
|||
<p>
|
||||
This table contains all domain requests that have been started within the registrar and the status of those requests.
|
||||
Updating values here will immediately update the corresponding values that users see in the registrar.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Once a domain request has been adjudicated, the details of that request should not be modified.
|
||||
To update attributes (like an organization’s name) after a domain’s approval,
|
||||
go to <a class="text-underline" href="{% url 'admin:registrar_domain_changelist' %}">Domains</a>.
|
||||
Similar fields display on each Domain page, but edits made there will not affect the corresponding domain request.
|
||||
</p>
|
|
@ -0,0 +1,10 @@
|
|||
<p>
|
||||
This table represents all “requested domains” that have been saved within a domain request form.
|
||||
If a registrant changes the requested domain in their form,
|
||||
the original name they listed and the new name will appear as separate records.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
This table does not include “alternative domains,”
|
||||
which are housed in the <a class="text-underline" href="{% url 'admin:registrar_website_changelist' %}">Websites</a> table.
|
||||
</p>
|
|
@ -0,0 +1,11 @@
|
|||
<p>
|
||||
Entries in the Hosts table indicate the relationship between an approved domain and a name server address
|
||||
(and, if applicable, the IP address for a name server address).
|
||||
</p>
|
||||
|
||||
<p>
|
||||
In general, you should not modify these values here. They should be updated directly inside the registrar.
|
||||
To update a domain’s name servers and/or IP addresses,
|
||||
in the registrar, go to the domain in <a class="text-underline" href="{% url 'admin:registrar_domain_changelist' %}">Domains</a>,
|
||||
then click the "Manage domain" button.
|
||||
</p>
|
|
@ -0,0 +1,7 @@
|
|||
<p>
|
||||
Log entries represent instances where something was created, updated, or deleted within the registrar or /admin.
|
||||
The table on this page is useful for searching actions across all records.
|
||||
To understand what happened to an individual record (like a domain request),
|
||||
it’s better to go to that specific page
|
||||
(<a class="text-underline" href="{% url 'admin:registrar_domainrequest_changelist' %}">Domain requests</a>) and click on the “History” button.
|
||||
</p>
|
|
@ -0,0 +1,18 @@
|
|||
<p>
|
||||
Public contacts represent the three registry contact types (administrative, technical, and security)
|
||||
and their fields that are exposed in WHOIS data.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
We don’t currently allow registrants to publish real contact information to WHOIS,
|
||||
but we must publish something to WHOIS. For each of the contact types, we use default values that are
|
||||
associated with the program instead of the real contact information,
|
||||
which we then redact in whole at the registry/WHOIS.
|
||||
We do allow registrants to set a security contact email address,
|
||||
which is published to WHOIS when a user sets one.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The public contacts in this table are a reflection of the data in the registry and should not be updated.
|
||||
This information is primarily used by developers for validation purposes.
|
||||
</p>
|
|
@ -0,0 +1,4 @@
|
|||
<p>
|
||||
This table represents the domains that were transitioned from the old registry in November 2023.
|
||||
This data has been preserved for historical reference and should not be updated.
|
||||
</p>
|
|
@ -0,0 +1,16 @@
|
|||
<p>
|
||||
A user is anyone who has access to the registrar. This includes request creators, domain managers, and CISA administrators.
|
||||
Within each user record, you can review that user’s permissions and user status.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
If a user has a domain request in ineligible status, then their user status will be “restricted.”
|
||||
Users who are in restricted status cannot create/edit domain requests or approved domains,
|
||||
and their existing requests are locked. They will see a 403 error if they try to take action in the registrar.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Each user record displays the associated “contact” info for that user,
|
||||
which is the same info found in the <a class="text-underline" href="{% url 'admin:registrar_contact_changelist' %}">Contacts</a> table.
|
||||
Updating these details on the user record will also update the corresponding contact record for that user.
|
||||
</p>
|
|
@ -0,0 +1,10 @@
|
|||
<p>
|
||||
This table represents the managers who are assigned to each domain in the registrar.
|
||||
There are separate records for each domain/manager combination.
|
||||
Managers can update information related to a domain, such as DNS data and security contact.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The creator of an approved domain request automatically becomes a manager for that domain.
|
||||
Anyone who retrieves a domain invitation is also assigned the manager role.
|
||||
</p>
|
|
@ -0,0 +1,10 @@
|
|||
<p>
|
||||
Groups are a way to bundle admin permissions so they can be easily assigned to multiple users.
|
||||
Once a group is created, it can be assigned to people via the <a class="text-underline" href="{% url 'admin:registrar_user_changelist' %}">Users</a> table.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
To maintain a single source of truth across all environments (stable, staging, etc.),
|
||||
the groups and permissions are set and updated through the codebase.
|
||||
<strong>Do not add, edit or delete user groups here.</strong>
|
||||
</p>
|
|
@ -0,0 +1,10 @@
|
|||
<p>
|
||||
This table contains users who have been allowed to bypass identity proofing through Login.gov after approval by a CISA representative.
|
||||
Bypassing identity proofing means they will not be asked to provide a form of ID, PII, and so on.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Additions to this table should be rare, and only after obtaining confirmation of their identity directly (or from a trusted person).
|
||||
Once a verified-by-staff user has been added as a domain manager, they can be removed from this list,
|
||||
(However, if they are removed as a domain manager for all domains and they attempt to sign in again, they will be identity proofed by Login.gov).
|
||||
</p>
|
|
@ -0,0 +1,8 @@
|
|||
<p>
|
||||
This table lists all the “current websites” and “alternative domains” that users have submitted in domain requests since January 2024.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
This does not include any “requested domains” that have appeared within the <a class="text-underline" href="{% url 'admin:registrar_domainrequest_changelist' %}">Domain requests</a> table.
|
||||
Those names are managed in the <a class="text-underline" href="{% url 'admin:registrar_draftdomain_changelist' %}">Draft domains<a/> table.
|
||||
</p>
|
|
@ -1,4 +1,5 @@
|
|||
{% extends "admin/fieldset.html" %}
|
||||
{% load custom_filters %}
|
||||
{% load static url_helpers %}
|
||||
|
||||
{% comment %}
|
||||
|
@ -44,7 +45,7 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
<div class="readonly">
|
||||
{% with total_websites=field.contents|split:", " %}
|
||||
{% for website in total_websites %}
|
||||
<a href="{{ website }}" class="padding-top-1 current-website__{{forloop.counter}}">{{ website }}</a>{% if not forloop.last %}, {% endif %}
|
||||
<a href="{{ website }}" target="_blank" class="padding-top-1 current-website__{{forloop.counter}}">{{ website }}</a>{% if not forloop.last %}, {% endif %}
|
||||
{# Acts as a <br> #}
|
||||
{% if total_websites|length < 5 %}
|
||||
<div class="display-block margin-top-1"></div>
|
||||
|
@ -67,7 +68,43 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
{% endblock field_readonly %}
|
||||
|
||||
{% block after_help_text %}
|
||||
{% if field.field.name == "creator" %}
|
||||
{% if field.field.name == "status" and original_object.history.count > 0 %}
|
||||
<div class="flex-container">
|
||||
<label aria-label="Submitter contact details"></label>
|
||||
<div>
|
||||
<div class="usa-table-container--scrollable collapse--dgsimple" tabindex="0">
|
||||
<table class="usa-table usa-table--borderless">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>User</th>
|
||||
<th>Changed at</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log_entry in original_object.history.all %}
|
||||
{% for key, value in log_entry.changes_display_dict.items %}
|
||||
{% if key == "status" %}
|
||||
<tr>
|
||||
<td>{{ value.1|default:"None" }}</td>
|
||||
<td>{{ log_entry.actor|default:"None" }}</td>
|
||||
<td>{{ log_entry.timestamp|default:"None" }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button type="button" class="collapse-toggle--dgsimple usa-button usa-button--unstyled margin-top-2 margin-bottom-1 margin-left-1">
|
||||
<span>Hide details</span>
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="/public/img/sprite.svg#expand_less"></use>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% elif field.field.name == "creator" %}
|
||||
<div class="flex-container tablet:margin-top-2">
|
||||
<label aria-label="Creator contact details"></label>
|
||||
{% include "django/admin/includes/contact_detail_list.html" with user=original_object.creator no_title_top_padding=field.is_readonly user_verification_type=original_object.creator.get_verification_type_display%}
|
||||
|
@ -126,5 +163,16 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
</details>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% elif field.field.name == "state_territory" %}
|
||||
<div class="flex-container margin-top-2">
|
||||
<span>
|
||||
CISA region:
|
||||
{% if original_object.generic_org_type and original_object.generic_org_type != original_object.OrganizationChoices.FEDERAL %}
|
||||
{{ original_object.state_territory|get_region }}
|
||||
{% else %}
|
||||
N/A
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock after_help_text %}
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
<div class="usa-site-alert--emergency margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert"
|
||||
>
|
||||
<div class="usa-site-alert--emergency margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
|
||||
<div class="usa-alert">
|
||||
<div class="usa-alert__body {% if add_body_class %}{{ add_body_class }}{% endif %}">
|
||||
<b>Attention:</b> You are on a test site.
|
||||
{% if has_profile_feature_flag %}
|
||||
The profile_feature flag is active.
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -67,3 +67,69 @@ def get_organization_long_name(generic_org_type):
|
|||
@register.filter(name="has_permission")
|
||||
def has_permission(user, permission):
|
||||
return user.has_perm(permission)
|
||||
|
||||
|
||||
@register.filter
|
||||
def get_region(state):
|
||||
if state and isinstance(state, str):
|
||||
regions = {
|
||||
"CT": 1,
|
||||
"ME": 1,
|
||||
"MA": 1,
|
||||
"NH": 1,
|
||||
"RI": 1,
|
||||
"VT": 1,
|
||||
"NJ": 2,
|
||||
"NY": 2,
|
||||
"PR": 2,
|
||||
"VI": 2,
|
||||
"DE": 3,
|
||||
"DC": 3,
|
||||
"MD": 3,
|
||||
"PA": 3,
|
||||
"VA": 3,
|
||||
"WV": 3,
|
||||
"AL": 4,
|
||||
"FL": 4,
|
||||
"GA": 4,
|
||||
"KY": 4,
|
||||
"MS": 4,
|
||||
"NC": 4,
|
||||
"SC": 4,
|
||||
"TN": 4,
|
||||
"IL": 5,
|
||||
"IN": 5,
|
||||
"MI": 5,
|
||||
"MN": 5,
|
||||
"OH": 5,
|
||||
"WI": 5,
|
||||
"AR": 6,
|
||||
"LA": 6,
|
||||
"NM": 6,
|
||||
"OK": 6,
|
||||
"TX": 6,
|
||||
"IA": 7,
|
||||
"KS": 7,
|
||||
"MO": 7,
|
||||
"NE": 7,
|
||||
"CO": 8,
|
||||
"MT": 8,
|
||||
"ND": 8,
|
||||
"SD": 8,
|
||||
"UT": 8,
|
||||
"WY": 8,
|
||||
"AZ": 9,
|
||||
"CA": 9,
|
||||
"HI": 9,
|
||||
"NV": 9,
|
||||
"GU": 9,
|
||||
"AS": 9,
|
||||
"MP": 9,
|
||||
"AK": 10,
|
||||
"ID": 10,
|
||||
"OR": 10,
|
||||
"WA": 10,
|
||||
}
|
||||
return regions.get(state.upper(), "N/A")
|
||||
else:
|
||||
return None
|
||||
|
|
|
@ -841,7 +841,7 @@ def completed_domain_request(
|
|||
)
|
||||
if not investigator:
|
||||
investigator, _ = User.objects.get_or_create(
|
||||
username="incrediblyfakeinvestigator",
|
||||
username="incrediblyfakeinvestigator" + str(uuid.uuid4())[:8],
|
||||
first_name="Joe",
|
||||
last_name="Bob",
|
||||
is_staff=True,
|
||||
|
|
|
@ -21,6 +21,12 @@ from registrar.admin import (
|
|||
MyHostAdmin,
|
||||
UserDomainRoleAdmin,
|
||||
VerifiedByStaffAdmin,
|
||||
WebsiteAdmin,
|
||||
DraftDomainAdmin,
|
||||
FederalAgencyAdmin,
|
||||
PublicContactAdmin,
|
||||
TransitionDomainAdmin,
|
||||
UserGroupAdmin,
|
||||
)
|
||||
from registrar.models import (
|
||||
Domain,
|
||||
|
@ -33,6 +39,9 @@ from registrar.models import (
|
|||
PublicContact,
|
||||
Host,
|
||||
Website,
|
||||
FederalAgency,
|
||||
UserGroup,
|
||||
TransitionDomain,
|
||||
)
|
||||
from registrar.models.user_domain_role import UserDomainRole
|
||||
from registrar.models.verified_by_staff import VerifiedByStaff
|
||||
|
@ -95,6 +104,89 @@ class TestDomainAdmin(MockEppLib, WebTest):
|
|||
)
|
||||
super().setUp()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_staff_can_see_cisa_region_federal(self):
|
||||
"""Tests if staff can see CISA Region: N/A"""
|
||||
|
||||
# Create a fake domain request
|
||||
_domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
|
||||
_domain_request.approve()
|
||||
|
||||
domain = _domain_request.approved_domain
|
||||
p = "userpass"
|
||||
self.client.login(username="staffuser", password=p)
|
||||
response = self.client.get(
|
||||
"/admin/registrar/domain/{}/change/".format(domain.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.name)
|
||||
|
||||
# Test if the page has the right CISA region
|
||||
expected_html = '<div class="flex-container margin-top-2"><span>CISA region: N/A</span></div>'
|
||||
# Remove whitespace from expected_html
|
||||
expected_html = "".join(expected_html.split())
|
||||
|
||||
# Remove whitespace from response content
|
||||
response_content = "".join(response.content.decode().split())
|
||||
|
||||
# Check if response contains expected_html
|
||||
self.assertIn(expected_html, response_content)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_staff_can_see_cisa_region_non_federal(self):
|
||||
"""Tests if staff can see the correct CISA region"""
|
||||
|
||||
# Create a fake domain request. State will be NY (2).
|
||||
_domain_request = completed_domain_request(
|
||||
status=DomainRequest.DomainRequestStatus.IN_REVIEW, generic_org_type="interstate"
|
||||
)
|
||||
|
||||
_domain_request.approve()
|
||||
|
||||
domain = _domain_request.approved_domain
|
||||
|
||||
p = "userpass"
|
||||
self.client.login(username="staffuser", password=p)
|
||||
response = self.client.get(
|
||||
"/admin/registrar/domain/{}/change/".format(domain.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.name)
|
||||
|
||||
# Test if the page has the right CISA region
|
||||
expected_html = '<div class="flex-container margin-top-2"><span>CISA region: 2</span></div>'
|
||||
# Remove whitespace from expected_html
|
||||
expected_html = "".join(expected_html.split())
|
||||
|
||||
# Remove whitespace from response content
|
||||
response_content = "".join(response.content.decode().split())
|
||||
|
||||
# Check if response contains expected_html
|
||||
self.assertIn(expected_html, response_content)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_has_model_description(self):
|
||||
"""Tests if this model has a model description on the table view"""
|
||||
p = "adminpass"
|
||||
self.client.login(username="superuser", password=p)
|
||||
response = self.client.get(
|
||||
"/admin/registrar/domain/",
|
||||
follow=True,
|
||||
)
|
||||
|
||||
# Make sure that the page is loaded correctly
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Test for a description snippet
|
||||
self.assertContains(response, "This table contains all approved domains in the .gov registrar.")
|
||||
self.assertContains(response, "Show more")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_contact_fields_on_domain_change_form_have_detail_table(self):
|
||||
"""Tests if the contact fields in the inlined Domain information have the detail table
|
||||
|
@ -511,7 +603,7 @@ class TestDomainAdmin(MockEppLib, WebTest):
|
|||
|
||||
# There are 4 template references to Federal (4) plus four references in the table
|
||||
# for our actual domain_request
|
||||
self.assertContains(response, "Federal", count=36)
|
||||
self.assertContains(response, "Federal", count=42)
|
||||
# This may be a bit more robust
|
||||
self.assertContains(response, '<td class="field-generic_org_type">Federal</td>', count=1)
|
||||
# Now let's make sure the long description does not exist
|
||||
|
@ -852,6 +944,23 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
)
|
||||
self.mock_client = MockSESClient()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_has_model_description(self):
|
||||
"""Tests if this model has a model description on the table view"""
|
||||
p = "adminpass"
|
||||
self.client.login(username="superuser", password=p)
|
||||
response = self.client.get(
|
||||
"/admin/registrar/domainrequest/",
|
||||
follow=True,
|
||||
)
|
||||
|
||||
# Make sure that the page is loaded correctly
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Test for a description snippet
|
||||
self.assertContains(response, "This table contains all domain requests")
|
||||
self.assertContains(response, "Show more")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_helper_text(self):
|
||||
"""
|
||||
|
@ -888,6 +997,96 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
self.test_helper.assert_response_contains_distinct_values(response, expected_values)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_status_logs(self):
|
||||
"""
|
||||
Tests that the status changes are shown in a table on the domain request change form,
|
||||
accurately and in chronological order.
|
||||
"""
|
||||
|
||||
# Create a fake domain request and domain
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.STARTED)
|
||||
|
||||
p = "adminpass"
|
||||
self.client.login(username="superuser", password=p)
|
||||
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)
|
||||
|
||||
# Table will contain one row for Started
|
||||
self.assertContains(response, "<td>Started</td>", count=1)
|
||||
self.assertNotContains(response, "<td>Submitted</td>")
|
||||
|
||||
domain_request.submit()
|
||||
domain_request.save()
|
||||
|
||||
response = self.client.get(
|
||||
"/admin/registrar/domainrequest/{}/change/".format(domain_request.pk),
|
||||
follow=True,
|
||||
)
|
||||
|
||||
# Table will contain and extra row for Submitted
|
||||
self.assertContains(response, "<td>Started</td>", count=1)
|
||||
self.assertContains(response, "<td>Submitted</td>", count=1)
|
||||
|
||||
domain_request.in_review()
|
||||
domain_request.save()
|
||||
|
||||
response = self.client.get(
|
||||
"/admin/registrar/domainrequest/{}/change/".format(domain_request.pk),
|
||||
follow=True,
|
||||
)
|
||||
|
||||
# Table will contain and extra row for In review
|
||||
self.assertContains(response, "<td>Started</td>", count=1)
|
||||
self.assertContains(response, "<td>Submitted</td>", count=1)
|
||||
self.assertContains(response, "<td>In review</td>", count=1)
|
||||
|
||||
domain_request.action_needed()
|
||||
domain_request.save()
|
||||
|
||||
response = self.client.get(
|
||||
"/admin/registrar/domainrequest/{}/change/".format(domain_request.pk),
|
||||
follow=True,
|
||||
)
|
||||
|
||||
# Table will contain and extra row for Action needed
|
||||
self.assertContains(response, "<td>Started</td>", count=1)
|
||||
self.assertContains(response, "<td>Submitted</td>", count=1)
|
||||
self.assertContains(response, "<td>In review</td>", count=1)
|
||||
self.assertContains(response, "<td>Action needed</td>", count=1)
|
||||
|
||||
domain_request.in_review()
|
||||
domain_request.save()
|
||||
|
||||
response = self.client.get(
|
||||
"/admin/registrar/domainrequest/{}/change/".format(domain_request.pk),
|
||||
follow=True,
|
||||
)
|
||||
|
||||
# Define the expected sequence of status changes
|
||||
expected_status_changes = [
|
||||
"<td>In review</td>",
|
||||
"<td>Action needed</td>",
|
||||
"<td>In review</td>",
|
||||
"<td>Submitted</td>",
|
||||
"<td>Started</td>",
|
||||
]
|
||||
|
||||
# Test for the order of status changes
|
||||
for status_change in expected_status_changes:
|
||||
self.assertContains(response, status_change, html=True)
|
||||
|
||||
# Table now contains 2 rows for Approved
|
||||
self.assertContains(response, "<td>Started</td>", count=1)
|
||||
self.assertContains(response, "<td>Submitted</td>", count=1)
|
||||
self.assertContains(response, "<td>In review</td>", count=2)
|
||||
self.assertContains(response, "<td>Action needed</td>", count=1)
|
||||
|
||||
def test_collaspe_toggle_button_markup(self):
|
||||
"""
|
||||
Tests for the correct collapse toggle button markup
|
||||
|
@ -906,7 +1105,6 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
# 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)
|
||||
|
||||
self.test_helper.assertContains(response, "<span>Show details</span>")
|
||||
|
||||
@less_console_noise_decorator
|
||||
|
@ -1178,7 +1376,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=34)
|
||||
self.assertContains(response, "Federal", count=40)
|
||||
# This may be a bit more robust
|
||||
self.assertContains(response, '<td class="field-generic_org_type">Federal</td>', count=1)
|
||||
# Now let's make sure the long description does not exist
|
||||
|
@ -1797,7 +1995,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
self.assertContains(response, domain_request.requested_domain.name)
|
||||
|
||||
# Check that the page contains the link we expect.
|
||||
expected_url = '<a href="city.com" class="padding-top-1 current-website__1">city.com</a>'
|
||||
expected_url = '<a href="city.com" target="_blank" class="padding-top-1 current-website__1">city.com</a>'
|
||||
self.assertContains(response, expected_url)
|
||||
|
||||
@less_console_noise_decorator
|
||||
|
@ -2390,6 +2588,66 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
|
||||
self.assertEqual(expected_list, actual_list)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_staff_can_see_cisa_region_federal(self):
|
||||
"""Tests if staff can see CISA Region: N/A"""
|
||||
|
||||
# Create a fake domain request
|
||||
_domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
|
||||
|
||||
p = "userpass"
|
||||
self.client.login(username="staffuser", password=p)
|
||||
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)
|
||||
|
||||
# Test if the page has the right CISA region
|
||||
expected_html = '<div class="flex-container margin-top-2"><span>CISA region: N/A</span></div>'
|
||||
# Remove whitespace from expected_html
|
||||
expected_html = "".join(expected_html.split())
|
||||
|
||||
# Remove whitespace from response content
|
||||
response_content = "".join(response.content.decode().split())
|
||||
|
||||
# Check if response contains expected_html
|
||||
self.assertIn(expected_html, response_content)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_staff_can_see_cisa_region_non_federal(self):
|
||||
"""Tests if staff can see the correct CISA region"""
|
||||
|
||||
# Create a fake domain request. State will be NY (2).
|
||||
_domain_request = completed_domain_request(
|
||||
status=DomainRequest.DomainRequestStatus.IN_REVIEW, generic_org_type="interstate"
|
||||
)
|
||||
|
||||
p = "userpass"
|
||||
self.client.login(username="staffuser", password=p)
|
||||
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)
|
||||
|
||||
# Test if the page has the right CISA region
|
||||
expected_html = '<div class="flex-container margin-top-2"><span>CISA region: 2</span></div>'
|
||||
# Remove whitespace from expected_html
|
||||
expected_html = "".join(expected_html.split())
|
||||
|
||||
# Remove whitespace from response content
|
||||
response_content = "".join(response.content.decode().split())
|
||||
|
||||
# Check if response contains expected_html
|
||||
self.assertIn(expected_html, response_content)
|
||||
|
||||
def tearDown(self):
|
||||
super().tearDown()
|
||||
Domain.objects.all().delete()
|
||||
|
@ -2401,7 +2659,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
self.mock_client.EMAILS_SENT.clear()
|
||||
|
||||
|
||||
class DomainInvitationAdminTest(TestCase):
|
||||
class TestDomainInvitationAdmin(TestCase):
|
||||
"""Tests for the DomainInvitation page"""
|
||||
|
||||
def setUp(self):
|
||||
|
@ -2417,6 +2675,25 @@ class DomainInvitationAdminTest(TestCase):
|
|||
User.objects.all().delete()
|
||||
Contact.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_has_model_description(self):
|
||||
"""Tests if this model has a model description on the table view"""
|
||||
p = "adminpass"
|
||||
self.client.login(username="superuser", password=p)
|
||||
response = self.client.get(
|
||||
"/admin/registrar/domaininvitation/",
|
||||
follow=True,
|
||||
)
|
||||
|
||||
# Make sure that the page is loaded correctly
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Test for a description snippet
|
||||
self.assertContains(
|
||||
response, "Domain invitations contain all individuals who have been invited to manage a .gov domain."
|
||||
)
|
||||
self.assertContains(response, "Show more")
|
||||
|
||||
def test_get_filters(self):
|
||||
"""Ensures that our filters are displaying correctly"""
|
||||
with less_console_noise():
|
||||
|
@ -2431,7 +2708,7 @@ class DomainInvitationAdminTest(TestCase):
|
|||
)
|
||||
|
||||
# Assert that the filters are added
|
||||
self.assertContains(response, "invited", count=2)
|
||||
self.assertContains(response, "invited", count=4)
|
||||
self.assertContains(response, "Invited", count=2)
|
||||
self.assertContains(response, "retrieved", count=2)
|
||||
self.assertContains(response, "Retrieved", count=2)
|
||||
|
@ -2466,6 +2743,23 @@ class TestHostAdmin(TestCase):
|
|||
Host.objects.all().delete()
|
||||
Domain.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_has_model_description(self):
|
||||
"""Tests if this model has a model description on the table view"""
|
||||
p = "adminpass"
|
||||
self.client.login(username="superuser", password=p)
|
||||
response = self.client.get(
|
||||
"/admin/registrar/host/",
|
||||
follow=True,
|
||||
)
|
||||
|
||||
# Make sure that the page is loaded correctly
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Test for a description snippet
|
||||
self.assertContains(response, "Entries in the Hosts table indicate the relationship between an approved domain")
|
||||
self.assertContains(response, "Show more")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_helper_text(self):
|
||||
"""
|
||||
|
@ -2544,6 +2838,88 @@ class TestDomainInformationAdmin(TestCase):
|
|||
Contact.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_admin_can_see_cisa_region_federal(self):
|
||||
"""Tests if admins can see CISA Region: N/A"""
|
||||
|
||||
# Create a fake domain request
|
||||
_domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
|
||||
_domain_request.approve()
|
||||
|
||||
domain_information = DomainInformation.objects.filter(domain_request=_domain_request).get()
|
||||
|
||||
p = "adminpass"
|
||||
self.client.login(username="superuser", password=p)
|
||||
response = self.client.get(
|
||||
"/admin/registrar/domaininformation/{}/change/".format(domain_information.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_information.domain.name)
|
||||
|
||||
# Test if the page has the right CISA region
|
||||
expected_html = '<div class="flex-container margin-top-2"><span>CISA region: N/A</span></div>'
|
||||
# Remove whitespace from expected_html
|
||||
expected_html = "".join(expected_html.split())
|
||||
|
||||
# Remove whitespace from response content
|
||||
response_content = "".join(response.content.decode().split())
|
||||
|
||||
# Check if response contains expected_html
|
||||
self.assertIn(expected_html, response_content)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_admin_can_see_cisa_region_non_federal(self):
|
||||
"""Tests if admins can see the correct CISA region"""
|
||||
|
||||
# Create a fake domain request. State will be NY (2).
|
||||
_domain_request = completed_domain_request(
|
||||
status=DomainRequest.DomainRequestStatus.IN_REVIEW, generic_org_type="interstate"
|
||||
)
|
||||
_domain_request.approve()
|
||||
|
||||
domain_information = DomainInformation.objects.filter(domain_request=_domain_request).get()
|
||||
p = "adminpass"
|
||||
self.client.login(username="superuser", password=p)
|
||||
response = self.client.get(
|
||||
"/admin/registrar/domaininformation/{}/change/".format(domain_information.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_information.domain.name)
|
||||
|
||||
# Test if the page has the right CISA region
|
||||
expected_html = '<div class="flex-container margin-top-2"><span>CISA region: 2</span></div>'
|
||||
# Remove whitespace from expected_html
|
||||
expected_html = "".join(expected_html.split())
|
||||
|
||||
# Remove whitespace from response content
|
||||
response_content = "".join(response.content.decode().split())
|
||||
|
||||
# Check if response contains expected_html
|
||||
self.assertIn(expected_html, response_content)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_has_model_description(self):
|
||||
"""Tests if this model has a model description on the table view"""
|
||||
p = "adminpass"
|
||||
self.client.login(username="superuser", password=p)
|
||||
response = self.client.get(
|
||||
"/admin/registrar/domaininformation/",
|
||||
follow=True,
|
||||
)
|
||||
|
||||
# Make sure that the page is loaded correctly
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Test for a description snippet
|
||||
self.assertContains(response, "Domain information represents the basic metadata")
|
||||
self.assertContains(response, "Show more")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_helper_text(self):
|
||||
"""
|
||||
|
@ -2787,7 +3163,7 @@ class TestDomainInformationAdmin(TestCase):
|
|||
self.test_helper.assert_table_sorted("-4", ("-submitter__first_name", "-submitter__last_name"))
|
||||
|
||||
|
||||
class UserDomainRoleAdminTest(TestCase):
|
||||
class TestUserDomainRoleAdmin(TestCase):
|
||||
def setUp(self):
|
||||
"""Setup environment for a mock admin user"""
|
||||
self.site = AdminSite()
|
||||
|
@ -2809,6 +3185,25 @@ class UserDomainRoleAdminTest(TestCase):
|
|||
Domain.objects.all().delete()
|
||||
UserDomainRole.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_has_model_description(self):
|
||||
"""Tests if this model has a model description on the table view"""
|
||||
p = "adminpass"
|
||||
self.client.login(username="superuser", password=p)
|
||||
response = self.client.get(
|
||||
"/admin/registrar/userdomainrole/",
|
||||
follow=True,
|
||||
)
|
||||
|
||||
# Make sure that the page is loaded correctly
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Test for a description snippet
|
||||
self.assertContains(
|
||||
response, "This table represents the managers who are assigned to each domain in the registrar"
|
||||
)
|
||||
self.assertContains(response, "Show more")
|
||||
|
||||
def test_domain_sortable(self):
|
||||
"""Tests if the UserDomainrole sorts by domain correctly"""
|
||||
with less_console_noise():
|
||||
|
@ -3005,6 +3400,23 @@ class TestMyUserAdmin(TestCase):
|
|||
super().tearDown()
|
||||
User.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_has_model_description(self):
|
||||
"""Tests if this model has a model description on the table view"""
|
||||
p = "adminpass"
|
||||
self.client.login(username="superuser", password=p)
|
||||
response = self.client.get(
|
||||
"/admin/registrar/user/",
|
||||
follow=True,
|
||||
)
|
||||
|
||||
# Make sure that the page is loaded correctly
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Test for a description snippet
|
||||
self.assertContains(response, "A user is anyone who has access to the registrar.")
|
||||
self.assertContains(response, "Show more")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_helper_text(self):
|
||||
"""
|
||||
|
@ -3439,7 +3851,7 @@ class DomainSessionVariableTest(TestCase):
|
|||
)
|
||||
|
||||
|
||||
class ContactAdminTest(TestCase):
|
||||
class TestContactAdmin(TestCase):
|
||||
def setUp(self):
|
||||
self.site = AdminSite()
|
||||
self.factory = RequestFactory()
|
||||
|
@ -3448,6 +3860,23 @@ class ContactAdminTest(TestCase):
|
|||
self.superuser = create_superuser()
|
||||
self.staffuser = create_user()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_has_model_description(self):
|
||||
"""Tests if this model has a model description on the table view"""
|
||||
p = "adminpass"
|
||||
self.client.login(username="superuser", password=p)
|
||||
response = self.client.get(
|
||||
"/admin/registrar/contact/",
|
||||
follow=True,
|
||||
)
|
||||
|
||||
# Make sure that the page is loaded correctly
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Test for a description snippet
|
||||
self.assertContains(response, "Contacts include anyone who has access to the registrar (known as “users”)")
|
||||
self.assertContains(response, "Show more")
|
||||
|
||||
def test_readonly_when_restricted_staffuser(self):
|
||||
with less_console_noise():
|
||||
request = self.factory.get("/")
|
||||
|
@ -3566,6 +3995,25 @@ class TestVerifiedByStaffAdmin(TestCase):
|
|||
VerifiedByStaff.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_has_model_description(self):
|
||||
"""Tests if this model has a model description on the table view"""
|
||||
p = "adminpass"
|
||||
self.client.login(username="superuser", password=p)
|
||||
response = self.client.get(
|
||||
"/admin/registrar/verifiedbystaff/",
|
||||
follow=True,
|
||||
)
|
||||
|
||||
# Make sure that the page is loaded correctly
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Test for a description snippet
|
||||
self.assertContains(
|
||||
response, "This table contains users who have been allowed to bypass " "identity proofing through Login.gov"
|
||||
)
|
||||
self.assertContains(response, "Show more")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_helper_text(self):
|
||||
"""
|
||||
|
@ -3608,3 +4056,204 @@ class TestVerifiedByStaffAdmin(TestCase):
|
|||
|
||||
# Check that the user field is set to the request.user
|
||||
self.assertEqual(vip_instance.requestor, self.superuser)
|
||||
|
||||
|
||||
class TestWebsiteAdmin(TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.site = AdminSite()
|
||||
self.superuser = create_superuser()
|
||||
self.admin = WebsiteAdmin(model=Website, admin_site=self.site)
|
||||
self.factory = RequestFactory()
|
||||
self.client = Client(HTTP_HOST="localhost:8080")
|
||||
self.test_helper = GenericTestHelper(admin=self.admin)
|
||||
|
||||
def tearDown(self):
|
||||
super().tearDown()
|
||||
Website.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_has_model_description(self):
|
||||
"""Tests if this model has a model description on the table view"""
|
||||
p = "adminpass"
|
||||
self.client.login(username="superuser", password=p)
|
||||
response = self.client.get(
|
||||
"/admin/registrar/website/",
|
||||
follow=True,
|
||||
)
|
||||
|
||||
# Make sure that the page is loaded correctly
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Test for a description snippet
|
||||
self.assertContains(response, "This table lists all the “current websites” and “alternative domains”")
|
||||
self.assertContains(response, "Show more")
|
||||
|
||||
|
||||
class TestDraftDomain(TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.site = AdminSite()
|
||||
self.superuser = create_superuser()
|
||||
self.admin = DraftDomainAdmin(model=DraftDomain, admin_site=self.site)
|
||||
self.factory = RequestFactory()
|
||||
self.client = Client(HTTP_HOST="localhost:8080")
|
||||
self.test_helper = GenericTestHelper(admin=self.admin)
|
||||
|
||||
def tearDown(self):
|
||||
super().tearDown()
|
||||
DraftDomain.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_has_model_description(self):
|
||||
"""Tests if this model has a model description on the table view"""
|
||||
p = "adminpass"
|
||||
self.client.login(username="superuser", password=p)
|
||||
response = self.client.get(
|
||||
"/admin/registrar/draftdomain/",
|
||||
follow=True,
|
||||
)
|
||||
|
||||
# Make sure that the page is loaded correctly
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Test for a description snippet
|
||||
self.assertContains(
|
||||
response, "This table represents all “requested domains” that have been saved within a domain"
|
||||
)
|
||||
self.assertContains(response, "Show more")
|
||||
|
||||
|
||||
class TestFederalAgency(TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.site = AdminSite()
|
||||
self.superuser = create_superuser()
|
||||
self.admin = FederalAgencyAdmin(model=FederalAgency, admin_site=self.site)
|
||||
self.factory = RequestFactory()
|
||||
self.client = Client(HTTP_HOST="localhost:8080")
|
||||
self.test_helper = GenericTestHelper(admin=self.admin)
|
||||
|
||||
def tearDown(self):
|
||||
super().tearDown()
|
||||
FederalAgency.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_has_model_description(self):
|
||||
"""Tests if this model has a model description on the table view"""
|
||||
p = "adminpass"
|
||||
self.client.login(username="superuser", password=p)
|
||||
response = self.client.get(
|
||||
"/admin/registrar/federalagency/",
|
||||
follow=True,
|
||||
)
|
||||
|
||||
# Make sure that the page is loaded correctly
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Test for a description snippet
|
||||
self.assertContains(response, "This table does not have a description yet.")
|
||||
self.assertContains(response, "Show more")
|
||||
|
||||
|
||||
class TestPublicContact(TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.site = AdminSite()
|
||||
self.superuser = create_superuser()
|
||||
self.admin = PublicContactAdmin(model=PublicContact, admin_site=self.site)
|
||||
self.factory = RequestFactory()
|
||||
self.client = Client(HTTP_HOST="localhost:8080")
|
||||
self.test_helper = GenericTestHelper(admin=self.admin)
|
||||
|
||||
def tearDown(self):
|
||||
super().tearDown()
|
||||
PublicContact.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_has_model_description(self):
|
||||
"""Tests if this model has a model description on the table view"""
|
||||
p = "adminpass"
|
||||
self.client.login(username="superuser", password=p)
|
||||
response = self.client.get(
|
||||
"/admin/registrar/publiccontact/",
|
||||
follow=True,
|
||||
)
|
||||
|
||||
# Make sure that the page is loaded correctly
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Test for a description snippet
|
||||
self.assertContains(response, "Public contacts represent the three registry contact types")
|
||||
self.assertContains(response, "Show more")
|
||||
|
||||
|
||||
class TestTransitionDomain(TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.site = AdminSite()
|
||||
self.superuser = create_superuser()
|
||||
self.admin = TransitionDomainAdmin(model=TransitionDomain, admin_site=self.site)
|
||||
self.factory = RequestFactory()
|
||||
self.client = Client(HTTP_HOST="localhost:8080")
|
||||
self.test_helper = GenericTestHelper(admin=self.admin)
|
||||
|
||||
def tearDown(self):
|
||||
super().tearDown()
|
||||
PublicContact.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_has_model_description(self):
|
||||
"""Tests if this model has a model description on the table view"""
|
||||
p = "adminpass"
|
||||
self.client.login(username="superuser", password=p)
|
||||
response = self.client.get(
|
||||
"/admin/registrar/transitiondomain/",
|
||||
follow=True,
|
||||
)
|
||||
|
||||
# Make sure that the page is loaded correctly
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Test for a description snippet
|
||||
self.assertContains(response, "This table represents the domains that were transitioned from the old registry")
|
||||
self.assertContains(response, "Show more")
|
||||
|
||||
|
||||
class TestUserGroup(TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.site = AdminSite()
|
||||
self.superuser = create_superuser()
|
||||
self.admin = UserGroupAdmin(model=UserGroup, admin_site=self.site)
|
||||
self.factory = RequestFactory()
|
||||
self.client = Client(HTTP_HOST="localhost:8080")
|
||||
self.test_helper = GenericTestHelper(admin=self.admin)
|
||||
|
||||
def tearDown(self):
|
||||
super().tearDown()
|
||||
User.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_has_model_description(self):
|
||||
"""Tests if this model has a model description on the table view"""
|
||||
p = "adminpass"
|
||||
self.client.login(username="superuser", password=p)
|
||||
response = self.client.get(
|
||||
"/admin/registrar/usergroup/",
|
||||
follow=True,
|
||||
)
|
||||
|
||||
# Make sure that the page is loaded correctly
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Test for a description snippet
|
||||
self.assertContains(
|
||||
response, "Groups are a way to bundle admin permissions so they can be easily assigned to multiple users."
|
||||
)
|
||||
self.assertContains(response, "Show more")
|
||||
|
|
|
@ -1152,12 +1152,18 @@ class TestContact(TestCase):
|
|||
def setUp(self):
|
||||
self.email_for_invalid = "intern@igorville.gov"
|
||||
self.invalid_user, _ = User.objects.get_or_create(
|
||||
username=self.email_for_invalid, email=self.email_for_invalid, first_name="", last_name=""
|
||||
username=self.email_for_invalid,
|
||||
email=self.email_for_invalid,
|
||||
first_name="",
|
||||
last_name="",
|
||||
phone="",
|
||||
)
|
||||
self.invalid_contact, _ = Contact.objects.get_or_create(user=self.invalid_user)
|
||||
|
||||
self.email = "mayor@igorville.gov"
|
||||
self.user, _ = User.objects.get_or_create(email=self.email, first_name="Jeff", last_name="Lebowski")
|
||||
self.user, _ = User.objects.get_or_create(
|
||||
email=self.email, first_name="Jeff", last_name="Lebowski", phone="123456789"
|
||||
)
|
||||
self.contact, _ = Contact.objects.get_or_create(user=self.user)
|
||||
|
||||
self.contact_as_ao, _ = Contact.objects.get_or_create(email="newguy@igorville.gov")
|
||||
|
@ -1169,19 +1175,22 @@ class TestContact(TestCase):
|
|||
Contact.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
|
||||
def test_saving_contact_updates_user_first_last_names(self):
|
||||
def test_saving_contact_updates_user_first_last_names_and_phone(self):
|
||||
"""When a contact is updated, we propagate the changes to the linked user if it exists."""
|
||||
|
||||
# User and Contact are created and linked as expected.
|
||||
# An empty User object should create an empty contact.
|
||||
self.assertEqual(self.invalid_contact.first_name, "")
|
||||
self.assertEqual(self.invalid_contact.last_name, "")
|
||||
self.assertEqual(self.invalid_contact.phone, "")
|
||||
self.assertEqual(self.invalid_user.first_name, "")
|
||||
self.assertEqual(self.invalid_user.last_name, "")
|
||||
self.assertEqual(self.invalid_user.phone, "")
|
||||
|
||||
# Manually update the contact - mimicking production (pre-existing data)
|
||||
self.invalid_contact.first_name = "Joey"
|
||||
self.invalid_contact.last_name = "Baloney"
|
||||
self.invalid_contact.phone = "123456789"
|
||||
self.invalid_contact.save()
|
||||
|
||||
# Refresh the user object to reflect the changes made in the database
|
||||
|
@ -1190,20 +1199,25 @@ class TestContact(TestCase):
|
|||
# Updating the contact's first and last names propagate to the user
|
||||
self.assertEqual(self.invalid_contact.first_name, "Joey")
|
||||
self.assertEqual(self.invalid_contact.last_name, "Baloney")
|
||||
self.assertEqual(self.invalid_contact.phone, "123456789")
|
||||
self.assertEqual(self.invalid_user.first_name, "Joey")
|
||||
self.assertEqual(self.invalid_user.last_name, "Baloney")
|
||||
self.assertEqual(self.invalid_user.phone, "123456789")
|
||||
|
||||
def test_saving_contact_does_not_update_user_first_last_names(self):
|
||||
def test_saving_contact_does_not_update_user_first_last_names_and_phone(self):
|
||||
"""When a contact is updated, we avoid propagating the changes to the linked user if it already has a value"""
|
||||
|
||||
# User and Contact are created and linked as expected
|
||||
self.assertEqual(self.contact.first_name, "Jeff")
|
||||
self.assertEqual(self.contact.last_name, "Lebowski")
|
||||
self.assertEqual(self.contact.phone, "123456789")
|
||||
self.assertEqual(self.user.first_name, "Jeff")
|
||||
self.assertEqual(self.user.last_name, "Lebowski")
|
||||
self.assertEqual(self.user.phone, "123456789")
|
||||
|
||||
self.contact.first_name = "Joey"
|
||||
self.contact.last_name = "Baloney"
|
||||
self.contact.phone = "987654321"
|
||||
self.contact.save()
|
||||
|
||||
# Refresh the user object to reflect the changes made in the database
|
||||
|
@ -1212,8 +1226,10 @@ class TestContact(TestCase):
|
|||
# Updating the contact's first and last names propagate to the user
|
||||
self.assertEqual(self.contact.first_name, "Joey")
|
||||
self.assertEqual(self.contact.last_name, "Baloney")
|
||||
self.assertEqual(self.contact.phone, "987654321")
|
||||
self.assertEqual(self.user.first_name, "Jeff")
|
||||
self.assertEqual(self.user.last_name, "Lebowski")
|
||||
self.assertEqual(self.user.phone, "123456789")
|
||||
|
||||
def test_saving_contact_does_not_update_user_email(self):
|
||||
"""When a contact's email is updated, the change is not propagated to the user."""
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
from django.shortcuts import render
|
||||
|
||||
from registrar.models import DomainRequest, Domain, UserDomainRole
|
||||
from waffle.decorators import flag_is_active
|
||||
|
||||
|
||||
def index(request):
|
||||
|
@ -20,6 +21,9 @@ def index(request):
|
|||
has_deletable_domain_requests = deletable_domain_requests.exists()
|
||||
context["has_deletable_domain_requests"] = has_deletable_domain_requests
|
||||
|
||||
# This is a django waffle flag which toggles features based off of the "flag" table
|
||||
context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
|
||||
|
||||
# If they can delete domain requests, add the delete button to the context
|
||||
if has_deletable_domain_requests:
|
||||
# Add the delete modal button to the context
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
-i https://pypi.python.org/simple
|
||||
annotated-types==0.6.0; python_version >= '3.8'
|
||||
asgiref==3.8.1; python_version >= '3.8'
|
||||
boto3==1.34.90; python_version >= '3.8'
|
||||
botocore==1.34.90; python_version >= '3.8'
|
||||
boto3==1.34.95; python_version >= '3.8'
|
||||
botocore==1.34.95; python_version >= '3.8'
|
||||
cachetools==5.3.3; python_version >= '3.7'
|
||||
certifi==2024.2.2; python_version >= '3.6'
|
||||
cfenv==0.5.3
|
||||
|
@ -22,9 +22,10 @@ django-csp==3.8
|
|||
django-fsm==2.8.1
|
||||
django-login-required-middleware==0.9.0
|
||||
django-phonenumber-field[phonenumberslite]==7.3.0; python_version >= '3.8'
|
||||
django-waffle==4.1.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==24.11.0; python_version >= '3.8'
|
||||
faker==25.0.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'
|
||||
|
@ -37,7 +38,7 @@ lxml==5.2.1; python_version >= '3.6'
|
|||
mako==1.3.3; python_version >= '3.8'
|
||||
markupsafe==2.1.5; python_version >= '3.7'
|
||||
marshmallow==3.21.1; python_version >= '3.8'
|
||||
oic==1.6.1; python_version ~= '3.7'
|
||||
oic==1.7.0; python_version ~= '3.8'
|
||||
orderedmultidict==1.0.1
|
||||
packaging==24.0; python_version >= '3.7'
|
||||
phonenumberslite==8.13.35
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue