diff --git a/.github/workflows/deploy-sandbox.yaml b/.github/workflows/deploy-sandbox.yaml index d9d7cbe14..1c486bdf7 100644 --- a/.github/workflows/deploy-sandbox.yaml +++ b/.github/workflows/deploy-sandbox.yaml @@ -24,6 +24,7 @@ jobs: || startsWith(github.head_ref, 'backup/') || startsWith(github.head_ref, 'meoward/') || startsWith(github.head_ref, 'bob/') + || startsWith(github.head_ref, 'cb/') outputs: environment: ${{ steps.var.outputs.environment}} runs-on: "ubuntu-latest" diff --git a/.github/workflows/migrate.yaml b/.github/workflows/migrate.yaml index 825ab04d7..f5815012c 100644 --- a/.github/workflows/migrate.yaml +++ b/.github/workflows/migrate.yaml @@ -16,6 +16,7 @@ on: - stable - staging - development + - cb - bob - meoward - backup diff --git a/.github/workflows/reset-db.yaml b/.github/workflows/reset-db.yaml index 05eb963c3..06638aa05 100644 --- a/.github/workflows/reset-db.yaml +++ b/.github/workflows/reset-db.yaml @@ -16,6 +16,7 @@ on: options: - staging - development + - cb - bob - meoward - backup diff --git a/docs/developer/adding-feature-flags.md b/docs/developer/adding-feature-flags.md new file mode 100644 index 000000000..dd97c7497 --- /dev/null +++ b/docs/developer/adding-feature-flags.md @@ -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. diff --git a/ops/manifests/manifest-cb.yaml b/ops/manifests/manifest-cb.yaml new file mode 100644 index 000000000..b9be98d27 --- /dev/null +++ b/ops/manifests/manifest-cb.yaml @@ -0,0 +1,32 @@ +--- +applications: +- name: getgov-cb + buildpacks: + - python_buildpack + path: ../../src + instances: 1 + memory: 512M + stack: cflinuxfs4 + timeout: 180 + command: ./run.sh + health-check-type: http + health-check-http-endpoint: /health + health-check-invocation-timeout: 40 + env: + # Send stdout and stderr straight to the terminal without buffering + PYTHONUNBUFFERED: yup + # Tell Django where to find its configuration + DJANGO_SETTINGS_MODULE: registrar.config.settings + # Tell Django where it is being hosted + DJANGO_BASE_URL: https://getgov-cb.app.cloud.gov + # Tell Django how much stuff to log + DJANGO_LOG_LEVEL: INFO + # default public site location + GETGOV_PUBLIC_SITE_URL: https://get.gov + # Flag to disable/enable features in prod environments + IS_PRODUCTION: False + routes: + - route: getgov-cb.app.cloud.gov + services: + - getgov-credentials + - getgov-cb-database diff --git a/src/Pipfile b/src/Pipfile index 33abf0158..fdf127d7c 100644 --- a/src/Pipfile +++ b/src/Pipfile @@ -33,6 +33,7 @@ pyzipper="*" tblib = "*" django-admin-multiple-choice-list-filter = "*" django-import-export = "*" +django-waffle = "*" [dev-packages] django-debug-toolbar = "*" diff --git a/src/Pipfile.lock b/src/Pipfile.lock index f21b833ce..a42563c63 100644 --- a/src/Pipfile.lock +++ b/src/Pipfile.lock @@ -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": [ @@ -387,6 +387,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", @@ -417,12 +426,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", @@ -613,7 +622,6 @@ "sha256:3d0c3dd24bb4605439bf91068598d00c6370684f8de4a67c2992683f6c309d6b", "sha256:3dbe858ee582cbb2c6294dc85f55b5f19c918c2597855e950f34b660f1a5ede6", "sha256:3dc773b2861b37b41a6136e0b72a1a44689a9c4c101e0cddb6b854016acc0aa8", - "sha256:3e183c6e3298a2ed5af9d7a356ea823bccaab4ec2349dc9ed83999fd289d14d5", "sha256:3f7765e69bbce0906a7c74d5fe46d2c7a7596147318dbc08e4a2431f3060e306", "sha256:417d14450f06d51f363e41cace6488519038f940676ce9664b34ebf5653433a5", "sha256:44f6c7caff88d988db017b9b0e4ab04934f11e3e72d478031efc7edcac6c622f", @@ -839,12 +847,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" }, "openpyxl": { "hashes": [ @@ -1374,49 +1382,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": [ @@ -1429,28 +1437,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": [ @@ -1487,20 +1495,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": [ @@ -1553,37 +1561,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": [ @@ -1794,14 +1802,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", diff --git a/src/djangooidc/backends.py b/src/djangooidc/backends.py index b0e6417db..41e442f2d 100644 --- a/src/djangooidc/backends.py +++ b/src/djangooidc/backends.py @@ -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): diff --git a/src/djangooidc/tests/test_backends.py b/src/djangooidc/tests/test_backends.py index ac7f74903..c15106fa9 100644 --- a/src/djangooidc/tests/test_backends.py +++ b/src/djangooidc/tests/test_backends.py @@ -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 diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 89f8391b3..356286936 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -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 @@ -1091,7 +1093,7 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): ( "Show details", { - "classes": ["collapse--dotgov"], + "classes": ["collapse--dgfieldset"], "description": "Extends type of organization", "fields": [ "federal_type", @@ -1117,7 +1119,7 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): ( "Show details", { - "classes": ["collapse--dotgov"], + "classes": ["collapse--dgfieldset"], "description": "Extends organization name and mailing address", "fields": [ "address_line1", @@ -1352,7 +1354,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): ( "Show details", { - "classes": ["collapse--dotgov"], + "classes": ["collapse--dgfieldset"], "description": "Extends type of organization", "fields": [ "federal_type", @@ -1378,7 +1380,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): ( "Show details", { - "classes": ["collapse--dotgov"], + "classes": ["collapse--dgfieldset"], "description": "Extends organization name and mailing address", "fields": [ "address_line1", @@ -2252,7 +2254,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 @@ -2274,3 +2285,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) diff --git a/src/registrar/assets/js/dja-collapse.js b/src/registrar/assets/js/dja-collapse.js index c33954192..6fd838e2e 100644 --- a/src/registrar/assets/js/dja-collapse.js +++ b/src/registrar/assets/js/dja-collapse.js @@ -4,19 +4,18 @@ * Works with our fieldset override */ -/*global gettext*/ 'use strict'; { window.addEventListener('load', function() { // Add anchor tag for Show/Hide link - const fieldsets = document.querySelectorAll('fieldset.collapse--dotgov'); + 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--dotgov usa-button usa-button--unstyled'; + button.className = 'collapse-toggle--dgfieldset usa-button usa-button--unstyled'; } } // Add toggle to hide/show anchor tag @@ -38,8 +37,40 @@ fieldset.classList.add('collapsed'); } }; - document.querySelectorAll('.collapse-toggle--dotgov').forEach(function(el) { + 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); + }); + }); +} diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index f38afd252..702364cba 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -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) + } +})(); diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index b4b590acb..9f5ea7a97 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -243,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; } @@ -293,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; @@ -395,7 +395,6 @@ details.dja-detail-table { border-top: none; border-bottom: none; } - } @@ -542,32 +541,42 @@ 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 { - background: none; - text-transform: none; +} +.collapse-toggle--dgsimple, +.module.collapse--dgfieldset button { + background: none; + text-transform: none; + color: var(--link-fg); + margin-top: 8px; + margin-left: 10px; + span { + text-decoration: underline; + font-size: 13px; + font-feature-settings: "kern"; + font-kerning: normal; + line-height: 13px; + font-family: family('sans'); + } + &:hover { color: var(--link-fg); - margin-top: 8px; - margin-left: 10px; - span { - text-decoration: underline; - font-size: 13px; - 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"; + 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); @@ -576,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; } @@ -633,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; } @@ -676,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; +} diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index a18adabde..f2890cc20 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -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 # # # ### @@ -150,6 +149,8 @@ INSTALLED_APPS = [ "django_admin_multiple_choice_list_filter", # library for export and import of data "import_export", + # Waffle feature flags + "waffle", ] # Middleware are routines for processing web requests. @@ -185,6 +186,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`) @@ -321,6 +324,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 @@ -644,6 +658,7 @@ ALLOWED_HOSTS = [ "getgov-stable.app.cloud.gov", "getgov-staging.app.cloud.gov", "getgov-development.app.cloud.gov", + "getgov-cb.app.cloud.gov", "getgov-bob.app.cloud.gov", "getgov-meoward.app.cloud.gov", "getgov-backup.app.cloud.gov", diff --git a/src/registrar/fixtures_users.py b/src/registrar/fixtures_users.py index 7f991fa0e..c31acacfd 100644 --- a/src/registrar/fixtures_users.py +++ b/src/registrar/fixtures_users.py @@ -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") diff --git a/src/registrar/migrations/0090_waffleflag.py b/src/registrar/migrations/0090_waffleflag.py new file mode 100644 index 000000000..3edef9a7e --- /dev/null +++ b/src/registrar/migrations/0090_waffleflag.py @@ -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", + }, + ), + ] diff --git a/src/registrar/models/__init__.py b/src/registrar/models/__init__.py index d3bbb3ae5..f084a5d8b 100644 --- a/src/registrar/models/__init__.py +++ b/src/registrar/models/__init__.py @@ -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) diff --git a/src/registrar/models/contact.py b/src/registrar/models/contact.py index 9deb22641..3ebd8bc3e 100644 --- a/src/registrar/models/contact.py +++ b/src/registrar/models/contact.py @@ -101,11 +101,23 @@ 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): - self.user.first_name = self.first_name - self.user.last_name = self.last_name - self.user.save() + 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): if self.first_name or self.last_name: diff --git a/src/registrar/models/waffle_flag.py b/src/registrar/models/waffle_flag.py new file mode 100644 index 000000000..d185c2a82 --- /dev/null +++ b/src/registrar/models/waffle_flag.py @@ -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" diff --git a/src/registrar/templates/admin/change_list.html b/src/registrar/templates/admin/change_list.html index 05c2d4e64..43abf0861 100644 --- a/src/registrar/templates/admin/change_list.html +++ b/src/registrar/templates/admin/change_list.html @@ -2,6 +2,10 @@ {% block content_title %}

{{ title }}

+ + {# Adds a model description #} + {% include "admin/model_descriptions.html" %} +

{{ cl.result_count }} {% if cl.get_ordering_field_columns %} diff --git a/src/registrar/templates/admin/fieldset.html b/src/registrar/templates/admin/fieldset.html index 19c5db294..89537b098 100644 --- a/src/registrar/templates/admin/fieldset.html +++ b/src/registrar/templates/admin/fieldset.html @@ -8,7 +8,7 @@ https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/
{% if fieldset.name %} {# Customize the markup for the collapse toggle #} - {% if 'collapse--dotgov' in fieldset.classes %} + {% if 'collapse--dgfieldset' in fieldset.classes %} diff --git a/src/registrar/templates/django/admin/includes/descriptions/contact_description.html b/src/registrar/templates/django/admin/includes/descriptions/contact_description.html new file mode 100644 index 000000000..11141dca8 --- /dev/null +++ b/src/registrar/templates/django/admin/includes/descriptions/contact_description.html @@ -0,0 +1,10 @@ +

+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 Users table. +

+ +

+Updating someone’s contact information here will not affect that person’s Login.gov information. +

diff --git a/src/registrar/templates/django/admin/includes/descriptions/domain_description.html b/src/registrar/templates/django/admin/includes/descriptions/domain_description.html new file mode 100644 index 000000000..5b8a79a4a --- /dev/null +++ b/src/registrar/templates/django/admin/includes/descriptions/domain_description.html @@ -0,0 +1,25 @@ +

+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. +

+ +

+Individual domain pages allow you to view registry-related details and edit the associated domain information. +

+ +

+Other actions available on the domain page include the ability to: +

+ + +

+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. +

diff --git a/src/registrar/templates/django/admin/includes/descriptions/domain_information_description.html b/src/registrar/templates/django/admin/includes/descriptions/domain_information_description.html new file mode 100644 index 000000000..36d197cf8 --- /dev/null +++ b/src/registrar/templates/django/admin/includes/descriptions/domain_information_description.html @@ -0,0 +1,19 @@ +

+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 Domains table. +

+ +

+Updating values here will immediately update the corresponding values that users see in the registrar. +

+ +

+Domain information is similar to Domain requests, +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. +

diff --git a/src/registrar/templates/django/admin/includes/descriptions/domain_invitation_description.html b/src/registrar/templates/django/admin/includes/descriptions/domain_invitation_description.html new file mode 100644 index 000000000..7765b9203 --- /dev/null +++ b/src/registrar/templates/django/admin/includes/descriptions/domain_invitation_description.html @@ -0,0 +1,16 @@ +

+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. +

+ +

+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. +

+ +

+If an invitation is created in this table, an email will not be sent. +To have an email sent, go to the domain in Domains, +click the “Manage domain” button, and add a domain manager. +

diff --git a/src/registrar/templates/django/admin/includes/descriptions/domain_request_description.html b/src/registrar/templates/django/admin/includes/descriptions/domain_request_description.html new file mode 100644 index 000000000..5adc07454 --- /dev/null +++ b/src/registrar/templates/django/admin/includes/descriptions/domain_request_description.html @@ -0,0 +1,11 @@ +

+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. +

+ +

+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 Domains. +Similar fields display on each Domain page, but edits made there will not affect the corresponding domain request. +

diff --git a/src/registrar/templates/django/admin/includes/descriptions/draft_domain_description.html b/src/registrar/templates/django/admin/includes/descriptions/draft_domain_description.html new file mode 100644 index 000000000..9e0ac9914 --- /dev/null +++ b/src/registrar/templates/django/admin/includes/descriptions/draft_domain_description.html @@ -0,0 +1,10 @@ +

+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. +

+ +

+This table does not include “alternative domains,” +which are housed in the Websites table. +

diff --git a/src/registrar/templates/django/admin/includes/descriptions/host_description.html b/src/registrar/templates/django/admin/includes/descriptions/host_description.html new file mode 100644 index 000000000..a39519898 --- /dev/null +++ b/src/registrar/templates/django/admin/includes/descriptions/host_description.html @@ -0,0 +1,11 @@ +

+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). +

+ +

+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 Domains, +then click the "Manage domain" button. +

diff --git a/src/registrar/templates/django/admin/includes/descriptions/logentry_description.html b/src/registrar/templates/django/admin/includes/descriptions/logentry_description.html new file mode 100644 index 000000000..0dc3fe94e --- /dev/null +++ b/src/registrar/templates/django/admin/includes/descriptions/logentry_description.html @@ -0,0 +1,7 @@ +

+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 +(Domain requests) and click on the “History” button. +

diff --git a/src/registrar/templates/django/admin/includes/descriptions/public_contact_description.html b/src/registrar/templates/django/admin/includes/descriptions/public_contact_description.html new file mode 100644 index 000000000..809b62a33 --- /dev/null +++ b/src/registrar/templates/django/admin/includes/descriptions/public_contact_description.html @@ -0,0 +1,18 @@ +

+Public contacts represent the three registry contact types (administrative, technical, and security) +and their fields that are exposed in WHOIS data. +

+ +

+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. +

+ +

+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. +

diff --git a/src/registrar/templates/django/admin/includes/descriptions/transition_domain_description.html b/src/registrar/templates/django/admin/includes/descriptions/transition_domain_description.html new file mode 100644 index 000000000..331c7b18b --- /dev/null +++ b/src/registrar/templates/django/admin/includes/descriptions/transition_domain_description.html @@ -0,0 +1,4 @@ +

+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. +

diff --git a/src/registrar/templates/django/admin/includes/descriptions/user_description.html b/src/registrar/templates/django/admin/includes/descriptions/user_description.html new file mode 100644 index 000000000..2f1777169 --- /dev/null +++ b/src/registrar/templates/django/admin/includes/descriptions/user_description.html @@ -0,0 +1,16 @@ +

+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. +

+ +

+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. +

+ +

+Each user record displays the associated “contact” info for that user, +which is the same info found in the Contacts table. +Updating these details on the user record will also update the corresponding contact record for that user. +

diff --git a/src/registrar/templates/django/admin/includes/descriptions/user_domain_role_description.html b/src/registrar/templates/django/admin/includes/descriptions/user_domain_role_description.html new file mode 100644 index 000000000..7066fcb93 --- /dev/null +++ b/src/registrar/templates/django/admin/includes/descriptions/user_domain_role_description.html @@ -0,0 +1,10 @@ +

+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. +

+ +

+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. +

diff --git a/src/registrar/templates/django/admin/includes/descriptions/user_group_description.html b/src/registrar/templates/django/admin/includes/descriptions/user_group_description.html new file mode 100644 index 000000000..610c8b430 --- /dev/null +++ b/src/registrar/templates/django/admin/includes/descriptions/user_group_description.html @@ -0,0 +1,10 @@ +

+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 Users table. +

+ +

+To maintain a single source of truth across all environments (stable, staging, etc.), +the groups and permissions are set and updated through the codebase. +Do not add, edit or delete user groups here. +

diff --git a/src/registrar/templates/django/admin/includes/descriptions/verified_by_staff_description.html b/src/registrar/templates/django/admin/includes/descriptions/verified_by_staff_description.html new file mode 100644 index 000000000..f7e4a58c9 --- /dev/null +++ b/src/registrar/templates/django/admin/includes/descriptions/verified_by_staff_description.html @@ -0,0 +1,10 @@ +

+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. +

+ +

+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). +

diff --git a/src/registrar/templates/django/admin/includes/descriptions/website_description.html b/src/registrar/templates/django/admin/includes/descriptions/website_description.html new file mode 100644 index 000000000..f6f5bdd1c --- /dev/null +++ b/src/registrar/templates/django/admin/includes/descriptions/website_description.html @@ -0,0 +1,8 @@ +

+This table lists all the “current websites” and “alternative domains” that users have submitted in domain requests since January 2024. +

+ +

+This does not include any “requested domains” that have appeared within the Domain requests table. +Those names are managed in the Draft domains table. +

diff --git a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html index c7bb6325e..5f23ac6c0 100644 --- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html +++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html @@ -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)
{% with total_websites=field.contents|split:", " %} {% for website in total_websites %} - {{ website }}{% if not forloop.last %}, {% endif %} + {{ website }}{% if not forloop.last %}, {% endif %} {# Acts as a
#} {% if total_websites|length < 5 %}
@@ -70,29 +71,37 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% if field.field.name == "status" and original_object.history.count > 0 %}
-
- - - - - - - - - - {% for log_entry in original_object.history.all %} - {% for key, value in log_entry.changes_display_dict.items %} - {% if key == "status" %} - - - - - - {% endif %} +
+
+
StatusUserChanged at
{{ value.1|default:"None" }}{{ log_entry.actor|default:"None" }}{{ log_entry.timestamp|default:"None" }}
+ + + + + + + + + {% for log_entry in original_object.history.all %} + {% for key, value in log_entry.changes_display_dict.items %} + {% if key == "status" %} + + + + + + {% endif %} + {% endfor %} {% endfor %} - {% endfor %} - -
StatusUserChanged at
{{ value.1|default:"None" }}{{ log_entry.actor|default:"None" }}{{ log_entry.timestamp|default:"None" }}
+ + +
+
{% elif field.field.name == "creator" %} @@ -154,5 +163,16 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% endif %} {% endwith %} + {% elif field.field.name == "state_territory" %} +
+ + 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 %} + +
{% endif %} {% endblock after_help_text %} diff --git a/src/registrar/templates/includes/non-production-alert.html b/src/registrar/templates/includes/non-production-alert.html index 911eea9d6..661fb1397 100644 --- a/src/registrar/templates/includes/non-production-alert.html +++ b/src/registrar/templates/includes/non-production-alert.html @@ -1,8 +1,10 @@ -
+
Attention: You are on a test site. + {% if has_profile_feature_flag %} + The profile_feature flag is active. + {% endif %}
-
\ No newline at end of file +
diff --git a/src/registrar/templatetags/custom_filters.py b/src/registrar/templatetags/custom_filters.py index 9fa5c9aa9..798558355 100644 --- a/src/registrar/templatetags/custom_filters.py +++ b/src/registrar/templatetags/custom_filters.py @@ -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 diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index c2f11b85d..f1c77fb8e 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -837,7 +837,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, diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index d8f4975e8..6dbff5d53 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -22,6 +22,12 @@ from registrar.admin import ( UserDomainRoleAdmin, VerifiedByStaffAdmin, FsmModelResource, + WebsiteAdmin, + DraftDomainAdmin, + FederalAgencyAdmin, + PublicContactAdmin, + TransitionDomainAdmin, + UserGroupAdmin, ) from registrar.models import ( Domain, @@ -34,6 +40,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 @@ -132,6 +141,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 = '
CISA region: N/A
' + # 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 = '
CISA region: 2
' + # 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 @@ -897,6 +989,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): """ @@ -1931,7 +2040,7 @@ class TestDomainRequestAdmin(MockEppLib): self.assertContains(response, domain_request.requested_domain.name) # Check that the page contains the link we expect. - expected_url = 'city.com' + expected_url = 'city.com' self.assertContains(response, expected_url) @less_console_noise_decorator @@ -2526,6 +2635,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 = '
CISA region: N/A
' + # 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 = '
CISA region: 2
' + # 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() @@ -2537,7 +2706,7 @@ class TestDomainRequestAdmin(MockEppLib): self.mock_client.EMAILS_SENT.clear() -class DomainInvitationAdminTest(TestCase): +class TestDomainInvitationAdmin(TestCase): """Tests for the DomainInvitation page""" def setUp(self): @@ -2553,6 +2722,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(): @@ -2567,7 +2755,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) @@ -2602,6 +2790,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): """ @@ -2680,6 +2885,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 = '
CISA region: N/A
' + # 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 = '
CISA region: 2
' + # 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): """ @@ -2923,7 +3210,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() @@ -2945,6 +3232,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(): @@ -3141,6 +3447,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): """ @@ -3575,7 +3898,7 @@ class DomainSessionVariableTest(TestCase): ) -class ContactAdminTest(TestCase): +class TestContactAdmin(TestCase): def setUp(self): self.site = AdminSite() self.factory = RequestFactory() @@ -3584,6 +3907,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("/") @@ -3702,6 +4042,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): """ @@ -3744,3 +4103,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") diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index ca77d1ddf..1558ab310 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -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.""" diff --git a/src/registrar/views/index.py b/src/registrar/views/index.py index cb170078a..651507c44 100644 --- a/src/registrar/views/index.py +++ b/src/registrar/views/index.py @@ -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 diff --git a/src/requirements.txt b/src/requirements.txt index d4878748a..3f7158449 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -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 @@ -24,10 +24,11 @@ django-fsm==2.8.1 django-import-export==3.3.8; python_version >= '3.8' 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' et-xmlfile==1.1.0; python_version >= '3.6' -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' @@ -42,7 +43,7 @@ markuppy==1.14 markupsafe==2.1.5; python_version >= '3.7' marshmallow==3.21.1; python_version >= '3.8' odfpy==1.4.1 -oic==1.6.1; python_version ~= '3.7' +oic==1.7.0; python_version ~= '3.8' openpyxl==3.1.2 orderedmultidict==1.0.1 packaging==24.0; python_version >= '3.7'