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 = "" + domain_manager_details = "
UIDNameEmail
" + if not self.is_omb_analyst: + domain_manager_details += "" + domain_manager_details += "" for domain_manager in domain_managers: full_name = domain_manager.get_formatted_name() change_url = reverse("admin:registrar_user_change", args=[domain_manager.pk]) domain_manager_details += "" - domain_manager_details += f'" domain_manager_details += f"" domain_manager_details += "" @@ -3359,7 +3691,8 @@ class DomainInformationInline(admin.StackedInline): superuser_perm = request.user.has_perm("registrar.full_access_permission") analyst_perm = request.user.has_perm("registrar.analyst_access_permission") - if analyst_perm and not superuser_perm: + omb_analyst_perm = request.user.groups.filter(name="omb_analysts_group").exists() + if (analyst_perm or omb_analyst_perm) and not superuser_perm: return True return super().has_change_permission(request, obj) @@ -3433,6 +3766,23 @@ class DomainInformationInline(admin.StackedInline): return modified_fieldsets + 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 + self.is_omb_analyst = request.user.groups.filter(name="omb_analysts_group").exists() + form.show_contact_as_plain_text = self.is_omb_analyst + form.is_omb_analyst = self.is_omb_analyst + + return form + + def get_formset(self, request, obj=None, **kwargs): + """Attach request to the formset so that it can be available in the form""" + formset = super().get_formset(request, obj, **kwargs) + formset.form.request = request # Attach request to form + return formset + class DomainResource(FsmModelResource): """defines how each field in the referenced model should be mapped to the corresponding fields in the @@ -3442,7 +3792,7 @@ class DomainResource(FsmModelResource): model = models.Domain -class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): +class DomainAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): """Custom domain admin class to add extra buttons.""" resource_classes = [DomainResource] @@ -3554,7 +3904,7 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): if self.value(): return queryset.filter( Q(domain_info__portfolio__federal_type=self.value()) - | Q(domain_info__portfolio__isnull=True, domain_info__federal_type=self.value()) + | Q(domain_info__portfolio__isnull=True, domain_info__federal_agency__federal_type=self.value()) ) return queryset @@ -3581,7 +3931,7 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): Q(domain_info__portfolio__isnull=False) & Q(domain_info__portfolio__federal_agency__isnull=False), then=F("domain_info__portfolio__federal_agency__federal_type"), ), - # Otherwise, return the natively assigned value + # Otherwise, return federal type from federal agency default=F("domain_info__federal_agency__federal_type"), ), converted_organization_name=Case( @@ -4008,8 +4358,10 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): # Fixes a bug wherein users which are only is_staff # can access 'change' when GET, # but cannot access this page when it is a request of type POST. - if request.user.has_perm("registrar.full_access_permission") or request.user.has_perm( - "registrar.analyst_access_permission" + if ( + request.user.has_perm("registrar.full_access_permission") + or request.user.has_perm("registrar.analyst_access_permission") + or request.user.groups.filter(name="omb_analysts_group").exists() ): return True return super().has_change_permission(request, obj) @@ -4024,8 +4376,37 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): if portfolio_id: # Further filter the queryset by the portfolio qs = qs.filter(domain_info__portfolio=portfolio_id) + # Check if user is in OMB analysts group + if request.user.groups.filter(name="omb_analysts_group").exists(): + return qs.filter( + converted_generic_org_type=DomainRequest.OrganizationChoices.FEDERAL, + converted_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.domain_info.converted_generic_org_type == DomainRequest.OrganizationChoices.FEDERAL + and obj.domain_info.converted_federal_type == BranchChoices.EXECUTIVE + ) + return super().has_view_permission(request, obj) + + 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 + is_omb_analyst = request.user.groups.filter(name="omb_analysts_group").exists() + form.show_contact_as_plain_text = is_omb_analyst + form.is_omb_analyst = is_omb_analyst + + return form + class DraftDomainResource(resources.ModelResource): """defines how each field in the referenced model should be mapped to the corresponding fields in the @@ -4035,7 +4416,7 @@ class DraftDomainResource(resources.ModelResource): model = models.DraftDomain -class DraftDomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): +class DraftDomainAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): """Custom draft domain admin class.""" resource_classes = [DraftDomainResource] @@ -4147,7 +4528,7 @@ class PublicContactResource(resources.ModelResource): self.after_save_instance(instance, using_transactions, dry_run) -class PublicContactAdmin(ListHeaderAdmin, ImportExportModelAdmin): +class PublicContactAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): """Custom PublicContact admin class.""" resource_classes = [PublicContactResource] @@ -4202,6 +4583,11 @@ class PortfolioAdmin(ListHeaderAdmin): _meta = Meta() + 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 + change_form_template = "django/admin/portfolio_change_form.html" fieldsets = [ # created_on is the created_at field @@ -4283,6 +4669,19 @@ class PortfolioAdmin(ListHeaderAdmin): # rather than strip it out of our logic. analyst_readonly_fields = [] # type: ignore + omb_analyst_readonly_fields = [ + "notes", + "organization_type", + "organization_name", + "federal_agency", + "state_territory", + "address_line1", + "address_line2", + "city", + "zipcode", + "urbanization", + ] + def get_admin_users(self, obj): # Filter UserPortfolioPermission objects related to the portfolio admin_permissions = self.get_user_portfolio_permission_admins(obj) @@ -4368,6 +4767,8 @@ class PortfolioAdmin(ListHeaderAdmin): """Returns the number of administrators for this portfolio""" admin_count = len(self.get_user_portfolio_permission_admins(obj)) if admin_count > 0: + if self.is_omb_analyst: + return format_html(f"{admin_count} administrators") url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={obj.id}" # Create a clickable link with the count return format_html(f'{admin_count} admins') @@ -4379,6 +4780,8 @@ class PortfolioAdmin(ListHeaderAdmin): """Returns the number of basic members for this portfolio""" member_count = len(self.get_user_portfolio_permission_non_admins(obj)) if member_count > 0: + if self.is_omb_analyst: + return format_html(f"{member_count} members") url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={obj.id}" # Create a clickable link with the count return format_html(f'{member_count} basic members') @@ -4424,12 +4827,35 @@ class PortfolioAdmin(ListHeaderAdmin): 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(): + self.is_omb_analyst = True + 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_type == BranchChoices.EXECUTIVE + return super().has_view_permission(request, obj) + def change_view(self, request, object_id, form_url="", extra_context=None): """Add related suborganizations and domain groups. Add the summary for the portfolio members field (list of members that link to change_forms).""" @@ -4474,6 +4900,17 @@ class PortfolioAdmin(ListHeaderAdmin): super().save_model(request, obj, form, change) + 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 + self.is_omb_analyst = request.user.groups.filter(name="omb_analysts_group").exists() + form.show_contact_as_plain_text = self.is_omb_analyst + form.is_omb_analyst = self.is_omb_analyst + + return form + class FederalAgencyResource(resources.ModelResource): """defines how each field in the referenced model should be mapped to the corresponding fields in the @@ -4483,13 +4920,66 @@ class FederalAgencyResource(resources.ModelResource): model = models.FederalAgency -class FederalAgencyAdmin(ListHeaderAdmin, ImportExportModelAdmin): +class FederalAgencyAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): list_display = ["agency"] search_fields = ["agency"] search_help_text = "Search by federal agency." ordering = ["agency"] resource_classes = [FederalAgencyResource] + # Readonly fields for analysts and superusers + readonly_fields = [] + + # Read only that we'll leverage for CISA Analysts + analyst_readonly_fields = [] # type: ignore + + # Read only that we'll leverage for OMB Analysts + omb_analyst_readonly_fields = [ + "agency", + "federal_type", + "acronym", + "is_fceb", + ] + + 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_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_type == BranchChoices.EXECUTIVE + return super().has_view_permission(request, obj) + + def get_readonly_fields(self, request, obj=None): + """Set the read-only state on form elements. + We have 2 conditions that determine which fields are read-only: + admin user permissions and the domain request creator's 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 + class UserGroupAdmin(AuditedAdmin): """Overwrite the generated UserGroup admin class""" @@ -4539,11 +5029,11 @@ class WaffleFlagAdmin(FlagAdmin): return super().changelist_view(request, extra_context=extra_context) -class DomainGroupAdmin(ListHeaderAdmin, ImportExportModelAdmin): +class DomainGroupAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): list_display = ["name", "portfolio"] -class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin): +class SuborganizationAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): list_display = ["name", "portfolio"] autocomplete_fields = [ @@ -4554,6 +5044,38 @@ class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin): change_form_template = "django/admin/suborg_change_form.html" + 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 = [ + "name", + "portfolio", + "city", + "state_territory", + ] + + 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 change_view(self, request, object_id, form_url="", extra_context=None): """Add suborg's related domains and requests to context""" obj = self.get_object(request, object_id) @@ -4571,6 +5093,30 @@ class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin): extra_context = {"domain_requests": domain_requests, "domains": domains} return super().change_view(request, object_id, form_url, extra_context) + def get_queryset(self, request): + """Custom get_queryset to filter for OMB analysts.""" + 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( + portfolio__organization_type=DomainRequest.OrganizationChoices.FEDERAL, + portfolio__federal_agency__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.portfolio + and obj.portfolio.federal_agency + and obj.portfolio.federal_agency.federal_type == BranchChoices.EXECUTIVE + ) + return super().has_view_permission(request, obj) + class AllowedEmailAdmin(ListHeaderAdmin): class Meta: diff --git a/src/registrar/assets/src/js/getgov-admin/domain-request-form.js b/src/registrar/assets/src/js/getgov-admin/domain-request-form.js index db6467875..b9084494a 100644 --- a/src/registrar/assets/src/js/getgov-admin/domain-request-form.js +++ b/src/registrar/assets/src/js/getgov-admin/domain-request-form.js @@ -105,8 +105,10 @@ export function initApprovedDomain() { return; } - const statusToCheck = "approved"; + const statusToCheck = "approved"; // when checking against a select + const readonlyStatusToCheck = "Approved"; // when checking against a readonly div display value const statusSelect = document.getElementById("id_status"); + const statusField = document.querySelector("field-status"); const sessionVariableName = "showApprovedDomain"; let approvedDomainFormGroup = document.querySelector(".field-approved_domain"); @@ -120,18 +122,32 @@ export function initApprovedDomain() { // Handle showing/hiding the related fields on page load. function initializeFormGroups() { - let isStatus = statusSelect.value == statusToCheck; + // Status is either in a select or in a readonly div. Both + // cases are handled below. + let isStatus = false; + if (statusSelect) { + isStatus = statusSelect.value == statusToCheck; + } else { + // statusSelect does not exist, indicating readonly + if (statusField) { + let readonlyDiv = statusField.querySelector("div.readonly"); + let readonlyStatusText = readonlyDiv.textContent.trim(); + isStatus = readonlyStatusText == readonlyStatusToCheck; + } + } // Initial handling of these groups. updateFormGroupVisibility(isStatus); - // Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage - statusSelect.addEventListener('change', () => { - // Show the approved if the status is what we expect. - isStatus = statusSelect.value == statusToCheck; - updateFormGroupVisibility(isStatus); - addOrRemoveSessionBoolean(sessionVariableName, isStatus); - }); + if (statusSelect) { + // Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage + statusSelect.addEventListener('change', () => { + // Show the approved if the status is what we expect. + isStatus = statusSelect.value == statusToCheck; + updateFormGroupVisibility(isStatus); + addOrRemoveSessionBoolean(sessionVariableName, isStatus); + }); + } // Listen to Back/Forward button navigation and handle approvedDomainFormGroup display based on session storage // When you navigate using forward/back after changing status but not saving, when you land back on the DA page the @@ -322,6 +338,7 @@ class CustomizableEmailBase { * @property {HTMLElement} modalConfirm - The confirm button in the modal. * @property {string} apiUrl - The API URL for fetching email content. * @property {string} statusToCheck - The status to check against. Used for show/hide on textAreaFormGroup/dropdownFormGroup. + * @property {string} readonlyStatusToCheck - The status to check against when readonly. Used for show/hide on textAreaFormGroup/dropdownFormGroup. * @property {string} sessionVariableName - The session variable name. Used for show/hide on textAreaFormGroup/dropdownFormGroup. * @property {string} apiErrorMessage - The error message that the ajax call returns. */ @@ -338,6 +355,7 @@ class CustomizableEmailBase { this.textAreaFormGroup = config.textAreaFormGroup; this.dropdownFormGroup = config.dropdownFormGroup; this.statusToCheck = config.statusToCheck; + this.readonlyStatusToCheck = config.readonlyStatusToCheck; this.sessionVariableName = config.sessionVariableName; // Non-configurable variables @@ -363,19 +381,31 @@ class CustomizableEmailBase { // Handle showing/hiding the related fields on page load. initializeFormGroups() { - let isStatus = this.statusSelect.value == this.statusToCheck; + let isStatus = false; + if (this.statusSelect) { + isStatus = this.statusSelect.value == this.statusToCheck; + } else { + // statusSelect does not exist, indicating readonly + if (this.dropdownFormGroup) { + let readonlyDiv = this.dropdownFormGroup.querySelector("div.readonly"); + let readonlyStatusText = readonlyDiv.textContent.trim(); + isStatus = readonlyStatusText == this.readonlyStatusToCheck; + } + } // Initial handling of these groups. this.updateFormGroupVisibility(isStatus); - // Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage - this.statusSelect.addEventListener('change', () => { - // Show the action needed field if the status is what we expect. - // Then track if its shown or hidden in our session cache. - isStatus = this.statusSelect.value == this.statusToCheck; - this.updateFormGroupVisibility(isStatus); - addOrRemoveSessionBoolean(this.sessionVariableName, isStatus); - }); + if (this.statusSelect) { + // Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage + this.statusSelect.addEventListener('change', () => { + // Show the action needed field if the status is what we expect. + // Then track if its shown or hidden in our session cache. + isStatus = this.statusSelect.value == this.statusToCheck; + this.updateFormGroupVisibility(isStatus); + addOrRemoveSessionBoolean(this.sessionVariableName, isStatus); + }); + } // Listen to Back/Forward button navigation and handle rejectionReasonFormGroup display based on session storage // When you navigate using forward/back after changing status but not saving, when you land back on the DA page the @@ -403,58 +433,66 @@ class CustomizableEmailBase { } initializeDropdown() { - this.dropdown.addEventListener("change", () => { - let reason = this.dropdown.value; - if (this.initialDropdownValue !== this.dropdown.value || this.initialEmailValue !== this.textarea.value) { - let searchParams = new URLSearchParams( - { - "reason": reason, - "domain_request_id": this.domainRequestId, - } - ); - // Replace the email content - fetch(`${this.apiUrl}?${searchParams.toString()}`) - .then(response => { - return response.json().then(data => data); - }) - .then(data => { - if (data.error) { - console.error("Error in AJAX call: " + data.error); - }else { - this.textarea.value = data.email; - } - this.updateUserInterface(reason); - }) - .catch(error => { - console.error(this.apiErrorMessage, error) - }); - } - }); + if (this.dropdown) { + this.dropdown.addEventListener("change", () => { + let reason = this.dropdown.value; + if (this.initialDropdownValue !== this.dropdown.value || this.initialEmailValue !== this.textarea.value) { + let searchParams = new URLSearchParams( + { + "reason": reason, + "domain_request_id": this.domainRequestId, + } + ); + // Replace the email content + fetch(`${this.apiUrl}?${searchParams.toString()}`) + .then(response => { + return response.json().then(data => data); + }) + .then(data => { + if (data.error) { + console.error("Error in AJAX call: " + data.error); + }else { + this.textarea.value = data.email; + } + this.updateUserInterface(reason); + }) + .catch(error => { + console.error(this.apiErrorMessage, error) + }); + } + }); + } } initializeModalConfirm() { - this.modalConfirm.addEventListener("click", () => { - this.textarea.removeAttribute('readonly'); - this.textarea.focus(); + // When the modal confirm button is present, add a listener + if (this.modalConfirm) { + this.modalConfirm.addEventListener("click", () => { + this.textarea.removeAttribute('readonly'); + this.textarea.focus(); hideElement(this.directEditButton); hideElement(this.modalTrigger); - }); + }); + } } initializeDirectEditButton() { - this.directEditButton.addEventListener("click", () => { - this.textarea.removeAttribute('readonly'); - this.textarea.focus(); + // When the direct edit button is present, add a listener + if (this.directEditButton) { + this.directEditButton.addEventListener("click", () => { + this.textarea.removeAttribute('readonly'); + this.textarea.focus(); hideElement(this.directEditButton); hideElement(this.modalTrigger); - }); + }); + } } isEmailAlreadySent() { return this.lastSentEmailContent.value.replace(/\s+/g, '') === this.textarea.value.replace(/\s+/g, ''); } - updateUserInterface(reason=this.dropdown.value, excluded_reasons=["other"]) { + updateUserInterface(reason, excluded_reasons=["other"]) { if (!reason) { // No reason selected, we will set the label to "Email", show the "Make a selection" placeholder, hide the trigger, textarea, hide the help text this.showPlaceholderNoReason(); @@ -468,23 +506,25 @@ class CustomizableEmailBase { // Helper function that makes overriding the readonly textarea easy showReadonlyTextarea() { - // A triggering selection is selected, all hands on board: - this.textarea.setAttribute('readonly', true); - showElement(this.textarea); - hideElement(this.textareaPlaceholder); + if (this.textarea && this.textareaPlaceholder) { + // A triggering selection is selected, all hands on board: + this.textarea.setAttribute('readonly', true); + showElement(this.textarea); + hideElement(this.textareaPlaceholder); - if (this.isEmailAlreadySentConst) { - hideElement(this.directEditButton); - showElement(this.modalTrigger); + if (this.isEmailAlreadySentConst) { + hideElement(this.directEditButton); + showElement(this.modalTrigger); + } else { + showElement(this.directEditButton); + hideElement(this.modalTrigger); + } + + if (this.isEmailAlreadySent()) { + this.formLabel.innerHTML = "Email sent to creator:"; } else { - showElement(this.directEditButton); - hideElement(this.modalTrigger); - } - - if (this.isEmailAlreadySent()) { - this.formLabel.innerHTML = "Email sent to creator:"; - } else { - this.formLabel.innerHTML = "Email:"; + this.formLabel.innerHTML = "Email:"; + } } } @@ -516,9 +556,10 @@ class customActionNeededEmail extends CustomizableEmailBase { lastSentEmailContent: document.getElementById("last-sent-action-needed-email-content"), modalConfirm: document.getElementById("action-needed-reason__confirm-edit-email"), apiUrl: document.getElementById("get-action-needed-email-for-user-json")?.value || null, - textAreaFormGroup: document.querySelector('.field-action_needed_reason'), - dropdownFormGroup: document.querySelector('.field-action_needed_reason_email'), + textAreaFormGroup: document.querySelector('.field-action_needed_reason_email'), + dropdownFormGroup: document.querySelector('.field-action_needed_reason'), statusToCheck: "action needed", + readonlyStatusToCheck: "Action needed", sessionVariableName: "showActionNeededReason", apiErrorMessage: "Error when attempting to grab action needed email: " } @@ -529,7 +570,15 @@ class customActionNeededEmail extends CustomizableEmailBase { // Hide/show the email fields depending on the current status this.initializeFormGroups(); // Setup the textarea, edit button, helper text - this.updateUserInterface(); + let reason = null; + if (this.dropdown) { + reason = this.dropdown.value; + } else if (this.dropdownFormGroup && this.dropdownFormGroup.querySelector("div.readonly")) { + if (this.dropdownFormGroup.querySelector("div.readonly").textContent) { + reason = this.dropdownFormGroup.querySelector("div.readonly").textContent.trim() + } + } + this.updateUserInterface(reason); this.initializeDropdown(); this.initializeModalConfirm(); this.initializeDirectEditButton(); @@ -560,12 +609,6 @@ export function initActionNeededEmail() { // Initialize UI const customEmail = new customActionNeededEmail(); - // Check that every variable was setup correctly - const nullItems = Object.entries(customEmail.config).filter(([key, value]) => value === null).map(([key]) => key); - if (nullItems.length > 0) { - console.error(`Failed to load customActionNeededEmail(). Some variables were null: ${nullItems.join(", ")}`) - return; - } customEmail.loadActionNeededEmail() }); } @@ -581,6 +624,7 @@ class customRejectedEmail extends CustomizableEmailBase { textAreaFormGroup: document.querySelector('.field-rejection_reason'), dropdownFormGroup: document.querySelector('.field-rejection_reason_email'), statusToCheck: "rejected", + readonlyStatusToCheck: "Rejected", sessionVariableName: "showRejectionReason", errorMessage: "Error when attempting to grab rejected email: " }; @@ -589,7 +633,15 @@ class customRejectedEmail extends CustomizableEmailBase { loadRejectedEmail() { this.initializeFormGroups(); - this.updateUserInterface(); + let reason = null; + if (this.dropdown) { + reason = this.dropdown.value; + } else if (this.dropdownFormGroup && this.dropdownFormGroup.querySelector("div.readonly")) { + if (this.dropdownFormGroup.querySelector("div.readonly").textContent) { + reason = this.dropdownFormGroup.querySelector("div.readonly").textContent.trim() + } + } + this.updateUserInterface(reason); this.initializeDropdown(); this.initializeModalConfirm(); this.initializeDirectEditButton(); @@ -600,7 +652,7 @@ class customRejectedEmail extends CustomizableEmailBase { this.showPlaceholder("Email:", "Select a rejection reason to see email"); } - updateUserInterface(reason=this.dropdown.value, excluded_reasons=[]) { + updateUserInterface(reason, excluded_reasons=[]) { super.updateUserInterface(reason, excluded_reasons); } } @@ -619,12 +671,6 @@ export function initRejectedEmail() { // Initialize UI const customEmail = new customRejectedEmail(); - // Check that every variable was setup correctly - const nullItems = Object.entries(customEmail.config).filter(([key, value]) => value === null).map(([key]) => key); - if (nullItems.length > 0) { - console.error(`Failed to load customRejectedEmail(). Some variables were null: ${nullItems.join(", ")}`) - return; - } customEmail.loadRejectedEmail() }); } @@ -648,7 +694,6 @@ function handleSuborgFieldsAndButtons() { // Ensure that every variable is present before proceeding if (!requestedSuborganizationField || !suborganizationCity || !suborganizationStateTerritory || !rejectButton) { - console.warn("handleSuborganizationSelection() => Could not find required fields.") return; } diff --git a/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js b/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js index 9a60e1684..3880e63fa 100644 --- a/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js +++ b/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js @@ -12,7 +12,9 @@ export function handlePortfolioSelection( suborgDropdownSelector="#id_sub_organization" ) { // These dropdown are select2 fields so they must be interacted with via jquery + // In the event that these fields are readonly, need a variable to reference their row const portfolioDropdown = django.jQuery(portfolioDropdownSelector); + const portfolioField = document.querySelector(".field-portfolio"); const suborganizationDropdown = django.jQuery(suborgDropdownSelector); const suborganizationField = document.querySelector(".field-sub_organization"); const requestedSuborganizationField = document.querySelector(".field-requested_suborganization"); @@ -394,17 +396,33 @@ export function handlePortfolioSelection( * - Various global field elements (e.g., `suborganizationField`, `seniorOfficialField`, `portfolioOrgTypeFieldSet`) are used. */ function updatePortfolioFieldsDisplay() { - // Retrieve the selected portfolio ID - let portfolio_id = portfolioDropdown.val(); + let portfolio_id = null; + let portfolio_selected = false; + // portfolio will be either readonly or a dropdown, handle both cases + if (portfolioDropdown.length) { // need to test length since the query will always be defined, even if not in DOM + // Retrieve the selected portfolio ID + portfolio_id = portfolioDropdown.val(); + if (portfolio_id) { + portfolio_selected = true; + } + } else { + // get readonly field value + let portfolio = portfolioField.querySelector(".readonly").innerText; + if (portfolio != "-") { + portfolio_selected = true; + } + } - if (portfolio_id) { + if (portfolio_selected) { // A portfolio is selected - update suborganization dropdown and show/hide relevant fields - // Update suborganization dropdown for the selected portfolio - updateSubOrganizationDropdown(portfolio_id); + if (portfolio_id) { + // Update suborganization dropdown for the selected portfolio + updateSubOrganizationDropdown(portfolio_id); + } // Show fields relevant to a selected portfolio - showElement(suborganizationField); + if (suborganizationField) showElement(suborganizationField); hideElement(seniorOfficialField); showElement(portfolioSeniorOfficialField); @@ -427,7 +445,7 @@ export function handlePortfolioSelection( // No portfolio is selected - reverse visibility of fields // Hide suborganization field as no portfolio is selected - hideElement(suborganizationField); + if (suborganizationField) hideElement(suborganizationField); // Show fields that are relevant when no portfolio is selected showElement(seniorOfficialField); @@ -468,10 +486,22 @@ export function handlePortfolioSelection( * This function ensures the form dynamically reflects whether a specific suborganization is being selected or requested. */ function updateSuborganizationFieldsDisplay() { - let portfolio_id = portfolioDropdown.val(); + let portfolio_selected = false; + // portfolio will be either readonly or a dropdown, handle both cases + if (portfolioDropdown.length) { // need to test length since the query will always be defined, even if not in DOM + // Retrieve the selected portfolio ID + if (portfolioDropdown.val()) { + portfolio_selected = true; + } + } else { + // get readonly field value + if (portfolioField.querySelector(".readonly").innerText != "-") { + portfolio_selected = true; + } + } let suborganization_id = suborganizationDropdown.val(); - if (portfolio_id && !suborganization_id) { + if (portfolio_selected && !suborganization_id) { // Show suborganization request fields if (requestedSuborganizationField) showElement(requestedSuborganizationField); if (suborganizationCity) showElement(suborganizationCity); diff --git a/src/registrar/assets/src/js/getgov-admin/portfolio-form.js b/src/registrar/assets/src/js/getgov-admin/portfolio-form.js index 74729c2b2..7777dabdb 100644 --- a/src/registrar/assets/src/js/getgov-admin/portfolio-form.js +++ b/src/registrar/assets/src/js/getgov-admin/portfolio-form.js @@ -21,6 +21,8 @@ function handlePortfolioFields(){ const federalTypeField = document.querySelector(".field-federal_type"); const urbanizationField = document.querySelector(".field-urbanization"); const stateTerritoryDropdown = document.getElementById("id_state_territory"); + const stateTerritoryField = document.querySelector(".field-state_territory"); + const stateTerritoryReadonly = stateTerritoryField.querySelector(".readonly"); const seniorOfficialAddUrl = document.getElementById("senior-official-add-url").value; const seniorOfficialApi = document.getElementById("senior_official_from_agency_json_url").value; const federalPortfolioApi = document.getElementById("federal_and_portfolio_types_from_agency_json_url").value; @@ -85,9 +87,9 @@ function handlePortfolioFields(){ * 2. else show org name, hide federal agency, hide federal type if applicable */ function handleOrganizationTypeChange() { - if (organizationTypeDropdown && organizationNameField) { - let selectedValue = organizationTypeDropdown.value; - if (selectedValue === "federal") { + if (organizationTypeField && organizationNameField) { + let selectedValue = organizationTypeDropdown ? organizationTypeDropdown.value : organizationTypeReadonly.innerText; + if (selectedValue === "federal" || selectedValue === "Federal") { hideElement(organizationNameField); showElement(federalAgencyField); if (federalTypeField) { @@ -207,8 +209,8 @@ function handlePortfolioFields(){ * Handle urbanization */ function handleStateTerritoryChange() { - let selectedValue = stateTerritoryDropdown.value; - if (selectedValue === "PR") { + let selectedValue = stateTerritoryDropdown ? stateTerritoryDropdown.value : stateTerritoryReadonly.innerText; + if (selectedValue === "PR" || selectedValue === "Puerto Rico (PR)") { showElement(urbanizationField) } else { hideElement(urbanizationField) @@ -265,7 +267,7 @@ function handlePortfolioFields(){ * Initializes necessary data and display configurations for the portfolio fields. */ function initializePortfolioSettings() { - if (urbanizationField && stateTerritoryDropdown) { + if (urbanizationField && stateTerritoryField) { handleStateTerritoryChange(); } handleOrganizationTypeChange(); @@ -285,9 +287,11 @@ function handlePortfolioFields(){ handleStateTerritoryChange(); }); } - organizationTypeDropdown.addEventListener("change", function() { - handleOrganizationTypeChange(); - }); + if (organizationTypeDropdown) { + organizationTypeDropdown.addEventListener("change", function() { + handleOrganizationTypeChange(); + }); + } } // Run initial setup functions diff --git a/src/registrar/decorators.py b/src/registrar/decorators.py index b4b5c3bd2..d607935a2 100644 --- a/src/registrar/decorators.py +++ b/src/registrar/decorators.py @@ -12,6 +12,9 @@ logger = logging.getLogger(__name__) # Constants for clarity ALL = "all" IS_STAFF = "is_staff" +IS_CISA_ANALYST = "is_cisa_analyst" +IS_OMB_ANALYST = "is_omb_analyst" +IS_FULL_ACCESS = "is_full_access" IS_DOMAIN_MANAGER = "is_domain_manager" IS_DOMAIN_REQUEST_CREATOR = "is_domain_request_creator" IS_STAFF_MANAGING_DOMAIN = "is_staff_managing_domain" @@ -108,6 +111,9 @@ def _user_has_permission(user, request, rules, **kwargs): # Define permission checks permission_checks = [ (IS_STAFF, lambda: user.is_staff), + (IS_CISA_ANALYST, lambda: user.has_perm("registrar.analyst_access_permission")), + (IS_OMB_ANALYST, lambda: user.groups.filter(name="omb_analysts_group").exists()), + (IS_FULL_ACCESS, lambda: user.has_perm("registrar.full_access_permission")), ( IS_DOMAIN_MANAGER, lambda: (not user.is_org_user(request) and _is_domain_manager(user, **kwargs)) diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index af60c8649..a7d487b40 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -86,7 +86,6 @@ class RequestingEntityForm(RegistrarForm): return {} # get the domain request as a dict, per usual method domain_request_dict = {name: getattr(obj, name) for name in cls.declared_fields.keys()} # type: ignore - # set sub_organization to 'other' if is_requesting_new_suborganization is True if isinstance(obj, DomainRequest) and obj.is_requesting_new_suborganization(): domain_request_dict["sub_organization"] = "other" diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index b83e718cb..db1f58d88 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -22,6 +22,7 @@ from registrar.models.utility.portfolio_helper import ( get_domains_display, get_members_description_display, get_members_display, + get_portfolio_invitation_associations, ) logger = logging.getLogger(__name__) @@ -459,7 +460,14 @@ class PortfolioNewMemberForm(BasePortfolioMemberForm): if hasattr(e, "code"): field = "email" if "email" in self.fields else None if e.code == "has_existing_permissions": - self.add_error(field, f"{self.instance.email} is already a member of another .gov organization.") + existing_permissions, existing_invitations = get_portfolio_invitation_associations(self.instance) + + same_portfolio_for_permissions = existing_permissions.exclude(portfolio=self.instance.portfolio) + same_portfolio_for_invitations = existing_invitations.exclude(portfolio=self.instance.portfolio) + if same_portfolio_for_permissions.exists() or same_portfolio_for_invitations.exists(): + self.add_error( + field, f"{self.instance.email} is already a member of another .gov organization." + ) override_error = True elif e.code == "has_existing_invitations": self.add_error( diff --git a/src/registrar/migrations/0143_create_groups_v18.py b/src/registrar/migrations/0143_create_groups_v18.py new file mode 100644 index 000000000..d0b7a6dbc --- /dev/null +++ b/src/registrar/migrations/0143_create_groups_v18.py @@ -0,0 +1,38 @@ +# This migration creates the create_full_access_group and create_cisa_analyst_group groups +# It is dependent on 0079 (which populates federal agencies) +# If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS +# in the user_group model then: +# [NOT RECOMMENDED] +# step 1: docker-compose exec app ./manage.py migrate --fake registrar 0035_contenttypes_permissions +# step 2: docker-compose exec app ./manage.py migrate registrar 0036_create_groups +# step 3: fake run the latest migration in the migrations list +# [RECOMMENDED] +# Alternatively: +# step 1: duplicate the migration that loads data +# step 2: docker-compose exec app ./manage.py migrate + +from django.db import migrations +from registrar.models import UserGroup +from typing import Any + + +# For linting: RunPython expects a function reference, +# so let's give it one +def create_groups(apps, schema_editor) -> Any: + UserGroup.create_cisa_analyst_group(apps, schema_editor) + UserGroup.create_omb_analyst_group(apps, schema_editor) + UserGroup.create_full_access_group(apps, schema_editor) + + +class Migration(migrations.Migration): + dependencies = [ + ("registrar", "0142_domainrequest_feb_naming_requirements_and_more"), + ] + + operations = [ + migrations.RunPython( + create_groups, + reverse_code=migrations.RunPython.noop, + atomic=True, + ), + ] diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index d3c0ed347..01ed246ac 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -245,6 +245,7 @@ class Domain(TimeStampedModel, DomainHelper): is called in the validate function on the request/domain page throws- RegistryError or InvalidDomainError""" + if not cls.string_could_be_domain(domain): logger.warning("Not a valid domain: %s" % str(domain)) # throw invalid domain error so that it can be caught in diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py index aa933e282..3839e5290 100644 --- a/src/registrar/models/domain_information.py +++ b/src/registrar/models/domain_information.py @@ -449,7 +449,9 @@ class DomainInformation(TimeStampedModel): def converted_federal_type(self): if self.portfolio: return self.portfolio.federal_type - return self.federal_type + elif self.federal_agency: + return self.federal_agency.federal_type + return None @property def converted_senior_official(self): diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index 1499ec109..d68a29ab1 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -1528,7 +1528,9 @@ class DomainRequest(TimeStampedModel): def converted_federal_type(self): if self.portfolio: return self.portfolio.federal_type - return self.federal_type + elif self.federal_agency: + return self.federal_agency.federal_type + return None @property def converted_address_line1(self): diff --git a/src/registrar/models/user_group.py b/src/registrar/models/user_group.py index 4770f34bc..331e36605 100644 --- a/src/registrar/models/user_group.py +++ b/src/registrar/models/user_group.py @@ -141,6 +141,99 @@ class UserGroup(Group): except Exception as e: logger.error(f"Error creating analyst permissions group: {e}") + def create_omb_analyst_group(apps, schema_editor): + """This method gets run from a data migration.""" + + # Hard to pass self to these methods as the calls from migrations + # are only expecting apps and schema_editor, so we'll just define + # apps, schema_editor in the local scope instead + OMB_ANALYST_GROUP_PERMISSIONS = [ + { + "app_label": "registrar", + "model": "domainrequest", + "permissions": ["change_domainrequest"], + }, + { + "app_label": "registrar", + "model": "domain", + "permissions": ["view_domain"], + }, + { + "app_label": "registrar", + "model": "domaininvitation", + "permissions": ["view_domaininvitation"], + }, + { + "app_label": "registrar", + "model": "federalagency", + "permissions": ["view_federalagency"], + }, + { + "app_label": "registrar", + "model": "portfolio", + "permissions": ["view_portfolio"], + }, + { + "app_label": "registrar", + "model": "suborganization", + "permissions": ["view_suborganization"], + }, + { + "app_label": "registrar", + "model": "seniorofficial", + "permissions": ["view_seniorofficial"], + }, + ] + + # Avoid error: You can't execute queries until the end + # of the 'atomic' block. + # From django docs: + # https://docs.djangoproject.com/en/4.2/topics/migrations/#data-migrations + # We can’t import the Person model directly as it may be a newer + # version than this migration expects. We use the historical version. + ContentType = apps.get_model("contenttypes", "ContentType") + Permission = apps.get_model("auth", "Permission") + UserGroup = apps.get_model("registrar", "UserGroup") + + logger.info("Going to create the OMB Analyst Group") + try: + omb_analysts_group, _ = UserGroup.objects.get_or_create( + name="omb_analysts_group", + ) + + omb_analysts_group.permissions.clear() + + for permission in OMB_ANALYST_GROUP_PERMISSIONS: + app_label = permission["app_label"] + model_name = permission["model"] + permissions = permission["permissions"] + + # Retrieve the content type for the app and model + content_type = ContentType.objects.get(app_label=app_label, model=model_name) + + # Retrieve the permissions based on their codenames + permissions = Permission.objects.filter(content_type=content_type, codename__in=permissions) + + # Assign the permissions to the group + omb_analysts_group.permissions.add(*permissions) + + # Convert the permissions QuerySet to a list of codenames + permission_list = list(permissions.values_list("codename", flat=True)) + + logger.debug( + app_label + + " | " + + model_name + + " | " + + ", ".join(permission_list) + + " added to group " + + omb_analysts_group.name + ) + + logger.debug("OMB Analyst permissions added to group " + omb_analysts_group.name) + except Exception as e: + logger.error(f"Error creating analyst permissions group: {e}") + def create_full_access_group(apps, schema_editor): """This method gets run from a data migration.""" diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py index 009ea3c26..669985725 100644 --- a/src/registrar/models/utility/portfolio_helper.py +++ b/src/registrar/models/utility/portfolio_helper.py @@ -257,9 +257,6 @@ def validate_user_portfolio_permission(user_portfolio_permission): Raises: ValidationError: If any of the validation rules are violated. """ - PortfolioInvitation = apps.get_model("registrar.PortfolioInvitation") - UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission") - has_portfolio = bool(user_portfolio_permission.portfolio_id) portfolio_permissions = set(user_portfolio_permission._get_portfolio_permissions()) @@ -286,8 +283,8 @@ def validate_user_portfolio_permission(user_portfolio_permission): # == Validate the multiple_porfolios flag. == # if not flag_is_active_for_user(user_portfolio_permission.user, "multiple_portfolios"): - existing_permissions = UserPortfolioPermission.objects.exclude(id=user_portfolio_permission.id).filter( - user=user_portfolio_permission.user + existing_permissions, existing_invitations = get_user_portfolio_permission_associations( + user_portfolio_permission ) if existing_permissions.exists(): raise ValidationError( @@ -296,10 +293,6 @@ def validate_user_portfolio_permission(user_portfolio_permission): code="has_existing_permissions", ) - existing_invitations = PortfolioInvitation.objects.filter(email=user_portfolio_permission.user.email).exclude( - Q(portfolio=user_portfolio_permission.portfolio) - | Q(status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED) - ) if existing_invitations.exists(): raise ValidationError( "This user is already assigned to a portfolio invitation. " @@ -308,6 +301,32 @@ def validate_user_portfolio_permission(user_portfolio_permission): ) +def get_user_portfolio_permission_associations(user_portfolio_permission): + """ + Retrieves the associations for a user portfolio invitation. + + Returns: + A tuple: + (existing_permissions, existing_invitations) + where: + - existing_permissions: UserPortfolioPermission objects excluding the current permission. + - existing_invitations: PortfolioInvitation objects for the user email excluding + the current invitation and those with status RETRIEVED. + """ + PortfolioInvitation = apps.get_model("registrar.PortfolioInvitation") + UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission") + existing_permissions = UserPortfolioPermission.objects.exclude(id=user_portfolio_permission.id).filter( + user=user_portfolio_permission.user + ) + existing_invitations = PortfolioInvitation.objects.filter( + email__iexact=user_portfolio_permission.user.email + ).exclude( + Q(portfolio=user_portfolio_permission.portfolio) + | Q(status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED) + ) + return (existing_permissions, existing_invitations) + + def validate_portfolio_invitation(portfolio_invitation): """ Validates a PortfolioInvitation instance. Located in portfolio_helper to avoid circular imports @@ -324,7 +343,6 @@ def validate_portfolio_invitation(portfolio_invitation): Raises: ValidationError: If any of the validation rules are violated. """ - PortfolioInvitation = apps.get_model("registrar.PortfolioInvitation") UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission") User = get_user_model() @@ -351,17 +369,12 @@ def validate_portfolio_invitation(portfolio_invitation): ) # == Validate the multiple_porfolios flag. == # - user = User.objects.filter(email=portfolio_invitation.email).first() + user = User.objects.filter(email__iexact=portfolio_invitation.email).first() # If user returns None, then we check for global assignment of multiple_portfolios. # Otherwise we just check on the user. if not flag_is_active_for_user(user, "multiple_portfolios"): - existing_permissions = UserPortfolioPermission.objects.filter(user=user) - - existing_invitations = PortfolioInvitation.objects.filter(email=portfolio_invitation.email).exclude( - Q(id=portfolio_invitation.id) | Q(status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED) - ) - + existing_permissions, existing_invitations = get_portfolio_invitation_associations(portfolio_invitation) if existing_permissions.exists(): raise ValidationError( "This user is already assigned to a portfolio. " @@ -377,6 +390,27 @@ def validate_portfolio_invitation(portfolio_invitation): ) +def get_portfolio_invitation_associations(portfolio_invitation): + """ + Retrieves the associations for a portfolio invitation. + + Returns: + A tuple: + (existing_permissions, existing_invitations) + where: + - existing_permissions: UserPortfolioPermission objects matching the email. + - existing_invitations: PortfolioInvitation objects for the email excluding + the current invitation and those with status RETRIEVED. + """ + PortfolioInvitation = apps.get_model("registrar.PortfolioInvitation") + UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission") + existing_permissions = UserPortfolioPermission.objects.filter(user__email__iexact=portfolio_invitation.email) + existing_invitations = PortfolioInvitation.objects.filter(email__iexact=portfolio_invitation.email).exclude( + Q(id=portfolio_invitation.id) | Q(status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED) + ) + return (existing_permissions, existing_invitations) + + def cleanup_after_portfolio_member_deletion(portfolio, email, user=None): """ Cleans up after removing a portfolio member or a portfolio invitation. diff --git a/src/registrar/templates/admin/app_list.html b/src/registrar/templates/admin/app_list.html index ecce12a3e..6f30e3031 100644 --- a/src/registrar/templates/admin/app_list.html +++ b/src/registrar/templates/admin/app_list.html @@ -63,6 +63,7 @@
UIDNameEmail
{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)}{escape(domain_manager.email)}
{% endfor %} + {% if perms.registrar.analyst_access_permission or perms.full_access_permission %}
@@ -78,6 +79,7 @@
Analytics
+ {% endif %} {% else %}

{% 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 %}
+ {% if not adminform.form.is_omb_analyst %} {# Dja has margin styles defined on inputs as is. Lets work with it, rather than fight it. #} + {% endif %}
- {% if original.state != original.State.DELETED %} + {% if original.state != original.State.DELETED and not adminform.form.is_omb_analyst %} Extend expiration date @@ -31,9 +33,11 @@ {% endif %} {% if original.state == original.State.READY or original.state == original.State.ON_HOLD %} + {% if not adminform.form.is_omb_analyst %} | {% endif %} - {% if original.state != original.State.DELETED %} + {% endif %} + {% if original.state != original.State.DELETED and not adminform.form.is_omb_analyst %} Remove from registry diff --git a/src/registrar/templates/django/admin/includes/contact_detail_list.html b/src/registrar/templates/django/admin/includes/contact_detail_list.html index 0baabac17..b3cdeb875 100644 --- a/src/registrar/templates/django/admin/includes/contact_detail_list.html +++ b/src/registrar/templates/django/admin/includes/contact_detail_list.html @@ -6,7 +6,11 @@ {% if show_formatted_name %} {% if user.get_formatted_name %} - {{ user.get_formatted_name }} + {% if adminform.form.show_contact_as_plain_text %} + {{ user.get_formatted_name }} + {% else %} + {{ user.get_formatted_name }} + {% endif %} {% else %} None {% endif %} diff --git a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html index f12bd67f9..dcf393d82 100644 --- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html +++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html @@ -69,7 +69,11 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% elif field.field.name == "portfolio_senior_official" %}
{% if original_object.portfolio.senior_official %} - {{ field.contents }} + {% if adminform.form.show_contact_as_plain_text %} + {{ field.contents|striptags }} + {% else %} + {{ field.contents }} + {% endif %} {% else %} No senior official found.
{% endif %} @@ -78,7 +82,11 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% if all_contacts.count > 2 %}
{% for contact in all_contacts %} - {{ contact.get_formatted_name }}{% if not forloop.last %}, {% endif %} + {% if adminform.form.show_contact_as_plain_text %} + {{ contact.get_formatted_name }}{% if not forloop.last %}, {% endif %} + {% else %} + {{ contact.get_formatted_name }}{% if not forloop.last %}, {% endif %} + {% endif %} {% endfor %}
{% else %} @@ -153,6 +161,10 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)

No additional members found.

{% endif %}
+ {% elif field.field.name == "creator" and adminform.form.show_contact_as_plain_text %} +
{{ field.contents|striptags }}
+ {% elif field.field.name == "senior_official" and adminform.form.show_contact_as_plain_text %} +
{{ field.contents|striptags }}
{% else %}
{{ field.contents }}
{% endif %} diff --git a/src/registrar/templates/django/admin/includes/portfolio/portfolio_admins_table.html b/src/registrar/templates/django/admin/includes/portfolio/portfolio_admins_table.html index 7add74323..574c05738 100644 --- a/src/registrar/templates/django/admin/includes/portfolio/portfolio_admins_table.html +++ b/src/registrar/templates/django/admin/includes/portfolio/portfolio_admins_table.html @@ -16,7 +16,11 @@ {% for admin in admins %} {% url 'admin:registrar_userportfoliopermission_change' admin.pk as url %} - {{ admin.user.get_formatted_name}} + {% if adminform.form.is_omb_analyst %} + {{ admin.user.get_formatted_name }} + {% else %} + {{ admin.user.get_formatted_name}} + {% endif %} {{ admin.user.title }} {% if admin.user.email %} diff --git a/src/registrar/templates/django/admin/includes/portfolio/portfolio_fieldset.html b/src/registrar/templates/django/admin/includes/portfolio/portfolio_fieldset.html index 87b56cb60..54ac502d1 100644 --- a/src/registrar/templates/django/admin/includes/portfolio/portfolio_fieldset.html +++ b/src/registrar/templates/django/admin/includes/portfolio/portfolio_fieldset.html @@ -30,6 +30,9 @@ No senior official found. Create one now.
{% endif %} + + {% elif field.field.name == "creator" and adminform.form.show_contact_as_plain_text %} +
{{ field.contents|striptags }}
{% else %}
{{ field.contents }}
{% endif %} diff --git a/src/registrar/templates/domain_base.html b/src/registrar/templates/domain_base.html index 249f69d32..60c931dce 100644 --- a/src/registrar/templates/domain_base.html +++ b/src/registrar/templates/domain_base.html @@ -58,7 +58,7 @@ {% if request.path|endswith:"renewal"%}

Renew {{domain.name}}

{%else%} -

Domain Overview

+

Domain overview

{% endif%} {% endblock %} {# domain_content #} diff --git a/src/registrar/templates/domain_detail.html b/src/registrar/templates/domain_detail.html index eba0eaf85..a0d477249 100644 --- a/src/registrar/templates/domain_detail.html +++ b/src/registrar/templates/domain_detail.html @@ -99,7 +99,7 @@ {% if domain.dnssecdata is not None %} {% include "includes/summary_item.html" with title='DNSSEC' value='Enabled' edit_link=url editable=is_editable %} {% else %} - {% include "includes/summary_item.html" with title='DNSSEC' value='Not Enabled' edit_link=url editable=is_editable %} + {% include "includes/summary_item.html" with title='DNSSEC' value='Not enabled' edit_link=url editable=is_editable %} {% endif %} {% if portfolio %} diff --git a/src/registrar/templates/emails/transition_domain_invitation.txt b/src/registrar/templates/emails/transition_domain_invitation.txt index 14dd626dd..35947eb72 100644 --- a/src/registrar/templates/emails/transition_domain_invitation.txt +++ b/src/registrar/templates/emails/transition_domain_invitation.txt @@ -57,7 +57,7 @@ THANK YOU The .gov team .Gov blog -Domain management <{{ manage_url }}}> +Domain management <{{ manage_url }}> Get.gov The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) 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 b7d9b7e81..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): @@ -2076,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/utility/admin_helpers.py b/src/registrar/utility/admin_helpers.py index 93a0a16b5..adbc182d0 100644 --- a/src/registrar/utility/admin_helpers.py +++ b/src/registrar/utility/admin_helpers.py @@ -1,4 +1,5 @@ from registrar.models.domain_request import DomainRequest +from django.conf import settings from django.template.loader import get_template from django.utils.html import format_html from django.urls import reverse @@ -35,8 +36,13 @@ def _get_default_email(domain_request, file_path, reason, excluded_reasons=None) return None recipient = domain_request.creator + env_base_url = settings.BASE_URL + # If NOT in prod, update instances of "manage.get.gov" links to point to + # current environment, ie "getgov-rh.app.cloud.gov" + manage_url = env_base_url if not settings.IS_PRODUCTION else "https://manage.get.gov" + # Return the context of the rendered views - context = {"domain_request": domain_request, "recipient": recipient, "reason": reason} + context = {"domain_request": domain_request, "recipient": recipient, "reason": reason, "manage_url": manage_url} email_body_text = get_template(file_path).render(context=context) email_body_text_cleaned = email_body_text.strip().lstrip("\n") if email_body_text else None diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index fad58b2e2..cde91baca 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -579,8 +579,8 @@ class DomainExport(BaseExport): Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False), then=F("portfolio__federal_agency__federal_type"), ), - # Otherwise, return the natively assigned value - default=F("federal_type"), + # Otherwise, return the federal type from federal agency + default=F("federal_agency__federal_type"), output_field=CharField(), ), "converted_organization_name": Case( @@ -1654,8 +1654,8 @@ class DomainRequestExport(BaseExport): Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False), then=F("portfolio__federal_agency__federal_type"), ), - # Otherwise, return the natively assigned value - default=F("federal_type"), + # Otherwise, return the federal type from federal agency + default=F("federal_agency__federal_type"), output_field=CharField(), ), "converted_organization_name": Case( diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py index 64987c08d..6e27b9ed4 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -227,7 +227,6 @@ class DomainRequestWizard(TemplateView): creator=self.request.user, portfolio=portfolio, ) - # Question for reviewers: we should probably be doing this right? if portfolio and not self._domain_request.generic_org_type: self._domain_request.generic_org_type = portfolio.organization_type @@ -598,7 +597,6 @@ class RequestingEntity(DomainRequestWizard): "suborganization_state_territory": None, } ) - super().save(forms) @@ -997,11 +995,9 @@ class Finished(DomainRequestWizard): forms = [] # type: ignore def get(self, request, *args, **kwargs): - context = self.get_context_data() - context["domain_request_id"] = self.domain_request.id # clean up this wizard session, because we are done with it del self.storage - return render(self.request, self.template_name, context) + return render(self.request, self.template_name) @grant_access(IS_DOMAIN_REQUEST_CREATOR, HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT) diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index c2ec44b9e..7fa421eaa 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -970,7 +970,7 @@ class PortfolioAddMemberView(DetailView, FormMixin): portfolio = form.cleaned_data["portfolio"] is_admin_invitation = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in form.cleaned_data["roles"] - requested_user = User.objects.filter(email=requested_email).first() + requested_user = User.objects.filter(email__iexact=requested_email).first() permission_exists = UserPortfolioPermission.objects.filter(user=requested_user, portfolio=portfolio).exists() try: if not requested_user or not permission_exists: diff --git a/src/registrar/views/report_views.py b/src/registrar/views/report_views.py index c07dcfc1b..7f1e63e32 100644 --- a/src/registrar/views/report_views.py +++ b/src/registrar/views/report_views.py @@ -6,7 +6,7 @@ from django.shortcuts import render from django.contrib import admin from django.db.models import Avg, F -from registrar.decorators import ALL, HAS_PORTFOLIO_MEMBERS_VIEW, IS_STAFF, grant_access +from registrar.decorators import ALL, HAS_PORTFOLIO_MEMBERS_VIEW, IS_CISA_ANALYST, IS_FULL_ACCESS, grant_access from .. import models import datetime from django.utils import timezone @@ -16,7 +16,7 @@ import logging logger = logging.getLogger(__name__) -@grant_access(IS_STAFF) +@grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS) class AnalyticsView(View): def get(self, request): thirty_days_ago = datetime.datetime.today() - datetime.timedelta(days=30) @@ -176,7 +176,7 @@ class AnalyticsView(View): return render(request, "admin/analytics.html", context) -@grant_access(IS_STAFF) +@grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS) class ExportDataType(View): def get(self, request, *args, **kwargs): # match the CSV example with all the fields @@ -227,7 +227,7 @@ class ExportMembersPortfolio(View): return response -@grant_access(IS_STAFF) +@grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS) class ExportDataFull(View): def get(self, request, *args, **kwargs): # Smaller export based on 1 @@ -237,7 +237,7 @@ class ExportDataFull(View): return response -@grant_access(IS_STAFF) +@grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS) class ExportDataFederal(View): def get(self, request, *args, **kwargs): # Federal only @@ -247,7 +247,7 @@ class ExportDataFederal(View): return response -@grant_access(IS_STAFF) +@grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS) class ExportDomainRequestDataFull(View): """Generates a downloaded report containing all Domain Requests (except started)""" @@ -259,7 +259,7 @@ class ExportDomainRequestDataFull(View): return response -@grant_access(IS_STAFF) +@grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS) class ExportDataDomainsGrowth(View): def get(self, request, *args, **kwargs): start_date = request.GET.get("start_date", "") @@ -272,7 +272,7 @@ class ExportDataDomainsGrowth(View): return response -@grant_access(IS_STAFF) +@grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS) class ExportDataRequestsGrowth(View): def get(self, request, *args, **kwargs): start_date = request.GET.get("start_date", "") @@ -285,7 +285,7 @@ class ExportDataRequestsGrowth(View): return response -@grant_access(IS_STAFF) +@grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS) class ExportDataManagedDomains(View): def get(self, request, *args, **kwargs): start_date = request.GET.get("start_date", "") @@ -297,7 +297,7 @@ class ExportDataManagedDomains(View): return response -@grant_access(IS_STAFF) +@grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS) class ExportDataUnmanagedDomains(View): def get(self, request, *args, **kwargs): start_date = request.GET.get("start_date", "") diff --git a/src/registrar/views/transfer_user.py b/src/registrar/views/transfer_user.py index 62cd0a9d2..ee8ebad35 100644 --- a/src/registrar/views/transfer_user.py +++ b/src/registrar/views/transfer_user.py @@ -4,7 +4,7 @@ from django.db.models import ForeignKey, OneToOneField, ManyToManyField, ManyToO from django.shortcuts import render, get_object_or_404, redirect from django.views import View -from registrar.decorators import IS_STAFF, grant_access +from registrar.decorators import IS_CISA_ANALYST, IS_FULL_ACCESS, grant_access from registrar.models.domain import Domain from registrar.models.domain_request import DomainRequest from registrar.models.user import User @@ -19,7 +19,7 @@ from registrar.utility.db_helpers import ignore_unique_violation logger = logging.getLogger(__name__) -@grant_access(IS_STAFF) +@grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS) class TransferUserView(View): """Transfer user methods that set up the transfer_user template and handle the forms on it.""" diff --git a/src/registrar/views/utility/api_views.py b/src/registrar/views/utility/api_views.py index 6d0a2b5ec..ea794e185 100644 --- a/src/registrar/views/utility/api_views.py +++ b/src/registrar/views/utility/api_views.py @@ -1,7 +1,7 @@ import logging from django.http import JsonResponse from django.forms.models import model_to_dict -from registrar.decorators import IS_STAFF, grant_access +from registrar.decorators import IS_CISA_ANALYST, IS_FULL_ACCESS, IS_OMB_ANALYST, grant_access from registrar.models import FederalAgency, SeniorOfficial, DomainRequest from registrar.utility.admin_helpers import get_action_needed_reason_default_email, get_rejection_reason_default_email from registrar.models.portfolio import Portfolio @@ -10,16 +10,10 @@ from registrar.utility.constants import BranchChoices logger = logging.getLogger(__name__) -@grant_access(IS_STAFF) +@grant_access(IS_CISA_ANALYST, IS_OMB_ANALYST, IS_FULL_ACCESS) def get_senior_official_from_federal_agency_json(request): """Returns federal_agency information as a JSON""" - # This API is only accessible to admins and analysts - superuser_perm = request.user.has_perm("registrar.full_access_permission") - analyst_perm = request.user.has_perm("registrar.analyst_access_permission") - if not request.user.is_authenticated or not any([analyst_perm, superuser_perm]): - return JsonResponse({"error": "You do not have access to this resource"}, status=403) - agency_name = request.GET.get("agency_name") agency = FederalAgency.objects.filter(agency=agency_name).first() senior_official = SeniorOfficial.objects.filter(federal_agency=agency).first() @@ -37,16 +31,10 @@ def get_senior_official_from_federal_agency_json(request): return JsonResponse({"error": "Senior Official not found"}, status=404) -@grant_access(IS_STAFF) +@grant_access(IS_CISA_ANALYST, IS_OMB_ANALYST, IS_FULL_ACCESS) def get_portfolio_json(request): """Returns portfolio information as a JSON""" - # This API is only accessible to admins and analysts - superuser_perm = request.user.has_perm("registrar.full_access_permission") - analyst_perm = request.user.has_perm("registrar.analyst_access_permission") - if not request.user.is_authenticated or not any([analyst_perm, superuser_perm]): - return JsonResponse({"error": "You do not have access to this resource"}, status=403) - portfolio_id = request.GET.get("id") try: portfolio = Portfolio.objects.get(id=portfolio_id) @@ -93,16 +81,10 @@ def get_portfolio_json(request): return JsonResponse(portfolio_dict) -@grant_access(IS_STAFF) +@grant_access(IS_CISA_ANALYST, IS_OMB_ANALYST, IS_FULL_ACCESS) def get_suborganization_list_json(request): """Returns suborganization list information for a portfolio as a JSON""" - # This API is only accessible to admins and analysts - superuser_perm = request.user.has_perm("registrar.full_access_permission") - analyst_perm = request.user.has_perm("registrar.analyst_access_permission") - if not request.user.is_authenticated or not any([analyst_perm, superuser_perm]): - return JsonResponse({"error": "You do not have access to this resource"}, status=403) - portfolio_id = request.GET.get("portfolio_id") try: portfolio = Portfolio.objects.get(id=portfolio_id) @@ -115,17 +97,11 @@ def get_suborganization_list_json(request): return JsonResponse({"results": results, "pagination": {"more": False}}) -@grant_access(IS_STAFF) +@grant_access(IS_CISA_ANALYST, IS_OMB_ANALYST, IS_FULL_ACCESS) def get_federal_and_portfolio_types_from_federal_agency_json(request): """Returns specific portfolio information as a JSON. Request must have both agency_name and organization_type.""" - # This API is only accessible to admins and analysts - superuser_perm = request.user.has_perm("registrar.full_access_permission") - analyst_perm = request.user.has_perm("registrar.analyst_access_permission") - if not request.user.is_authenticated or not any([analyst_perm, superuser_perm]): - return JsonResponse({"error": "You do not have access to this resource"}, status=403) - federal_type = None portfolio_type = None @@ -143,16 +119,10 @@ def get_federal_and_portfolio_types_from_federal_agency_json(request): return JsonResponse(response_data) -@grant_access(IS_STAFF) +@grant_access(IS_CISA_ANALYST, IS_OMB_ANALYST, IS_FULL_ACCESS) def get_action_needed_email_for_user_json(request): """Returns a default action needed email for a given user""" - # This API is only accessible to admins and analysts - superuser_perm = request.user.has_perm("registrar.full_access_permission") - analyst_perm = request.user.has_perm("registrar.analyst_access_permission") - if not request.user.is_authenticated or not any([analyst_perm, superuser_perm]): - return JsonResponse({"error": "You do not have access to this resource"}, status=403) - reason = request.GET.get("reason") domain_request_id = request.GET.get("domain_request_id") if not reason: @@ -167,16 +137,10 @@ def get_action_needed_email_for_user_json(request): return JsonResponse({"email": email}, status=200) -@grant_access(IS_STAFF) +@grant_access(IS_CISA_ANALYST, IS_OMB_ANALYST, IS_FULL_ACCESS) def get_rejection_email_for_user_json(request): """Returns a default rejection email for a given user""" - # This API is only accessible to admins and analysts - superuser_perm = request.user.has_perm("registrar.full_access_permission") - analyst_perm = request.user.has_perm("registrar.analyst_access_permission") - if not request.user.is_authenticated or not any([analyst_perm, superuser_perm]): - return JsonResponse({"error": "You do not have access to this resource"}, status=403) - reason = request.GET.get("reason") domain_request_id = request.GET.get("domain_request_id") if not reason: