diff --git a/docs/developer/README.md b/docs/developer/README.md index 46194bd70..464626b87 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -207,6 +207,17 @@ Linters: docker-compose exec app ./manage.py lint ``` +### Get availability for domain requests to work locally + +If you're on local (localhost:8080) and want to submit a domain request, and keep getting the "We’re experiencing a system error. Please wait a few minutes and try again. If you continue to get this error, contact help@get.gov." error, you can get past the availability check by updating the available() function in registrar/models/domain.py to return True and comment everything else out - see below for reference! + +``` +@classmethod +def available(cls, domain: str) -> bool: + # Comment everything else out in the function + return True +``` + ### Testing behind logged in pages To test behind logged in pages with external tools, like `pa11y-ci` or `OWASP Zap`, add @@ -305,15 +316,15 @@ You can also compile the **Sass** at any time using `npx gulp compile`. Similarl We use the [CSS Block Element Modifier (BEM)](https://getbem.com/naming/) naming convention for our custom classes. This is in line with how USWDS [approaches](https://designsystem.digital.gov/whats-new/updates/2019/04/08/introducing-uswds-2-0/) their CSS class architecture and helps keep our code cohesive and readable. -### Upgrading USWDS and other JavaScript packages +### Updating USWDS 1. Version numbers can be manually controlled in `package.json`. Edit that, if desired. -2. Now run `docker-compose run node npm update`. -3. Then run `docker-compose up` to recompile and recopy the assets, or run `docker-compose updateUswds` if your docker is already up. -4. Make note of the dotgov changes in uswds-edited.js. -5. Copy over the newly compiled code from uswds.js into uswds-edited.js. -6. Put back the dotgov changes you made note of into uswds-edited.js. -7. Examine the results in the running application (remember to empty your cache!) and commit `package.json` and `package-lock.json` if all is well. +2. Now run `npx gulp updateUswds`. Refer to [official docs](https://designsystem.digital.gov/documentation/getting-started/developers/phase-two-compile/) to see what this is doing. +3. Make note of the dotgov changes in uswds-edited.js (Ctrl-F DOTGOV for modifications to USWDS compiled code). +4. Copy over the newly compiled code from uswds.js into uswds-edited.js. +5. Put back the dotgov changes you made note of into uswds-edited.js. +6. Examine the results in the running application (remember to empty your cache!) and commit `package.json` and `package-lock.json` if all is well. +7. Read the [release notes](https://github.com/uswds/uswds/releases) for the new versions installed, note 'Breaking' and 'Markup change' and make adjustments to the code base as needed. ## Finite State Machines diff --git a/src/package-lock.json b/src/package-lock.json index 0f2a8c38b..f46ecd3e8 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -63,23 +63,22 @@ } }, "node_modules/@babel/core": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.8.tgz", - "integrity": "sha512-l+lkXCHS6tQEc5oUpK28xBOZ6+HwaH7YwoYQbLFiYb4nS2/l1tKnZEtEWkD0GuiYdvArf9qBS0XlQGXzPMsNqQ==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.9.tgz", + "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.8", + "@babel/generator": "^7.26.9", "@babel/helper-compilation-targets": "^7.26.5", "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.7", - "@babel/parser": "^7.26.8", - "@babel/template": "^7.26.8", - "@babel/traverse": "^7.26.8", - "@babel/types": "^7.26.8", - "@types/gensync": "^1.0.0", + "@babel/helpers": "^7.26.9", + "@babel/parser": "^7.26.9", + "@babel/template": "^7.26.9", + "@babel/traverse": "^7.26.9", + "@babel/types": "^7.26.9", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -95,14 +94,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.8.tgz", - "integrity": "sha512-ef383X5++iZHWAXX0SXQR6ZyQhw/0KtTkrTz61WXRhFM6dhpHulO/RJz79L8S6ugZHJkOOkUrUdxgdF2YiPFnA==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.9.tgz", + "integrity": "sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.26.8", - "@babel/types": "^7.26.8", + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" @@ -142,18 +141,18 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.9.tgz", - "integrity": "sha512-UTZQMvt0d/rSz6KI+qdu7GQze5TIajwTS++GUozlw8VBJDEOAqSXwm1WvmYEZwqdqSGQshRocPDqrt4HBZB3fQ==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.26.9.tgz", + "integrity": "sha512-ubbUqCofvxPRurw5L8WTsCLSkQiVpov4Qx0WMA+jUN+nXBK8ADPlJO1grkFw5CWKC5+sZSOfuGMdX1aI1iT9Sg==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", "@babel/helper-member-expression-to-functions": "^7.25.9", "@babel/helper-optimise-call-expression": "^7.25.9", - "@babel/helper-replace-supers": "^7.25.9", + "@babel/helper-replace-supers": "^7.26.5", "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", - "@babel/traverse": "^7.25.9", + "@babel/traverse": "^7.26.9", "semver": "^6.3.1" }, "engines": { @@ -363,27 +362,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.7.tgz", - "integrity": "sha512-8NHiL98vsi0mbPQmYAGWwfcFaOy4j2HY49fXJCfuDcdE7fMIsH9a7GdaeXpIBsbT7307WU8KCMp5pUVDNL4f9A==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.9.tgz", + "integrity": "sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.7" + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.8.tgz", - "integrity": "sha512-TZIQ25pkSoaKEYYaHbbxkfL36GNsQ6iFiBbeuzAkLnXayKR1yP1zFe+NxuZWWsUyvt8icPU9CCq0sgWGXR1GEw==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz", + "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.26.8" + "@babel/types": "^7.26.9" }, "bin": { "parser": "bin/babel-parser.js" @@ -809,13 +808,13 @@ } }, "node_modules/@babel/plugin-transform-for-of": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.25.9.tgz", - "integrity": "sha512-LqHxduHoaGELJl2uhImHwRQudhCM50pT46rIBNvtT/Oql3nqiS3wOwP+5ten7NpYSXrrVLgtZU3DZmPtWZo16A==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.26.9.tgz", + "integrity": "sha512-Hry8AusVm8LW5BVFgiyUReuoGzPUpdHQQqJY5bZnbbf+ngOHWuCuYFKw/BqaaWlvEUrF91HMhDtEaI1hZzNbLg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-plugin-utils": "^7.26.5", "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" }, "engines": { @@ -1376,9 +1375,9 @@ } }, "node_modules/@babel/preset-env": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.8.tgz", - "integrity": "sha512-um7Sy+2THd697S4zJEfv/U5MHGJzkN2xhtsR3T/SWRbVSic62nbISh51VVfU9JiO/L/Z97QczHTaFVkOU8IzNg==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.9.tgz", + "integrity": "sha512-vX3qPGE8sEKEAZCWk05k3cpTAE3/nOYca++JA+Rd0z2NCNzabmYvEiSShKzm10zdquOIAVXsy2Ei/DTW34KlKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1411,7 +1410,7 @@ "@babel/plugin-transform-dynamic-import": "^7.25.9", "@babel/plugin-transform-exponentiation-operator": "^7.26.3", "@babel/plugin-transform-export-namespace-from": "^7.25.9", - "@babel/plugin-transform-for-of": "^7.25.9", + "@babel/plugin-transform-for-of": "^7.26.9", "@babel/plugin-transform-function-name": "^7.25.9", "@babel/plugin-transform-json-strings": "^7.25.9", "@babel/plugin-transform-literals": "^7.25.9", @@ -1475,9 +1474,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.7.tgz", - "integrity": "sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz", + "integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==", "dev": true, "license": "MIT", "dependencies": { @@ -1488,32 +1487,32 @@ } }, "node_modules/@babel/template": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.8.tgz", - "integrity": "sha512-iNKaX3ZebKIsCvJ+0jd6embf+Aulaa3vNBqZ41kM7iTWjx5qzWKXGHiJUW3+nTpQ18SG11hdF8OAzKrpXkb96Q==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", + "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.26.2", - "@babel/parser": "^7.26.8", - "@babel/types": "^7.26.8" + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.8.tgz", - "integrity": "sha512-nic9tRkjYH0oB2dzr/JoGIm+4Q6SuYeLEiIiZDwBscRMYFJ+tMAz98fuel9ZnbXViA2I0HVSSRRK8DW5fjXStA==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.9.tgz", + "integrity": "sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.8", - "@babel/parser": "^7.26.8", - "@babel/template": "^7.26.8", - "@babel/types": "^7.26.8", + "@babel/generator": "^7.26.9", + "@babel/parser": "^7.26.9", + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.9", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -1522,9 +1521,9 @@ } }, "node_modules/@babel/types": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.8.tgz", - "integrity": "sha512-eUuWapzEGWFEpHFxgEaBG8e3n6S8L3MSu0oda755rOfabWPnh0Our1AozNFVUxGFIhbKgd1ksprsoDGMinTOTA==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz", + "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==", "dev": true, "license": "MIT", "dependencies": { @@ -1999,13 +1998,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/gensync": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@types/gensync/-/gensync-1.0.4.tgz", - "integrity": "sha512-C3YYeRQWp2fmq9OryX+FoDy8nXS6scQ7dPptD8LnFDAUNcKWJjXQKDNJD3HVm+kOUsXhTOkpi69vI4EuAr95bA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2014,9 +2006,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.13.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz", - "integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==", + "version": "22.13.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.9.tgz", + "integrity": "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2255,9 +2247,9 @@ "license": "Apache-2.0" }, "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "dev": true, "license": "MIT", "bin": { @@ -2868,9 +2860,9 @@ "license": "MIT" }, "node_modules/caniuse-lite": { - "version": "1.0.30001699", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001699.tgz", - "integrity": "sha512-b+uH5BakXZ9Do9iK+CkDmctUSEqZl+SP056vc5usa0PL+ev5OHw003rZXcnjNDv3L8P5j6rwT6C0BPKSikW08w==", + "version": "1.0.30001702", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001702.tgz", + "integrity": "sha512-LoPe/D7zioC0REI5W73PeR1e1MLCipRGq/VkovJnd6Df+QVqT+vT33OXCp8QUd7kA7RZrHWxb1B36OQKI/0gOA==", "dev": true, "funding": [ { @@ -3142,13 +3134,13 @@ } }, "node_modules/core-js-compat": { - "version": "3.40.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.40.0.tgz", - "integrity": "sha512-0XEDpr5y5mijvw8Lbc6E5AkjrHfp7eEoPlu36SWeAbcL8fn1G1ANe8DBlo2XoNN89oVpxWwOjYIPVzR4ZvsKCQ==", + "version": "3.41.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.41.0.tgz", + "integrity": "sha512-RFsU9LySVue9RTwdDVX/T0e2Y6jRYWXERKElIjpuEOEnxaXffI0X7RUwVzfYLfzuLXSNJDYoRYUAmRUcyln20A==", "dev": true, "license": "MIT", "dependencies": { - "browserslist": "^4.24.3" + "browserslist": "^4.24.4" }, "funding": { "type": "opencollective", @@ -3371,9 +3363,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.97", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.97.tgz", - "integrity": "sha512-HKLtaH02augM7ZOdYRuO19rWDeY+QSJ1VxnXFa/XDFLf07HvM90pALIJFgrO+UVaajI3+aJMMpojoUTLZyQ7JQ==", + "version": "1.5.113", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.113.tgz", + "integrity": "sha512-wjT2O4hX+wdWPJ76gWSkMhcHAV2PTMX+QetUCPYEdCIe+cxmgzzSSiGRCKW8nuh4mwKZlpv0xvoW7OF2X+wmHg==", "dev": true, "license": "ISC" }, @@ -3661,13 +3653,6 @@ "node": ">=8.6.0" } }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, "node_modules/fast-levenshtein": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-3.0.0.tgz", @@ -3706,9 +3691,9 @@ } }, "node_modules/fastq": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.0.tgz", - "integrity": "sha512-7SFSRCNjBQIZH/xZR3iy5iQYR8aGBE0h3VG6/cwlbrpdciNYBMotQav8c1XI3HjHH+NikUpP53nPdlZSdWmFzA==", + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", "dev": true, "license": "ISC", "dependencies": { @@ -5718,16 +5703,6 @@ "once": "^1.3.1" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/puppeteer": { "version": "9.1.1", "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-9.1.1.tgz", @@ -6113,9 +6088,9 @@ } }, "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "dev": true, "license": "MIT", "engines": { @@ -6164,9 +6139,9 @@ } }, "node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -6186,9 +6161,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.84.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.84.0.tgz", - "integrity": "sha512-XDAbhEPJRxi7H0SxrnOpiXFQoUJHwkR2u3Zc4el+fK/Tt5Hpzw5kkQ59qVDfvdaUq6gCrEZIbySFBM2T9DNKHg==", + "version": "1.85.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.85.1.tgz", + "integrity": "sha512-Uk8WpxM5v+0cMR0XjX9KfRIacmSG86RH4DCCZjLU2rFh5tyutt9siAXJ7G+YfxQ99Q6wrRMbMlVl6KqUms71ag==", "license": "MIT", "dependencies": { "chokidar": "^4.0.0", @@ -6661,9 +6636,9 @@ } }, "node_modules/sass/node_modules/readdirp": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.1.tgz", - "integrity": "sha512-h80JrZu/MHUZCyHu5ciuoI0+WxsCxzxJTILn6Fs8rxSnFPh+UVHYfeIxK1nVGugMqkfC4vJcBOYbkfkwYK0+gw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "license": "MIT", "engines": { "node": ">= 14.18.0" @@ -6978,9 +6953,9 @@ } }, "node_modules/terser": { - "version": "5.38.2", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.38.2.tgz", - "integrity": "sha512-w8CXxxbFA5zfNsR/i8HZq5bvn18AK0O9jj7hyo1YqkovLxEFa0uP0LCVGZRqiRaKRFxXhELBp8SteeAjEnfeJg==", + "version": "5.39.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz", + "integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -6997,9 +6972,9 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.11", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.11.tgz", - "integrity": "sha512-RVCsMfuD0+cTt3EwX8hSl2Ks56EbFHWmhluwcqoPKtBnfjiT6olaq7PRIRfhyU8nnC2MrnDrBLfrD/RGE+cVXQ==", + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", + "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", "dev": true, "license": "MIT", "dependencies": { @@ -7229,9 +7204,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz", - "integrity": "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", "dev": true, "funding": [ { @@ -7259,16 +7234,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -7522,9 +7487,9 @@ "license": "BSD-2-Clause" }, "node_modules/webpack": { - "version": "5.97.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.97.1.tgz", - "integrity": "sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg==", + "version": "5.98.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.98.0.tgz", + "integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==", "dev": true, "license": "MIT", "dependencies": { @@ -7546,9 +7511,9 @@ "loader-runner": "^4.2.0", "mime-types": "^2.1.27", "neo-async": "^2.6.2", - "schema-utils": "^3.2.0", + "schema-utils": "^4.3.0", "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.10", + "terser-webpack-plugin": "^5.3.11", "watchpack": "^2.4.1", "webpack-sources": "^3.2.3" }, @@ -7617,59 +7582,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/webpack/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/webpack/node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/webpack/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/webpack/node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -7842,9 +7754,9 @@ } }, "node_modules/yocto-queue": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz", - "integrity": "sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.0.tgz", + "integrity": "sha512-KHBC7z61OJeaMGnF3wqNZj+GGNXOyypZviiKpQeiHirG5Ib1ImwcLBH70rbMSkKfSmUNBsdf2PwaEJtKvgmkNw==", "dev": true, "license": "MIT", "engines": { diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 09d0eaa81..83c547269 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -75,6 +75,19 @@ from django.utils.translation import gettext_lazy as _ logger = logging.getLogger(__name__) +class ImportExportRegistrarModelAdmin(ImportExportModelAdmin): + + def has_import_permission(self, request): + return request.user.has_perm("registrar.analyst_access_permission") or request.user.has_perm( + "registrar.full_access_permission" + ) + + def has_export_permission(self, request): + return request.user.has_perm("registrar.analyst_access_permission") or request.user.has_perm( + "registrar.full_access_permission" + ) + + class FsmModelResource(resources.ModelResource): """ModelResource is extended to support importing of tables which have FSMFields. ModelResource is extended with the following changes @@ -465,7 +478,7 @@ class DomainRequestAdminForm(forms.ModelForm): # only set the available transitions if the user is not restricted # from editing the domain request; otherwise, the form will be # readonly and the status field will not have a widget - if not domain_request.creator.is_restricted(): + if not domain_request.creator.is_restricted() and "status" in self.fields: self.fields["status"].widget.choices = available_transitions def get_custom_field_transitions(self, instance, field): @@ -919,7 +932,7 @@ class ListHeaderAdmin(AuditedAdmin, OrderableFieldsMixin): return filters -class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin): +class MyUserAdmin(BaseUserAdmin, ImportExportRegistrarModelAdmin): """Custom user admin class to use our inlines.""" resource_classes = [UserResource] @@ -1224,7 +1237,7 @@ class HostResource(resources.ModelResource): model = models.Host -class MyHostAdmin(AuditedAdmin, ImportExportModelAdmin): +class MyHostAdmin(AuditedAdmin, ImportExportRegistrarModelAdmin): """Custom host admin class to use our inlines.""" resource_classes = [HostResource] @@ -1242,7 +1255,7 @@ class HostIpResource(resources.ModelResource): model = models.HostIP -class HostIpAdmin(AuditedAdmin, ImportExportModelAdmin): +class HostIpAdmin(AuditedAdmin, ImportExportRegistrarModelAdmin): """Custom host ip admin class""" resource_classes = [HostIpResource] @@ -1257,7 +1270,7 @@ class ContactResource(resources.ModelResource): model = models.Contact -class ContactAdmin(ListHeaderAdmin, ImportExportModelAdmin): +class ContactAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): """Custom contact admin class to add search.""" resource_classes = [ContactResource] @@ -1391,6 +1404,59 @@ class SeniorOfficialAdmin(ListHeaderAdmin): # in autocomplete_fields for Senior Official ordering = ["first_name", "last_name"] + readonly_fields = [] + + # Even though this is empty, I will leave it as a stub for easy changes in the future + # rather than strip it out of our logic. + analyst_readonly_fields = [] # type: ignore + + omb_analyst_readonly_fields = [ + "first_name", + "last_name", + "title", + "phone", + "email", + "federal_agency", + ] + + def get_readonly_fields(self, request, obj=None): + """Set the read-only state on form elements. + We have conditions that determine which fields are read-only: + admin user permissions and analyst (cisa or omb) status, so + we'll use the baseline readonly_fields and extend it as needed. + """ + readonly_fields = list(self.readonly_fields) + + if request.user.has_perm("registrar.full_access_permission"): + return readonly_fields + # Return restrictive Read-only fields for OMB analysts + if request.user.groups.filter(name="omb_analysts_group").exists(): + readonly_fields.extend([field for field in self.omb_analyst_readonly_fields]) + return readonly_fields + # Return restrictive Read-only fields for analysts and + # users who might not belong to groups + readonly_fields.extend([field for field in self.analyst_readonly_fields]) + return readonly_fields + + def get_queryset(self, request): + """Restrict queryset based on user permissions.""" + qs = super().get_queryset(request) + + # Check if user is in OMB analysts group + if request.user.groups.filter(name="omb_analysts_group").exists(): + return qs.filter(federal_agency__federal_type=BranchChoices.EXECUTIVE) + + return qs # Return full queryset if the user doesn't have the restriction + + def has_view_permission(self, request, obj=None): + """Restrict view permissions based on group membership and model attributes.""" + if request.user.has_perm("registrar.full_access_permission"): + return True + if obj: + if request.user.groups.filter(name="omb_analysts_group").exists(): + return obj.federal_agency and obj.federal_agency.federal_type == BranchChoices.EXECUTIVE + return super().has_view_permission(request, obj) + class WebsiteResource(resources.ModelResource): """defines how each field in the referenced model should be mapped to the corresponding fields in the @@ -1400,7 +1466,7 @@ class WebsiteResource(resources.ModelResource): model = models.Website -class WebsiteAdmin(ListHeaderAdmin, ImportExportModelAdmin): +class WebsiteAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): """Custom website admin class.""" resource_classes = [WebsiteResource] @@ -1501,7 +1567,7 @@ class UserPortfolioPermissionAdmin(ListHeaderAdmin): obj.delete() # Calls the overridden delete method on each instance -class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin): +class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): """Custom user domain role admin class.""" resource_classes = [UserDomainRoleResource] @@ -1684,6 +1750,63 @@ class DomainInvitationAdmin(BaseInvitationAdmin): # Override for the delete confirmation page on the domain table (bulk delete action) delete_selected_confirmation_template = "django/admin/domain_invitation_delete_selected_confirmation.html" + def get_annotated_queryset(self, queryset): + return queryset.annotate( + converted_generic_org_type=Case( + # When portfolio is present, use its value instead + When( + domain__domain_info__portfolio__isnull=False, + then=F("domain__domain_info__portfolio__organization_type"), + ), + # Otherwise, return the natively assigned value + default=F("domain__domain_info__generic_org_type"), + ), + converted_federal_type=Case( + # When portfolio is present, use its value instead + When( + Q(domain__domain_info__portfolio__isnull=False) + & Q(domain__domain_info__portfolio__federal_agency__isnull=False), + then=F("domain__domain_info__portfolio__federal_agency__federal_type"), + ), + # Otherwise, return the federal agency's federal_type + default=F("domain__domain_info__federal_agency__federal_type"), + ), + ) + + def get_queryset(self, request): + """Restrict queryset based on user permissions.""" + qs = super().get_queryset(request) + + # Check if user is in OMB analysts group + if request.user.groups.filter(name="omb_analysts_group").exists(): + annotated_qs = self.get_annotated_queryset(qs) + return annotated_qs.filter( + converted_generic_org_type=DomainRequest.OrganizationChoices.FEDERAL, + converted_federal_type=BranchChoices.EXECUTIVE, + ) + + return qs # Return full queryset if the user doesn't have the restriction + + def has_view_permission(self, request, obj=None): + """Restrict view permissions based on group membership and model attributes.""" + if request.user.has_perm("registrar.full_access_permission"): + return True + if obj: + if request.user.groups.filter(name="omb_analysts_group").exists(): + return ( + obj.domain.domain_info.converted_generic_org_type == DomainRequest.OrganizationChoices.FEDERAL + and obj.domain.domain_info.converted_federal_type == BranchChoices.EXECUTIVE + ) + return super().has_view_permission(request, obj) + + # Select domain invitations to change -> Domain invitations + def changelist_view(self, request, extra_context=None): + if extra_context is None: + extra_context = {} + extra_context["tabtitle"] = "Domain invitations" + # Get the filtered values + return super().changelist_view(request, extra_context=extra_context) + def change_view(self, request, object_id, form_url="", extra_context=None): """Override the change_view to add the invitation obj for the change_form_object_tools template""" @@ -1846,7 +1969,7 @@ class PortfolioInvitationAdmin(BaseInvitationAdmin): requested_user = get_requested_user(requested_email) permission_exists = UserPortfolioPermission.objects.filter( - user__email=requested_email, portfolio=portfolio, user__email__isnull=False + user__email__iexact=requested_email, portfolio=portfolio, user__email__isnull=False ).exists() if not permission_exists: # if permission does not exist for a user with requested_email, send email @@ -1856,9 +1979,7 @@ class PortfolioInvitationAdmin(BaseInvitationAdmin): portfolio=portfolio, is_admin_invitation=is_admin_invitation, ): - messages.warning( - self.request, "Could not send email notification to existing organization admins." - ) + messages.warning(request, "Could not send email notification to existing organization admins.") # if user exists for email, immediately retrieve portfolio invitation upon creation if requested_user is not None: obj.retrieve() @@ -1907,7 +2028,7 @@ class DomainInformationResource(resources.ModelResource): model = models.DomainInformation -class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): +class DomainInformationAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): """Customize domain information admin class.""" class GenericOrgFilter(admin.SimpleListFilter): @@ -2184,6 +2305,47 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): "is_policy_acknowledged", ] + # Read only that we'll leverage for OMB Analysts + omb_analyst_readonly_fields = [ + "federal_agency", + "creator", + "about_your_organization", + "anything_else", + "cisa_representative_first_name", + "cisa_representative_last_name", + "cisa_representative_email", + "domain_request", + "notes", + "senior_official", + "organization_type", + "organization_name", + "state_territory", + "address_line1", + "address_line2", + "city", + "zipcode", + "urbanization", + "portfolio_organization_type", + "portfolio_federal_type", + "portfolio_organization_name", + "portfolio_federal_agency", + "portfolio_state_territory", + "portfolio_address_line1", + "portfolio_address_line2", + "portfolio_city", + "portfolio_zipcode", + "portfolio_urbanization", + "organization_type", + "federal_type", + "federal_agency", + "tribe_name", + "federally_recognized_tribe", + "state_recognized_tribe", + "about_your_organization", + "portfolio", + "sub_organization", + ] + # For each filter_horizontal, init in admin js initFilterHorizontalWidget # to activate the edit/delete/view buttons filter_horizontal = ("other_contacts",) @@ -2212,6 +2374,10 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): if request.user.has_perm("registrar.full_access_permission"): return readonly_fields + # Return restrictive Read-only fields for OMB analysts + if request.user.groups.filter(name="omb_analysts_group").exists(): + readonly_fields.extend([field for field in self.omb_analyst_readonly_fields]) + return readonly_fields # Return restrictive Read-only fields for analysts and # users who might not belong to groups readonly_fields.extend([field for field in self.analyst_readonly_fields]) @@ -2228,6 +2394,38 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): use_sort = db_field.name != "senior_official" return super().formfield_for_foreignkey(db_field, request, use_admin_sort_fields=use_sort, **kwargs) + def get_annotated_queryset(self, queryset): + return queryset.annotate( + conv_generic_org_type=Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__organization_type")), + # Otherwise, return the natively assigned value + default=F("generic_org_type"), + ), + conv_federal_type=Case( + # When portfolio is present, use its value instead + When( + Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False), + then=F("portfolio__federal_agency__federal_type"), + ), + # Otherwise, return the federal_type from federal agency + default=F("federal_agency__federal_type"), + ), + ) + + def get_queryset(self, request): + """Custom get_queryset to filter by portfolio if portfolio is in the + request params.""" + qs = super().get_queryset(request) + # Check if user is in OMB analysts group + if request.user.groups.filter(name="omb_analysts_group").exists(): + annotated_qs = self.get_annotated_queryset(qs) + return annotated_qs.filter( + conv_generic_org_type=DomainRequest.OrganizationChoices.FEDERAL, + conv_federal_type=BranchChoices.EXECUTIVE, + ) + return qs + class DomainRequestResource(FsmModelResource): """defines how each field in the referenced model should be mapped to the corresponding fields in the @@ -2237,7 +2435,7 @@ class DomainRequestResource(FsmModelResource): model = models.DomainRequest -class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): +class DomainRequestAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): """Custom domain requests admin class.""" resource_classes = [DomainRequestResource] @@ -2295,7 +2493,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): class FederalTypeFilter(admin.SimpleListFilter): """Custom Federal Type filter that accomodates portfolio feature. If we have a portfolio, use the portfolio's federal type. If not, use the - organization in the Domain Request object.""" + organization in the Domain Request object's federal agency.""" title = "federal type" parameter_name = "converted_federal_types" @@ -2336,7 +2534,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): if self.value(): return queryset.filter( Q(portfolio__federal_agency__federal_type=self.value()) - | Q(portfolio__isnull=True, federal_type=self.value()) + | Q(portfolio__isnull=True, federal_agency__federal_type=self.value()) ) return queryset @@ -2751,6 +2949,62 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): "cisa_representative_email", ] + # Read only that we'll leverage for OMB Analysts + omb_analyst_readonly_fields = [ + "federal_agency", + "creator", + "about_your_organization", + "requested_domain", + "approved_domain", + "alternative_domains", + "purpose", + "no_other_contacts_rationale", + "anything_else", + "is_policy_acknowledged", + "cisa_representative_first_name", + "cisa_representative_last_name", + "cisa_representative_email", + "status", + "investigator", + "notes", + "senior_official", + "organization_type", + "organization_name", + "state_territory", + "address_line1", + "address_line2", + "city", + "zipcode", + "urbanization", + "portfolio_organization_type", + "portfolio_federal_type", + "portfolio_organization_name", + "portfolio_federal_agency", + "portfolio_state_territory", + "portfolio_address_line1", + "portfolio_address_line2", + "portfolio_city", + "portfolio_zipcode", + "portfolio_urbanization", + "is_election_board", + "organization_type", + "federal_type", + "federal_agency", + "tribe_name", + "federally_recognized_tribe", + "state_recognized_tribe", + "about_your_organization", + "rejection_reason", + "rejection_reason_email", + "action_needed_reason", + "action_needed_reason_email", + "portfolio", + "sub_organization", + "requested_suborganization", + "suborganization_city", + "suborganization_state_territory", + ] + autocomplete_fields = [ "approved_domain", "requested_domain", @@ -2991,6 +3245,10 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): if request.user.has_perm("registrar.full_access_permission"): return readonly_fields + # Return restrictive Read-only fields for OMB analysts + if request.user.groups.filter(name="omb_analysts_group").exists(): + readonly_fields.extend([field for field in self.omb_analyst_readonly_fields]) + return readonly_fields # Return restrictive Read-only fields for analysts and # users who might not belong to groups readonly_fields.extend([field for field in self.analyst_readonly_fields]) @@ -3174,6 +3432,25 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): use_sort = db_field.name != "senior_official" return super().formfield_for_foreignkey(db_field, request, use_admin_sort_fields=use_sort, **kwargs) + def get_annotated_queryset(self, queryset): + return queryset.annotate( + conv_generic_org_type=Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__organization_type")), + # Otherwise, return the natively assigned value + default=F("generic_org_type"), + ), + conv_federal_type=Case( + # When portfolio is present, use its value instead + When( + Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False), + then=F("portfolio__federal_agency__federal_type"), + ), + # Otherwise, return federal type from federal agency + default=F("federal_agency__federal_type"), + ), + ) + def get_queryset(self, request): """Custom get_queryset to filter by portfolio if portfolio is in the request params.""" @@ -3183,8 +3460,39 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): if portfolio_id: # Further filter the queryset by the portfolio qs = qs.filter(portfolio=portfolio_id) + # Check if user is in OMB analysts group + if request.user.groups.filter(name="omb_analysts_group").exists(): + annotated_qs = self.get_annotated_queryset(qs) + return annotated_qs.filter( + conv_generic_org_type=DomainRequest.OrganizationChoices.FEDERAL, + conv_federal_type=BranchChoices.EXECUTIVE, + ) return qs + def has_view_permission(self, request, obj=None): + """Restrict view permissions based on group membership and model attributes.""" + if request.user.has_perm("registrar.full_access_permission"): + return True + if obj: + if request.user.groups.filter(name="omb_analysts_group").exists(): + return ( + obj.converted_generic_org_type == DomainRequest.OrganizationChoices.FEDERAL + and obj.converted_federal_type == BranchChoices.EXECUTIVE + ) + return super().has_view_permission(request, obj) + + def has_change_permission(self, request, obj=None): + """Restrict update permissions based on group membership and model attributes.""" + if request.user.has_perm("registrar.full_access_permission"): + return True + if obj: + if request.user.groups.filter(name="omb_analysts_group").exists(): + return ( + obj.converted_generic_org_type == DomainRequest.OrganizationChoices.FEDERAL + and obj.converted_federal_type == BranchChoices.EXECUTIVE + ) + return super().has_change_permission(request, obj) + def get_search_results(self, request, queryset, search_term): # Call the parent's method to apply default search logic base_queryset, use_distinct = super().get_search_results(request, queryset, search_term) @@ -3204,6 +3512,15 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): return combined_queryset, use_distinct + def get_form(self, request, obj=None, **kwargs): + """Pass the 'is_omb_analyst' attribute to the form.""" + form = super().get_form(request, obj, **kwargs) + + # Store attribute in the form for template access + form.show_contact_as_plain_text = request.user.groups.filter(name="omb_analysts_group").exists() + + return form + class TransitionDomainAdmin(ListHeaderAdmin): """Custom transition domain admin class.""" @@ -3235,6 +3552,16 @@ class DomainInformationInline(admin.StackedInline): template = "django/admin/includes/domain_info_inline_stacked.html" model = models.DomainInformation + def __init__(self, *args, **kwargs): + """Initialize the admin class and define a default value for is_omb_analyst.""" + super().__init__(*args, **kwargs) + self.is_omb_analyst = False # Default value in case it's accessed before being set + + def get_queryset(self, request): + """Ensure self.is_omb_analyst is set early.""" + self.is_omb_analyst = request.user.groups.filter(name="omb_analysts_group").exists() + return super().get_queryset(request) + # Define methods to display fields from the related portfolio def portfolio_senior_official(self, obj) -> Optional[SeniorOfficial]: return obj.portfolio.senior_official if obj.portfolio and obj.portfolio.senior_official else None @@ -3302,6 +3629,7 @@ class DomainInformationInline(admin.StackedInline): fieldsets = copy.deepcopy(list(DomainInformationAdmin.fieldsets)) readonly_fields = copy.deepcopy(DomainInformationAdmin.readonly_fields) analyst_readonly_fields = copy.deepcopy(DomainInformationAdmin.analyst_readonly_fields) + omb_analyst_readonly_fields = copy.deepcopy(DomainInformationAdmin.omb_analyst_readonly_fields) autocomplete_fields = copy.deepcopy(DomainInformationAdmin.autocomplete_fields) def get_domain_managers(self, obj): @@ -3322,12 +3650,16 @@ class DomainInformationInline(admin.StackedInline): if not domain_managers: return "No domain managers found." - domain_manager_details = "
UID | Name |
---|
UID | " + domain_manager_details += "Name | ||
---|---|---|---|
{escape(domain_manager.username)}' + if not self.is_omb_analyst: + domain_manager_details += f' | {escape(domain_manager.username)}' domain_manager_details += f" | {escape(full_name)} | " domain_manager_details += f"{escape(domain_manager.email)} | " domain_manager_details += "
{% translate 'You don’t have permission to view or edit anything.' %}
{% endif %} diff --git a/src/registrar/templates/django/admin/domain_change_form.html b/src/registrar/templates/django/admin/domain_change_form.html index 7aa0034b9..2e6f57237 100644 --- a/src/registrar/templates/django/admin/domain_change_form.html +++ b/src/registrar/templates/django/admin/domain_change_form.html @@ -11,13 +11,15 @@ {% block field_sets %}No additional members found.
{% endif %}No
{% endif %} -Incomplete
{% elif domain_request.is_interagency_initiative %} @@ -112,8 +112,8 @@ {% if domain_request.working_with_eop is None %}Incomplete
{% elif domain_request.working_with_eop %} -{{domain_request.eop_contact.first_name}} {{domain_request.eop_contact.last_name}}
-{{domain_request.eop_contact.email}}
+{{domain_request.eop_stakeholder_first_name}} {{domain_request.eop_stakeholder_last_name}}
+{{domain_request.eop_stakeholder_email}}
{% else %}No
{% endif %} diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index 8fbf052a4..a3e2114f7 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -1010,6 +1010,27 @@ def create_user(**kwargs): return user +def create_omb_analyst_user(**kwargs): + """Creates a analyst user with is_staff=True and the group cisa_analysts_group""" + User = get_user_model() + p = "userpass" + user = User.objects.create_user( + username=kwargs.get("username", "ombanalystuser"), + email=kwargs.get("email", "ombanalyst@example.com"), + first_name=kwargs.get("first_name", "first"), + last_name=kwargs.get("last_name", "last"), + is_staff=kwargs.get("is_staff", True), + title=kwargs.get("title", "title"), + password=kwargs.get("password", p), + phone=kwargs.get("phone", "8003111234"), + ) + # Retrieve the group or create it if it doesn't exist + group, _ = UserGroup.objects.get_or_create(name="omb_analysts_group") + # Add the user to the group + user.groups.set([group]) + return user + + def create_test_user(): username = "test_user" first_name = "First" diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 1de6b1be3..8fd2744ec 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -3,6 +3,7 @@ from django.utils import timezone from django.test import TestCase, RequestFactory, Client from django.contrib.admin.sites import AdminSite from registrar import models +from registrar.utility.constants import BranchChoices from registrar.utility.email import EmailSendingError from registrar.utility.errors import MissingEmailError from waffle.testutils import override_flag @@ -57,6 +58,7 @@ from .common import ( MockDbForSharedTests, AuditedAdminMockData, completed_domain_request, + create_omb_analyst_user, create_test_user, generic_domain_object, less_console_noise, @@ -136,18 +138,25 @@ class TestDomainInvitationAdmin(WebTest): csrf_checks = False @classmethod - def setUpClass(self): + def setUpClass(cls): super().setUpClass() - self.site = AdminSite() - self.factory = RequestFactory() - self.superuser = create_superuser() + cls.site = AdminSite() + cls.factory = RequestFactory() def setUp(self): super().setUp() + self.superuser = create_superuser() + self.cisa_analyst = create_user() + self.omb_analyst = create_omb_analyst_user() self.admin = ListHeaderAdmin(model=DomainInvitationAdmin, admin_site=AdminSite()) self.domain = Domain.objects.create(name="example.com") + self.fed_agency = FederalAgency.objects.create( + agency="New FedExec Agency", federal_type=BranchChoices.EXECUTIVE + ) self.portfolio = Portfolio.objects.create(organization_name="new portfolio", creator=self.superuser) - DomainInformation.objects.create(domain=self.domain, portfolio=self.portfolio, creator=self.superuser) + self.domain_info = DomainInformation.objects.create( + domain=self.domain, portfolio=self.portfolio, creator=self.superuser + ) """Create a client object""" self.client = Client(HTTP_HOST="localhost:8080") self.client.force_login(self.superuser) @@ -159,10 +168,124 @@ class TestDomainInvitationAdmin(WebTest): DomainInvitation.objects.all().delete() DomainInformation.objects.all().delete() Portfolio.objects.all().delete() + self.fed_agency.delete() Domain.objects.all().delete() Contact.objects.all().delete() User.objects.all().delete() + @less_console_noise_decorator + def test_analyst_view(self): + """Ensure regular analysts can view domain invitations.""" + invitation = DomainInvitation.objects.create(email="test@example.com", domain=self.domain) + self.client.force_login(self.cisa_analyst) + response = self.client.get(reverse("admin:registrar_domaininvitation_changelist")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, invitation.email) + + @less_console_noise_decorator + def test_omb_analyst_view_non_feb_domain(self): + """Ensure OMB analysts cannot view non-federal domains.""" + invitation = DomainInvitation.objects.create(email="test@example.com", domain=self.domain) + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_domaininvitation_changelist")) + self.assertNotContains(response, invitation.email) + + @less_console_noise_decorator + def test_omb_analyst_view_feb_domain(self): + """Ensure OMB analysts can view federal executive branch domains.""" + invitation = DomainInvitation.objects.create(email="test@example.com", domain=self.domain) + self.portfolio.organization_type = DomainRequest.OrganizationChoices.FEDERAL + self.portfolio.federal_agency = self.fed_agency + self.portfolio.save() + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_domaininvitation_changelist")) + self.assertContains(response, invitation.email) + + @less_console_noise_decorator + def test_superuser_view(self): + """Ensure superusers can view domain invitations.""" + invitation = DomainInvitation.objects.create(email="test@example.com", domain=self.domain) + response = self.client.get(reverse("admin:registrar_domaininvitation_changelist")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, invitation.email) + + @less_console_noise_decorator + def test_analyst_change(self): + """Ensure regular analysts can view domain invitations but not update.""" + invitation = DomainInvitation.objects.create(email="test@example.com", domain=self.domain) + self.client.force_login(self.cisa_analyst) + response = self.client.get(reverse("admin:registrar_domaininvitation_change", args=[invitation.id])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, invitation.email) + # test whether fields are readonly or editable + self.assertNotContains(response, "id_domain") + self.assertNotContains(response, "id_email") + self.assertContains(response, "closelink") + self.assertNotContains(response, "Save") + self.assertNotContains(response, "Delete") + + @less_console_noise_decorator + def test_omb_analyst_change_non_feb_domain(self): + """Ensure OMB analysts cannot change non-federal domains.""" + invitation = DomainInvitation.objects.create(email="test@example.com", domain=self.domain) + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_domaininvitation_change", args=[invitation.id])) + self.assertEqual(response.status_code, 302) + + @less_console_noise_decorator + def test_omb_analyst_change_feb_domain(self): + """Ensure OMB analysts can view federal executive branch domains.""" + invitation = DomainInvitation.objects.create(email="test@example.com", domain=self.domain) + # update domain + self.portfolio.organization_type = DomainRequest.OrganizationChoices.FEDERAL + self.portfolio.federal_agency = self.fed_agency + self.portfolio.save() + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_domaininvitation_change", args=[invitation.id])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, invitation.email) + # test whether fields are readonly or editable + self.assertNotContains(response, "id_domain") + self.assertNotContains(response, "id_email") + self.assertContains(response, "closelink") + self.assertNotContains(response, "Save") + self.assertNotContains(response, "Delete") + + @less_console_noise_decorator + def test_superuser_change(self): + """Ensure superusers can change domain invitations.""" + invitation = DomainInvitation.objects.create(email="test@example.com", domain=self.domain) + response = self.client.get(reverse("admin:registrar_domaininvitation_change", args=[invitation.id])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, invitation.email) + # test whether fields are readonly or editable + self.assertContains(response, "id_domain") + self.assertContains(response, "id_email") + self.assertNotContains(response, "closelink") + self.assertContains(response, "Save") + self.assertContains(response, "Delete") + + @less_console_noise_decorator + def test_omb_analyst_filter_feb_domain(self): + """Ensure OMB analysts can apply filters and only federal executive branch domains show.""" + # create invitation on domain that is not FEB + invitation = DomainInvitation.objects.create(email="test@example.com", domain=self.domain) + self.client.force_login(self.omb_analyst) + response = self.client.get( + reverse("admin:registrar_domaininvitation_changelist"), + {"status": DomainInvitation.DomainInvitationStatus.INVITED}, + ) + self.assertNotContains(response, invitation.email) + # update domain + self.portfolio.organization_type = DomainRequest.OrganizationChoices.FEDERAL + self.portfolio.federal_agency = self.fed_agency + self.portfolio.save() + response = self.client.get( + reverse("admin:registrar_domaininvitation_changelist"), + {"status": DomainInvitation.DomainInvitationStatus.INVITED}, + ) + self.assertContains(response, invitation.email) + @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" @@ -1139,6 +1262,7 @@ class TestUserPortfolioPermissionAdmin(TestCase): self.client = Client(HTTP_HOST="localhost:8080") self.superuser = create_superuser() self.testuser = create_test_user() + self.omb_analyst = create_omb_analyst_user() self.portfolio = Portfolio.objects.create(organization_name="Test Portfolio", creator=self.superuser) def tearDown(self): @@ -1148,6 +1272,26 @@ class TestUserPortfolioPermissionAdmin(TestCase): User.objects.all().delete() UserPortfolioPermission.objects.all().delete() + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts cannot view user portfolio permissions list.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_userportfoliopermission_changelist")) + self.assertEqual(response.status_code, 403) + + @less_console_noise_decorator + def test_omb_analyst_change(self): + """Ensure OMB analysts cannot change user portfolio permission.""" + self.client.force_login(self.omb_analyst) + user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( + user=self.superuser, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + response = self.client.get( + "/admin/registrar/userportfoliopermission/{}/change/".format(user_portfolio_permission.pk), + follow=True, + ) + self.assertEqual(response.status_code, 403) + @less_console_noise_decorator def test_has_change_form_description(self): """Tests if this model has a model description on the change form view""" @@ -1204,6 +1348,7 @@ class TestPortfolioInvitationAdmin(TestCase): def setUp(self): """Create a client object""" self.client = Client(HTTP_HOST="localhost:8080") + self.omb_analyst = create_omb_analyst_user() self.portfolio = Portfolio.objects.create(organization_name="Test Portfolio", creator=self.superuser) def tearDown(self): @@ -1217,6 +1362,26 @@ class TestPortfolioInvitationAdmin(TestCase): def tearDownClass(self): User.objects.all().delete() + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts cannot view portfolio invitations list.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_portfolioinvitation_changelist")) + self.assertEqual(response.status_code, 403) + + @less_console_noise_decorator + def test_omb_analyst_change(self): + """Ensure OMB analysts cannot change portfolio invitation.""" + self.client.force_login(self.omb_analyst) + invitation, _ = PortfolioInvitation.objects.get_or_create( + email=self.superuser.email, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + response = self.client.get( + "/admin/registrar/portfolioinvitation/{}/change/".format(invitation.pk), + follow=True, + ) + self.assertEqual(response.status_code, 403) + @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" @@ -1791,6 +1956,8 @@ class TestHostAdmin(TestCase): cls.factory = RequestFactory() cls.admin = MyHostAdmin(model=Host, admin_site=cls.site) cls.superuser = create_superuser() + cls.staffuser = create_user() + cls.omb_analyst = create_omb_analyst_user() def setUp(self): """Setup environment for a mock admin user""" @@ -1806,6 +1973,20 @@ class TestHostAdmin(TestCase): def tearDownClass(cls): User.objects.all().delete() + @less_console_noise_decorator + def test_analyst_view(self): + """Ensure analysts cannot view hosts list.""" + self.client.force_login(self.staffuser) + response = self.client.get(reverse("admin:registrar_host_changelist")) + self.assertEqual(response.status_code, 403) + + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts cannot view hosts list.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_host_changelist")) + self.assertEqual(response.status_code, 403) + @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" @@ -1870,6 +2051,7 @@ class TestDomainInformationAdmin(TestCase): cls.admin = DomainInformationAdmin(model=DomainInformation, admin_site=cls.site) cls.superuser = create_superuser() cls.staffuser = create_user() + cls.omb_analyst = create_omb_analyst_user() cls.mock_data_generator = AuditedAdminMockData() cls.test_helper = GenericTestHelper( factory=cls.factory, @@ -1881,12 +2063,24 @@ class TestDomainInformationAdmin(TestCase): def setUp(self): self.client = Client(HTTP_HOST="localhost:8080") + self.nonfeddomain = Domain.objects.create(name="nonfeddomain.com") + self.feddomain = Domain.objects.create(name="feddomain.com") + self.fed_agency = FederalAgency.objects.create( + agency="New FedExec Agency", federal_type=BranchChoices.EXECUTIVE + ) + self.portfolio = Portfolio.objects.create(organization_name="new portfolio", creator=self.superuser) + self.domain_info = DomainInformation.objects.create( + domain=self.feddomain, portfolio=self.portfolio, creator=self.superuser + ) def tearDown(self): """Delete all Users, Domains, and UserDomainRoles""" DomainInformation.objects.all().delete() DomainRequest.objects.all().delete() Domain.objects.all().delete() + DomainInformation.objects.all().delete() + Portfolio.objects.all().delete() + self.fed_agency.delete() Contact.objects.all().delete() @classmethod @@ -1894,6 +2088,56 @@ class TestDomainInformationAdmin(TestCase): User.objects.all().delete() SeniorOfficial.objects.all().delete() + @less_console_noise_decorator + def test_analyst_view(self): + """Ensure regular analysts cannot view domain information list.""" + self.client.force_login(self.staffuser) + response = self.client.get(reverse("admin:registrar_domaininformation_changelist")) + self.assertEqual(response.status_code, 403) + + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts cannot view domain information list.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_domaininformation_changelist")) + self.assertEqual(response.status_code, 403) + + @less_console_noise_decorator + def test_superuser_view(self): + """Ensure superusers can view domain information list.""" + self.client.force_login(self.superuser) + response = self.client.get(reverse("admin:registrar_domaininformation_changelist")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.feddomain.name) + + @less_console_noise_decorator + def test_analyst_change(self): + """Ensure regular analysts cannot view/edit domain information directly.""" + self.client.force_login(self.staffuser) + response = self.client.get( + reverse("admin:registrar_domaininformation_change", args=[self.feddomain.domain_info.id]) + ) + self.assertEqual(response.status_code, 403) + + @less_console_noise_decorator + def test_omb_analyst_change(self): + """Ensure OMB analysts cannot view/edit domain information directly.""" + self.client.force_login(self.omb_analyst) + response = self.client.get( + reverse("admin:registrar_domaininformation_change", args=[self.feddomain.domain_info.id]) + ) + self.assertEqual(response.status_code, 403) + + @less_console_noise_decorator + def test_superuser_change(self): + """Ensure superusers can view/change domain information directly.""" + self.client.force_login(self.superuser) + response = self.client.get( + reverse("admin:registrar_domaininformation_change", args=[self.feddomain.domain_info.id]) + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.feddomain.name) + @less_console_noise_decorator def test_domain_information_senior_official_is_alphabetically_sorted(self): """Tests if the senior offical dropdown is alphanetically sorted in the django admin display""" @@ -2258,6 +2502,8 @@ class TestUserDomainRoleAdmin(WebTest): cls.factory = RequestFactory() cls.admin = UserDomainRoleAdmin(model=UserDomainRole, admin_site=cls.site) cls.superuser = create_superuser() + cls.staffuser = create_user() + cls.omb_analyst = create_omb_analyst_user() cls.test_helper = GenericTestHelper( factory=cls.factory, user=cls.superuser, @@ -2285,6 +2531,31 @@ class TestUserDomainRoleAdmin(WebTest): super().tearDownClass() User.objects.all().delete() + @less_console_noise_decorator + def test_analyst_view(self): + """Ensure analysts cannot view user domain roles list.""" + self.client.force_login(self.staffuser) + response = self.client.get(reverse("admin:registrar_userdomainrole_changelist")) + self.assertEqual(response.status_code, 200) + + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts cannot view user domain roles list.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_userdomainrole_changelist")) + self.assertEqual(response.status_code, 403) + + @less_console_noise_decorator + def test_omb_analyst_change(self): + """Ensure OMB analysts cannot view/edit user domain roles list.""" + domain, _ = Domain.objects.get_or_create(name="anyrandomdomain.com") + user_domain_role, _ = UserDomainRole.objects.get_or_create( + user=self.superuser, domain=domain, role=[UserDomainRole.Roles.MANAGER] + ) + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_userdomainrole_change", args=[user_domain_role.id])) + self.assertEqual(response.status_code, 403) + @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" @@ -2580,6 +2851,7 @@ class TestMyUserAdmin(MockDbForSharedTests, WebTest): cls.admin = MyUserAdmin(model=get_user_model(), admin_site=admin_site) cls.superuser = create_superuser() cls.staffuser = create_user() + cls.omb_analyst = create_omb_analyst_user() cls.test_helper = GenericTestHelper(admin=cls.admin) def setUp(self): @@ -2596,6 +2868,13 @@ class TestMyUserAdmin(MockDbForSharedTests, WebTest): super().tearDownClass() User.objects.all().delete() + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts cannot view users list.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_user_changelist")) + self.assertEqual(response.status_code, 403) + @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" @@ -3221,6 +3500,7 @@ class TestContactAdmin(TestCase): cls.admin = ContactAdmin(model=Contact, admin_site=None) cls.superuser = create_superuser() cls.staffuser = create_user() + cls.omb_analyst = create_omb_analyst_user() def setUp(self): super().setUp() @@ -3236,6 +3516,13 @@ class TestContactAdmin(TestCase): super().tearDownClass() User.objects.all().delete() + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts cannot view contact list.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_contact_changelist")) + self.assertEqual(response.status_code, 403) + @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" @@ -3282,6 +3569,7 @@ class TestVerifiedByStaffAdmin(TestCase): super().setUpClass() cls.site = AdminSite() cls.superuser = create_superuser() + cls.omb_analyst = create_omb_analyst_user() cls.admin = VerifiedByStaffAdmin(model=VerifiedByStaff, admin_site=cls.site) cls.factory = RequestFactory() cls.test_helper = GenericTestHelper(admin=cls.admin) @@ -3299,18 +3587,20 @@ class TestVerifiedByStaffAdmin(TestCase): super().tearDownClass() User.objects.all().delete() + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts cannot view verified by staff list.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_verifiedbystaff_changelist")) + self.assertEqual(response.status_code, 403) + @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" self.client.force_login(self.superuser) - response = self.client.get( - "/admin/registrar/verifiedbystaff/", - follow=True, - ) - + response = self.client.get(reverse("admin:registrar_verifiedbystaff_changelist")) # 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" @@ -3365,6 +3655,7 @@ class TestWebsiteAdmin(TestCase): super().setUp() self.site = AdminSite() self.superuser = create_superuser() + self.omb_analyst = create_omb_analyst_user() self.admin = WebsiteAdmin(model=Website, admin_site=self.site) self.factory = RequestFactory() self.client = Client(HTTP_HOST="localhost:8080") @@ -3375,15 +3666,18 @@ class TestWebsiteAdmin(TestCase): Website.objects.all().delete() User.objects.all().delete() + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts cannot view website list.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_website_changelist")) + self.assertEqual(response.status_code, 403) + @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" self.client.force_login(self.superuser) - response = self.client.get( - "/admin/registrar/website/", - follow=True, - ) - + response = self.client.get(reverse("admin:registrar_website_changelist")) # Make sure that the page is loaded correctly self.assertEqual(response.status_code, 200) @@ -3392,13 +3686,14 @@ class TestWebsiteAdmin(TestCase): self.assertContains(response, "Show more") -class TestDraftDomain(TestCase): +class TestDraftDomainAdmin(TestCase): @classmethod def setUpClass(cls): super().setUpClass() cls.site = AdminSite() cls.superuser = create_superuser() + cls.omb_analyst = create_omb_analyst_user() cls.admin = DraftDomainAdmin(model=DraftDomain, admin_site=cls.site) cls.factory = RequestFactory() cls.test_helper = GenericTestHelper(admin=cls.admin) @@ -3416,15 +3711,18 @@ class TestDraftDomain(TestCase): super().tearDownClass() User.objects.all().delete() + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts cannot view draft domain list.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_draftdomain_changelist")) + self.assertEqual(response.status_code, 403) + @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" self.client.force_login(self.superuser) - response = self.client.get( - "/admin/registrar/draftdomain/", - follow=True, - ) - + response = self.client.get(reverse("admin:registrar_draftdomain_changelist")) # Make sure that the page is loaded correctly self.assertEqual(response.status_code, 200) @@ -3435,13 +3733,21 @@ class TestDraftDomain(TestCase): self.assertContains(response, "Show more") -class TestFederalAgency(TestCase): +class TestFederalAgencyAdmin(TestCase): @classmethod def setUpClass(cls): super().setUpClass() cls.site = AdminSite() cls.superuser = create_superuser() + cls.staffuser = create_user() + cls.omb_analyst = create_omb_analyst_user() + cls.non_feb_agency = FederalAgency.objects.create( + agency="Fake judicial agency", federal_type=BranchChoices.JUDICIAL + ) + cls.feb_agency = FederalAgency.objects.create( + agency="Fake executive agency", federal_type=BranchChoices.EXECUTIVE + ) cls.admin = FederalAgencyAdmin(model=FederalAgency, admin_site=cls.site) cls.factory = RequestFactory() cls.test_helper = GenericTestHelper(admin=cls.admin) @@ -3454,6 +3760,100 @@ class TestFederalAgency(TestCase): super().tearDownClass() User.objects.all().delete() + @less_console_noise_decorator + def test_analyst_view(self): + """Ensure regular analysts can view federal agencies.""" + self.client.force_login(self.staffuser) + response = self.client.get(reverse("admin:registrar_federalagency_changelist")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.non_feb_agency.agency) + self.assertContains(response, self.feb_agency.agency) + + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts can view FEB agencies but not other branches.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_federalagency_changelist")) + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, self.non_feb_agency.agency) + self.assertContains(response, self.feb_agency.agency) + + @less_console_noise_decorator + def test_superuser_view(self): + """Ensure superusers can view domain invitations.""" + self.client.force_login(self.superuser) + response = self.client.get(reverse("admin:registrar_federalagency_changelist")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.non_feb_agency.agency) + self.assertContains(response, self.feb_agency.agency) + + @less_console_noise_decorator + def test_analyst_change(self): + """Ensure regular analysts can view/edit federal agencies list.""" + self.client.force_login(self.staffuser) + response = self.client.get(reverse("admin:registrar_federalagency_change", args=[self.non_feb_agency.id])) + self.assertEqual(response.status_code, 200) + response = self.client.get(reverse("admin:registrar_federalagency_change", args=[self.feb_agency.id])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.feb_agency.agency) + # test whether fields are readonly or editable + self.assertContains(response, "id_agency") + self.assertContains(response, "id_federal_type") + self.assertContains(response, "id_acronym") + self.assertContains(response, "id_is_fceb") + self.assertNotContains(response, "closelink") + self.assertContains(response, "Save") + self.assertContains(response, "Delete") + + @less_console_noise_decorator + def test_omb_analyst_change(self): + """Ensure OMB analysts can change FEB agencies but not others.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_federalagency_change", args=[self.non_feb_agency.id])) + self.assertEqual(response.status_code, 302) + response = self.client.get(reverse("admin:registrar_federalagency_change", args=[self.feb_agency.id])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.feb_agency.agency) + # test whether fields are readonly or editable + self.assertNotContains(response, "id_agency") + self.assertNotContains(response, "id_federal_type") + self.assertNotContains(response, "id_acronym") + self.assertNotContains(response, "id_is_fceb") + self.assertContains(response, "closelink") + self.assertNotContains(response, "Save") + self.assertNotContains(response, "Delete") + + @less_console_noise_decorator + def test_superuser_change(self): + """Ensure superusers can change all federal agencies.""" + self.client.force_login(self.superuser) + response = self.client.get(reverse("admin:registrar_federalagency_change", args=[self.non_feb_agency.id])) + self.assertEqual(response.status_code, 200) + response = self.client.get(reverse("admin:registrar_federalagency_change", args=[self.feb_agency.id])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.feb_agency.agency) + # test whether fields are readonly or editable + self.assertContains(response, "id_agency") + self.assertContains(response, "id_federal_type") + self.assertContains(response, "id_acronym") + self.assertContains(response, "id_is_fceb") + self.assertNotContains(response, "closelink") + self.assertContains(response, "Save") + self.assertContains(response, "Delete") + + @less_console_noise_decorator + def test_omb_analyst_filter_feb_agencies(self): + """Ensure OMB analysts can apply filters and only federal agencies show.""" + self.client.force_login(self.omb_analyst) + # in setup, created two agencies: Fake judicial agency and Fake executive agency + # only executive agency should show up with the search for 'fake' + response = self.client.get( + reverse("admin:registrar_federalagency_changelist"), + data={"q": "fake"}, + ) + self.assertNotContains(response, self.non_feb_agency.agency) + self.assertContains(response, self.feb_agency.agency) + @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" @@ -3471,11 +3871,12 @@ class TestFederalAgency(TestCase): self.assertContains(response, "Show more") -class TestPublicContact(TestCase): +class TestPublicContactAdmin(TestCase): def setUp(self): super().setUp() self.site = AdminSite() self.superuser = create_superuser() + self.omb_analyst = create_omb_analyst_user() self.admin = PublicContactAdmin(model=PublicContact, admin_site=self.site) self.factory = RequestFactory() self.client = Client(HTTP_HOST="localhost:8080") @@ -3486,16 +3887,19 @@ class TestPublicContact(TestCase): PublicContact.objects.all().delete() User.objects.all().delete() + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts cannot view public contact list.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_publiccontact_changelist")) + self.assertEqual(response.status_code, 403) + @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, - ) - + response = self.client.get(reverse("admin:registrar_publiccontact_changelist")) # Make sure that the page is loaded correctly self.assertEqual(response.status_code, 200) @@ -3504,11 +3908,12 @@ class TestPublicContact(TestCase): self.assertContains(response, "Show more") -class TestTransitionDomain(TestCase): +class TestTransitionDomainAdmin(TestCase): def setUp(self): super().setUp() self.site = AdminSite() self.superuser = create_superuser() + self.omb_analyst = create_omb_analyst_user() self.admin = TransitionDomainAdmin(model=TransitionDomain, admin_site=self.site) self.factory = RequestFactory() self.client = Client(HTTP_HOST="localhost:8080") @@ -3519,15 +3924,18 @@ class TestTransitionDomain(TestCase): PublicContact.objects.all().delete() User.objects.all().delete() + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts cannot view transition domain list.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_transitiondomain_changelist")) + self.assertEqual(response.status_code, 403) + @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" self.client.force_login(self.superuser) - response = self.client.get( - "/admin/registrar/transitiondomain/", - follow=True, - ) - + response = self.client.get(reverse("admin:registrar_transitiondomain_changelist")) # Make sure that the page is loaded correctly self.assertEqual(response.status_code, 200) @@ -3536,11 +3944,12 @@ class TestTransitionDomain(TestCase): self.assertContains(response, "Show more") -class TestUserGroup(TestCase): +class TestUserGroupAdmin(TestCase): def setUp(self): super().setUp() self.site = AdminSite() self.superuser = create_superuser() + self.omb_analyst = create_omb_analyst_user() self.admin = UserGroupAdmin(model=UserGroup, admin_site=self.site) self.factory = RequestFactory() self.client = Client(HTTP_HOST="localhost:8080") @@ -3550,15 +3959,18 @@ class TestUserGroup(TestCase): super().tearDown() User.objects.all().delete() + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts cannot view user group list.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_usergroup_changelist")) + self.assertEqual(response.status_code, 403) + @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" self.client.force_login(self.superuser) - response = self.client.get( - "/admin/registrar/usergroup/", - follow=True, - ) - + response = self.client.get(reverse("admin:registrar_usergroup_changelist")) # Make sure that the page is loaded correctly self.assertEqual(response.status_code, 200) @@ -3575,12 +3987,23 @@ class TestPortfolioAdmin(TestCase): super().setUpClass() cls.site = AdminSite() cls.superuser = create_superuser() + cls.staffuser = create_user() + cls.omb_analyst = create_omb_analyst_user() cls.admin = PortfolioAdmin(model=Portfolio, admin_site=cls.site) cls.factory = RequestFactory() def setUp(self): self.client = Client(HTTP_HOST="localhost:8080") - self.portfolio = Portfolio.objects.create(organization_name="Test Portfolio", creator=self.superuser) + self.portfolio = Portfolio.objects.create(organization_name="Test portfolio", creator=self.superuser) + self.feb_agency = FederalAgency.objects.create( + agency="Test FedExec Agency", federal_type=BranchChoices.EXECUTIVE + ) + self.feb_portfolio = Portfolio.objects.create( + organization_name="Test FEB portfolio", + creator=self.superuser, + federal_agency=self.feb_agency, + organization_type=DomainRequest.OrganizationChoices.FEDERAL, + ) def tearDown(self): Suborganization.objects.all().delete() @@ -3588,8 +4011,118 @@ class TestPortfolioAdmin(TestCase): DomainRequest.objects.all().delete() Domain.objects.all().delete() Portfolio.objects.all().delete() + self.feb_agency.delete() User.objects.all().delete() + @less_console_noise_decorator + def test_analyst_view(self): + """Ensure regular analysts can view portfolios.""" + self.client.force_login(self.staffuser) + response = self.client.get(reverse("admin:registrar_portfolio_changelist")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.portfolio.organization_name) + self.assertContains(response, self.feb_portfolio.organization_name) + + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts can view FEB portfolios but not others.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_portfolio_changelist")) + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, self.portfolio.organization_name) + self.assertContains(response, self.feb_portfolio.organization_name) + + @less_console_noise_decorator + def test_superuser_view(self): + """Ensure superusers can view portfolios.""" + self.client.force_login(self.superuser) + response = self.client.get(reverse("admin:registrar_portfolio_changelist")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.portfolio.organization_name) + self.assertContains(response, self.feb_portfolio.organization_name) + + @less_console_noise_decorator + def test_analyst_change(self): + """Ensure regular analysts can view/edit portfolios.""" + self.client.force_login(self.staffuser) + response = self.client.get(reverse("admin:registrar_portfolio_change", args=[self.portfolio.id])) + self.assertEqual(response.status_code, 200) + response = self.client.get(reverse("admin:registrar_portfolio_change", args=[self.feb_portfolio.id])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.feb_portfolio.organization_name) + # test whether fields are readonly or editable + self.assertContains(response, "id_organization_name") + self.assertContains(response, "id_notes") + self.assertContains(response, "id_organization_type") + self.assertContains(response, "id_state_territory") + self.assertContains(response, "id_address_line1") + self.assertContains(response, "id_address_line2") + self.assertContains(response, "id_city") + self.assertContains(response, "id_zipcode") + self.assertContains(response, "id_urbanization") + self.assertNotContains(response, "closelink") + self.assertContains(response, "Save") + self.assertContains(response, "Delete") + + @less_console_noise_decorator + def test_omb_analyst_change(self): + """Ensure OMB analysts can change FEB portfolios but not others.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_portfolio_change", args=[self.portfolio.id])) + self.assertEqual(response.status_code, 302) + response = self.client.get(reverse("admin:registrar_portfolio_change", args=[self.feb_portfolio.id])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.feb_portfolio.organization_name) + # test whether fields are readonly or editable + self.assertNotContains(response, "id_organization_name") + self.assertNotContains(response, "id_notes") + self.assertNotContains(response, "id_organization_type") + self.assertNotContains(response, "id_state_territory") + self.assertNotContains(response, "id_address_line1") + self.assertNotContains(response, "id_address_line2") + self.assertNotContains(response, "id_city") + self.assertNotContains(response, "id_zipcode") + self.assertNotContains(response, "id_urbanization") + self.assertContains(response, "closelink") + self.assertNotContains(response, "Save") + self.assertNotContains(response, "Delete") + + @less_console_noise_decorator + def test_superuser_change(self): + """Ensure superusers can change all portfolios.""" + self.client.force_login(self.superuser) + response = self.client.get(reverse("admin:registrar_portfolio_change", args=[self.portfolio.id])) + self.assertEqual(response.status_code, 200) + response = self.client.get(reverse("admin:registrar_portfolio_change", args=[self.feb_portfolio.id])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.feb_portfolio.organization_name) + # test whether fields are readonly or editable + self.assertContains(response, "id_organization_name") + self.assertContains(response, "id_notes") + self.assertContains(response, "id_organization_type") + self.assertContains(response, "id_state_territory") + self.assertContains(response, "id_address_line1") + self.assertContains(response, "id_address_line2") + self.assertContains(response, "id_city") + self.assertContains(response, "id_zipcode") + self.assertContains(response, "id_urbanization") + self.assertNotContains(response, "closelink") + self.assertContains(response, "Save") + self.assertContains(response, "Delete") + + @less_console_noise_decorator + def test_omb_analyst_filter_feb_portfolios(self): + """Ensure OMB analysts can apply filters and only feb portfolios show.""" + self.client.force_login(self.omb_analyst) + # in setup, created two portfolios: Test portfolio and Test FEB portfolio + # only executive portfolio should show up with the search for 'portfolio' + response = self.client.get( + reverse("admin:registrar_portfolio_changelist"), + data={"q": "test"}, + ) + self.assertNotContains(response, self.portfolio.organization_name) + self.assertContains(response, self.feb_portfolio.organization_name) + @less_console_noise_decorator def test_created_on_display(self): """Tests the custom created on which is a reskin of the created_at field""" @@ -3777,6 +4310,7 @@ class TestTransferUser(WebTest): super().setUpClass() cls.site = AdminSite() cls.superuser = create_superuser() + cls.omb_analyst = create_omb_analyst_user() cls.admin = PortfolioAdmin(model=Portfolio, admin_site=cls.site) cls.factory = RequestFactory() @@ -3797,6 +4331,13 @@ class TestTransferUser(WebTest): Portfolio.objects.all().delete() UserDomainRole.objects.all().delete() + @less_console_noise_decorator + def test_omb_analyst(self): + """Ensure OMB analysts cannot view transfer_user.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("transfer_user", args=[self.user1.pk])) + self.assertEqual(response.status_code, 403) + @less_console_noise_decorator def test_transfer_user_shows_current_and_selected_user_information(self): """Assert we pull the current user info and display it on the transfer page""" diff --git a/src/registrar/tests/test_admin_domain.py b/src/registrar/tests/test_admin_domain.py index 969d043d7..aa6e799bd 100644 --- a/src/registrar/tests/test_admin_domain.py +++ b/src/registrar/tests/test_admin_domain.py @@ -17,14 +17,17 @@ from registrar.models import ( Host, Portfolio, ) +from registrar.models.federal_agency import FederalAgency from registrar.models.public_contact import PublicContact from registrar.models.user_domain_role import UserDomainRole +from registrar.utility.constants import BranchChoices from .common import ( MockSESClient, completed_domain_request, less_console_noise, create_superuser, create_user, + create_omb_analyst_user, create_ready_domain, MockEppLib, GenericTestHelper, @@ -48,7 +51,9 @@ class TestDomainAdminAsStaff(MockEppLib): @classmethod def setUpClass(self): super().setUpClass() + self.superuser = create_superuser() self.staffuser = create_user() + self.omb_analyst = create_omb_analyst_user() self.site = AdminSite() self.admin = DomainAdmin(model=Domain, admin_site=self.site) self.factory = RequestFactory() @@ -56,6 +61,24 @@ class TestDomainAdminAsStaff(MockEppLib): def setUp(self): self.client = Client(HTTP_HOST="localhost:8080") self.client.force_login(self.staffuser) + self.nonfebdomain = Domain.objects.create(name="nonfebexample.com") + self.febdomain = Domain.objects.create(name="febexample.com", state=Domain.State.READY) + self.fed_agency = FederalAgency.objects.create( + agency="New FedExec Agency", federal_type=BranchChoices.EXECUTIVE + ) + self.portfolio = Portfolio.objects.create( + organization_name="new portfolio", + organization_type=DomainRequest.OrganizationChoices.FEDERAL, + federal_agency=self.fed_agency, + creator=self.staffuser, + ) + self.domain_info = DomainInformation.objects.create( + domain=self.febdomain, portfolio=self.portfolio, creator=self.staffuser + ) + self.nonfebportfolio = Portfolio.objects.create( + organization_name="non feb portfolio", + creator=self.staffuser, + ) super().setUp() def tearDown(self): @@ -65,12 +88,134 @@ class TestDomainAdminAsStaff(MockEppLib): Domain.objects.all().delete() DomainInformation.objects.all().delete() DomainRequest.objects.all().delete() + Portfolio.objects.all().delete() + self.fed_agency.delete() @classmethod def tearDownClass(self): User.objects.all().delete() super().tearDownClass() + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts can view domain list.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_domain_changelist")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.febdomain.name) + self.assertNotContains(response, self.nonfebdomain.name) + self.assertNotContains(response, ">Import<") + self.assertNotContains(response, ">Export<") + + @less_console_noise_decorator + def test_omb_analyst_change(self): + """Ensure OMB analysts can view/edit federal executive branch domains.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_domain_change", args=[self.nonfebdomain.id])) + self.assertEqual(response.status_code, 302) + response = self.client.get(reverse("admin:registrar_domain_change", args=[self.febdomain.id])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.febdomain.name) + # test portfolio dropdown + self.assertContains(response, self.portfolio.organization_name) + self.assertNotContains(response, self.nonfebportfolio.organization_name) + # test buttons + self.assertNotContains(response, "Manage domain") + self.assertNotContains(response, "Get registry status") + self.assertNotContains(response, "Extend expiration date") + self.assertNotContains(response, "Remove from registry") + self.assertContains(response, "Place hold") + self.assertContains(response, "Save") + self.assertNotContains(response, ">Delete<") + # test whether fields are readonly or editable + self.assertNotContains(response, "id_domain_info-0-portfolio") + self.assertNotContains(response, "id_domain_info-0-sub_organization") + self.assertNotContains(response, "id_domain_info-0-creator") + self.assertNotContains(response, "id_domain_info-0-federal_agency") + self.assertNotContains(response, "id_domain_info-0-about_your_organization") + self.assertNotContains(response, "id_domain_info-0-anything_else") + self.assertNotContains(response, "id_domain_info-0-cisa_representative_first_name") + self.assertNotContains(response, "id_domain_info-0-cisa_representative_last_name") + self.assertNotContains(response, "id_domain_info-0-cisa_representative_email") + self.assertNotContains(response, "id_domain_info-0-domain_request") + self.assertNotContains(response, "id_domain_info-0-notes") + self.assertNotContains(response, "id_domain_info-0-senior_official") + self.assertNotContains(response, "id_domain_info-0-organization_type") + self.assertNotContains(response, "id_domain_info-0-state_territory") + self.assertNotContains(response, "id_domain_info-0-address_line1") + self.assertNotContains(response, "id_domain_info-0-address_line2") + self.assertNotContains(response, "id_domain_info-0-city") + self.assertNotContains(response, "id_domain_info-0-zipcode") + self.assertNotContains(response, "id_domain_info-0-urbanization") + self.assertNotContains(response, "id_domain_info-0-portfolio_organization_type") + self.assertNotContains(response, "id_domain_info-0-portfolio_federal_type") + self.assertNotContains(response, "id_domain_info-0-portfolio_organization_name") + self.assertNotContains(response, "id_domain_info-0-portfolio_federal_agency") + self.assertNotContains(response, "id_domain_info-0-portfolio_state_territory") + self.assertNotContains(response, "id_domain_info-0-portfolio_address_line1") + self.assertNotContains(response, "id_domain_info-0-portfolio_address_line2") + self.assertNotContains(response, "id_domain_info-0-portfolio_city") + self.assertNotContains(response, "id_domain_info-0-portfolio_zipcode") + self.assertNotContains(response, "id_domain_info-0-portfolio_urbanization") + self.assertNotContains(response, "id_domain_info-0-organization_type") + self.assertNotContains(response, "id_domain_info-0-federal_type") + self.assertNotContains(response, "id_domain_info-0-federal_agency") + self.assertNotContains(response, "id_domain_info-0-tribe_name") + self.assertNotContains(response, "id_domain_info-0-federally_recognized_tribe") + self.assertNotContains(response, "id_domain_info-0-state_recognized_tribe") + self.assertNotContains(response, "id_domain_info-0-about_your_organization") + self.assertNotContains(response, "id_domain_info-0-portfolio") + self.assertNotContains(response, "id_domain_info-0-sub_organization") + + @less_console_noise_decorator + def test_superuser_change(self): + """Ensure super user can view/edit all domains.""" + self.client.force_login(self.superuser) + response = self.client.get(reverse("admin:registrar_domain_change", args=[self.nonfebdomain.id])) + self.assertEqual(response.status_code, 200) + response = self.client.get(reverse("admin:registrar_domain_change", args=[self.febdomain.id])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.febdomain.name) + # test portfolio dropdown + self.assertContains(response, self.portfolio.organization_name) + # test buttons + self.assertContains(response, "Manage domain") + self.assertContains(response, "Get registry status") + self.assertContains(response, "Extend expiration date") + self.assertContains(response, "Remove from registry") + self.assertContains(response, "Place hold") + self.assertContains(response, "Save") + self.assertContains(response, ">Delete<") + # test whether fields are readonly or editable + self.assertContains(response, "id_domain_info-0-portfolio") + self.assertContains(response, "id_domain_info-0-sub_organization") + self.assertContains(response, "id_domain_info-0-creator") + self.assertContains(response, "id_domain_info-0-federal_agency") + self.assertContains(response, "id_domain_info-0-about_your_organization") + self.assertContains(response, "id_domain_info-0-anything_else") + self.assertContains(response, "id_domain_info-0-cisa_representative_first_name") + self.assertContains(response, "id_domain_info-0-cisa_representative_last_name") + self.assertContains(response, "id_domain_info-0-cisa_representative_email") + self.assertContains(response, "id_domain_info-0-domain_request") + self.assertContains(response, "id_domain_info-0-notes") + self.assertContains(response, "id_domain_info-0-senior_official") + self.assertContains(response, "id_domain_info-0-organization_type") + self.assertContains(response, "id_domain_info-0-state_territory") + self.assertContains(response, "id_domain_info-0-address_line1") + self.assertContains(response, "id_domain_info-0-address_line2") + self.assertContains(response, "id_domain_info-0-city") + self.assertContains(response, "id_domain_info-0-zipcode") + self.assertContains(response, "id_domain_info-0-urbanization") + self.assertContains(response, "id_domain_info-0-organization_type") + self.assertContains(response, "id_domain_info-0-federal_type") + self.assertContains(response, "id_domain_info-0-federal_agency") + self.assertContains(response, "id_domain_info-0-tribe_name") + self.assertContains(response, "id_domain_info-0-federally_recognized_tribe") + self.assertContains(response, "id_domain_info-0-state_recognized_tribe") + self.assertContains(response, "id_domain_info-0-about_your_organization") + self.assertContains(response, "id_domain_info-0-portfolio") + self.assertContains(response, "id_domain_info-0-sub_organization") + @less_console_noise_decorator def test_staff_can_see_cisa_region_federal(self): """Tests if staff can see CISA Region: N/A""" diff --git a/src/registrar/tests/test_admin_request.py b/src/registrar/tests/test_admin_request.py index 7bc150326..e6ad2ef3e 100644 --- a/src/registrar/tests/test_admin_request.py +++ b/src/registrar/tests/test_admin_request.py @@ -1,6 +1,8 @@ from datetime import datetime from django.forms import ValidationError from django.utils import timezone +from registrar.models.federal_agency import FederalAgency +from registrar.utility.constants import BranchChoices from waffle.testutils import override_flag import re from django.test import RequestFactory, Client, TestCase, override_settings @@ -37,6 +39,7 @@ from .common import ( less_console_noise, create_superuser, create_user, + create_omb_analyst_user, multiple_unalphabetical_domain_objects, MockEppLib, GenericTestHelper, @@ -68,6 +71,7 @@ class TestDomainRequestAdmin(MockEppLib): self.admin = DomainRequestAdmin(model=DomainRequest, admin_site=self.site) self.superuser = create_superuser() self.staffuser = create_user() + self.ombanalyst = create_omb_analyst_user() self.client = Client(HTTP_HOST="localhost:8080") self.test_helper = GenericTestHelper( factory=self.factory, @@ -80,6 +84,12 @@ class TestDomainRequestAdmin(MockEppLib): allowed_emails = [AllowedEmail(email="mayor@igorville.gov"), AllowedEmail(email="help@get.gov")] AllowedEmail.objects.bulk_create(allowed_emails) + def setUp(self): + super().setUp() + self.fed_agency = FederalAgency.objects.create( + agency="New FedExec Agency", federal_type=BranchChoices.EXECUTIVE + ) + def tearDown(self): super().tearDown() Host.objects.all().delete() @@ -92,6 +102,7 @@ class TestDomainRequestAdmin(MockEppLib): SeniorOfficial.objects.all().delete() Suborganization.objects.all().delete() Portfolio.objects.all().delete() + self.fed_agency.delete() self.mock_client.EMAILS_SENT.clear() @classmethod @@ -100,6 +111,71 @@ class TestDomainRequestAdmin(MockEppLib): User.objects.all().delete() AllowedEmail.objects.all().delete() + @override_flag("organization_feature", active=True) + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts can view domain request list.""" + febportfolio = Portfolio.objects.create( + organization_name="new portfolio", + organization_type=DomainRequest.OrganizationChoices.FEDERAL, + federal_agency=self.fed_agency, + creator=self.ombanalyst, + ) + nonfebportfolio = Portfolio.objects.create( + organization_name="non feb portfolio", + creator=self.ombanalyst, + ) + nonfebdomainrequest = completed_domain_request( + name="test1234nonfeb.gov", + portfolio=nonfebportfolio, + status=DomainRequest.DomainRequestStatus.SUBMITTED, + ) + febdomainrequest = completed_domain_request( + name="test1234feb.gov", + portfolio=febportfolio, + status=DomainRequest.DomainRequestStatus.SUBMITTED, + ) + self.client.force_login(self.ombanalyst) + response = self.client.get(reverse("admin:registrar_domainrequest_changelist")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, febdomainrequest.requested_domain.name) + self.assertNotContains(response, nonfebdomainrequest.requested_domain.name) + self.assertNotContains(response, ">Import<") + self.assertNotContains(response, ">Export<") + + @less_console_noise_decorator + def test_omb_analyst_change(self): + """Ensure OMB analysts can view/edit federal executive branch domain requests.""" + self.client.force_login(self.ombanalyst) + febportfolio = Portfolio.objects.create( + organization_name="new portfolio", + organization_type=DomainRequest.OrganizationChoices.FEDERAL, + federal_agency=self.fed_agency, + creator=self.ombanalyst, + ) + nonfebportfolio = Portfolio.objects.create( + organization_name="non feb portfolio", + creator=self.ombanalyst, + ) + nonfebdomainrequest = completed_domain_request( + name="test1234nonfeb.gov", + portfolio=nonfebportfolio, + status=DomainRequest.DomainRequestStatus.SUBMITTED, + ) + febdomainrequest = completed_domain_request( + name="test1234feb.gov", + portfolio=febportfolio, + status=DomainRequest.DomainRequestStatus.SUBMITTED, + ) + response = self.client.get(reverse("admin:registrar_domainrequest_change", args=[nonfebdomainrequest.id])) + self.assertEqual(response.status_code, 302) + response = self.client.get(reverse("admin:registrar_domainrequest_change", args=[febdomainrequest.id])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, febdomainrequest.requested_domain.name) + # test buttons + self.assertContains(response, "Save") + self.assertNotContains(response, ">Delete<") + @override_flag("organization_feature", active=True) @less_console_noise_decorator def test_clean_validates_duplicate_suborganization(self): @@ -1985,7 +2061,9 @@ class TestDomainRequestAdmin(MockEppLib): "feb_naming_requirements_details", "feb_purpose_choice", "working_with_eop", - "eop_contact", + "eop_stakeholder_first_name", + "eop_stakeholder_last_name", + "eop_stakeholder_email", "purpose", "has_timeframe", "time_frame_details", @@ -2074,6 +2152,86 @@ class TestDomainRequestAdmin(MockEppLib): self.assertEqual(readonly_fields, expected_fields) + def test_readonly_fields_for_omb_analyst(self): + with less_console_noise(): + request = self.factory.get("/") # Use the correct method and path + request.user = self.ombanalyst + + readonly_fields = self.admin.get_readonly_fields(request) + + expected_fields = [ + "portfolio_senior_official", + "portfolio_organization_type", + "portfolio_federal_type", + "portfolio_organization_name", + "portfolio_federal_agency", + "portfolio_state_territory", + "portfolio_address_line1", + "portfolio_address_line2", + "portfolio_city", + "portfolio_zipcode", + "portfolio_urbanization", + "other_contacts", + "current_websites", + "alternative_domains", + "is_election_board", + "status_history", + "federal_agency", + "creator", + "about_your_organization", + "requested_domain", + "approved_domain", + "alternative_domains", + "purpose", + "no_other_contacts_rationale", + "anything_else", + "is_policy_acknowledged", + "cisa_representative_first_name", + "cisa_representative_last_name", + "cisa_representative_email", + "status", + "investigator", + "notes", + "senior_official", + "organization_type", + "organization_name", + "state_territory", + "address_line1", + "address_line2", + "city", + "zipcode", + "urbanization", + "portfolio_organization_type", + "portfolio_federal_type", + "portfolio_organization_name", + "portfolio_federal_agency", + "portfolio_state_territory", + "portfolio_address_line1", + "portfolio_address_line2", + "portfolio_city", + "portfolio_zipcode", + "portfolio_urbanization", + "is_election_board", + "organization_type", + "federal_type", + "federal_agency", + "tribe_name", + "federally_recognized_tribe", + "state_recognized_tribe", + "about_your_organization", + "rejection_reason", + "rejection_reason_email", + "action_needed_reason", + "action_needed_reason_email", + "portfolio", + "sub_organization", + "requested_suborganization", + "suborganization_city", + "suborganization_state_territory", + ] + + self.assertEqual(readonly_fields, expected_fields) + def test_saving_when_restricted_creator(self): with less_console_noise(): # Create an instance of the model diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py index 9ec3bd0d3..236e810cf 100644 --- a/src/registrar/tests/test_reports.py +++ b/src/registrar/tests/test_reports.py @@ -72,7 +72,7 @@ class CsvReportsTest(MockDbForSharedTests): fake_open = mock_open() expected_file_content = [ call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), - call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), + call("cdomain11.gov,Federal,World War I Centennial Commission,,,,(blank)\r\n"), call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), @@ -94,7 +94,7 @@ class CsvReportsTest(MockDbForSharedTests): fake_open = mock_open() expected_file_content = [ call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), - call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), + call("cdomain11.gov,Federal,World War I Centennial Commission,,,,(blank)\r\n"), call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), @@ -261,9 +261,6 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive," "Portfolio 1 Federal Agency,Portfolio 1 Federal Agency,,, ,,(blank)," '"big_lebowski@dude.co, info@example.com, meoward@rocks.com",woofwardthethird@rocks.com\n' - "cdomain11.gov,Ready,2024-04-02,(blank),Federal - Executive," - "World War I Centennial Commission,,,, ,,(blank)," - "meoward@rocks.com,\n" "adomain10.gov,Ready,2024-04-03,(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),," "squeaker@rocks.com\n" "bdomain4.gov,Unknown,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" @@ -274,6 +271,9 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): "sdomain8.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" "xdomain7.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" "zdomain9.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" + "cdomain11.gov,Ready,2024-04-02,(blank),Federal," + "World War I Centennial Commission,,,, ,,(blank)," + "meoward@rocks.com,\n" "zdomain12.gov,Ready,2024-04-02,(blank),Interstate,,,,, ,,(blank),meoward@rocks.com,\n" ) @@ -498,7 +498,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # sorted alphabetially by domain name expected_content = ( "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" - "cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" + "cdomain11.gov,Federal,World War I Centennial Commission,,,,(blank)\n" "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n" "ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n" @@ -538,7 +538,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # sorted alphabetially by domain name expected_content = ( "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" - "cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" + "cdomain11.gov,Federal,World War I Centennial Commission,,,,(blank)\n" "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n" "ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n" @@ -594,7 +594,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): "State,Status,Expiration date, Deleted\n" "cdomain1.gov,Federal-Executive,Portfolio1FederalAgency,Portfolio1FederalAgency,Ready,(blank)\n" "adomain10.gov,Federal,ArmedForcesRetirementHome,Ready,(blank)\n" - "cdomain11.gov,Federal-Executive,WorldWarICentennialCommission,Ready,(blank)\n" + "cdomain11.gov,Federal,WorldWarICentennialCommission,Ready,(blank)\n" "zdomain12.gov,Interstate,Ready,(blank)\n" "zdomain9.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-01\n" "sdomain8.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-02\n" @@ -642,7 +642,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): "3,2,1,0,0,0,0,0,0,0\n" "\n" "Domain name,Domain type,Domain managers,Invited domain managers\n" - "cdomain11.gov,Federal - Executive,meoward@rocks.com,\n" + "cdomain11.gov,Federal,meoward@rocks.com,\n" 'cdomain1.gov,Federal - Executive,"big_lebowski@dude.co, info@example.com, meoward@rocks.com",' "woofwardthethird@rocks.com\n" "zdomain12.gov,Interstate,meoward@rocks.com,\n" @@ -716,7 +716,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): expected_content = ( "Domain request,Domain type,Federal type\n" "city3.gov,Federal,Executive\n" - "city4.gov,City,Executive\n" + "city4.gov,City,\n" "city6.gov,Federal,Executive\n" ) @@ -783,7 +783,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): "SO last name,SO email,SO title/role,Request purpose,Request additional details,Other contacts," "CISA regional representative,Current websites,Investigator\n" # Content - "city5.gov,Approved,Federal,No,Executive,,Testorg,N/A,,NY,2,requested_suborg,SanFran,CA,,,,,1,0," + "city5.gov,Approved,Federal,No,,,Testorg,N/A,,NY,2,requested_suborg,SanFran,CA,,,,,1,0," "city1.gov,Testy,Tester,testy@town.com,Chief Tester,Purpose of the site,There is more," "Testy Tester testy2@town.com,,city.com,\n" "city2.gov,In review,Federal,Yes,Executive,Portfolio 1 Federal Agency,Portfolio 1 Federal Agency," @@ -795,7 +795,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): 'There is more,"Meow Tester24 te2@town.com, Testy1232 Tester24 te2@town.com, ' 'Testy Tester testy2@town.com",' 'test@igorville.com,"city.com, https://www.example2.com, https://www.example.com",\n' - "city4.gov,Submitted,City,No,Executive,,Testorg,Yes,,NY,2,,,,,,,,0,1,city1.gov,Testy," + "city4.gov,Submitted,City,No,,,Testorg,Yes,,NY,2,,,,,,,,0,1,city1.gov,Testy," "Tester,testy@town.com," "Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more," "Testy Tester testy2@town.com," diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index 2065c2d35..114c066b3 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -3930,17 +3930,59 @@ class TestPortfolioInviteNewMemberView(MockEppLib, WebTest): response = self.client.post( reverse("new-member"), { - "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, - "domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, + "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN, "email": self.user.email, }, + follow=True, ) self.assertEqual(response.status_code, 200) + with open("debug_response.html", "w") as f: + f.write(response.content.decode("utf-8")) # Verify messages self.assertContains( response, - f"{self.user.email} is already a member of another .gov organization.", + "User is already a member of this portfolio.", + ) + + # Validate Database has not changed + invite_count_after = PortfolioInvitation.objects.count() + self.assertEqual(invite_count_after, invite_count_before) + + # assert that send_portfolio_invitation_email is not called + mock_send_email.assert_not_called() + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + @patch("registrar.views.portfolios.send_portfolio_invitation_email") + def test_member_invite_for_existing_member_uppercase(self, mock_send_email): + """Tests the member invitation flow for existing portfolio member with a different case.""" + self.client.force_login(self.user) + + # Simulate a session to ensure continuity + session_id = self.client.session.session_key + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + invite_count_before = PortfolioInvitation.objects.count() + + # Simulate submission of member invite for user who has already been invited + response = self.client.post( + reverse("new-member"), + { + "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN, + "email": self.user.email.upper(), + }, + follow=True, + ) + self.assertEqual(response.status_code, 200) + with open("debug_response.html", "w") as f: + f.write(response.content.decode("utf-8")) + + # Verify messages + self.assertContains( + response, + "User is already a member of this portfolio.", ) # Validate Database has not changed diff --git a/src/registrar/tests/test_views_request.py b/src/registrar/tests/test_views_request.py index e5162bdb1..914bb210d 100644 --- a/src/registrar/tests/test_views_request.py +++ b/src/registrar/tests/test_views_request.py @@ -2745,21 +2745,21 @@ class DomainRequestTests(TestWithUser, WebTest): def feb_review_page_tests(self, review_page): # Meets Naming Requirements - self.assertContains(review_page, "