Merge remote-tracking branch 'origin/main' into ms/3212-FEB-questions

This commit is contained in:
matthewswspence 2025-03-18 15:00:08 -05:00
commit ae03ec2314
No known key found for this signature in database
GPG key ID: FB458202A7852BA4
37 changed files with 2115 additions and 484 deletions

View file

@ -207,6 +207,17 @@ Linters:
docker-compose exec app ./manage.py lint 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 "Were 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 ### Testing behind logged in pages
To test behind logged in pages with external tools, like `pa11y-ci` or `OWASP Zap`, add 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. 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. 1. Version numbers can be manually controlled in `package.json`. Edit that, if desired.
2. Now run `docker-compose run node npm update`. 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. Then run `docker-compose up` to recompile and recopy the assets, or run `docker-compose updateUswds` if your docker is already up. 3. Make note of the dotgov changes in uswds-edited.js (Ctrl-F DOTGOV for modifications to USWDS compiled code).
4. Make note of the dotgov changes in uswds-edited.js. 4. Copy over the newly compiled code from uswds.js into uswds-edited.js.
5. 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. 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. 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 ## Finite State Machines

292
src/package-lock.json generated
View file

@ -63,23 +63,22 @@
} }
}, },
"node_modules/@babel/core": { "node_modules/@babel/core": {
"version": "7.26.8", "version": "7.26.9",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.8.tgz", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.9.tgz",
"integrity": "sha512-l+lkXCHS6tQEc5oUpK28xBOZ6+HwaH7YwoYQbLFiYb4nS2/l1tKnZEtEWkD0GuiYdvArf9qBS0XlQGXzPMsNqQ==", "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ampproject/remapping": "^2.2.0", "@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.26.2", "@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-compilation-targets": "^7.26.5",
"@babel/helper-module-transforms": "^7.26.0", "@babel/helper-module-transforms": "^7.26.0",
"@babel/helpers": "^7.26.7", "@babel/helpers": "^7.26.9",
"@babel/parser": "^7.26.8", "@babel/parser": "^7.26.9",
"@babel/template": "^7.26.8", "@babel/template": "^7.26.9",
"@babel/traverse": "^7.26.8", "@babel/traverse": "^7.26.9",
"@babel/types": "^7.26.8", "@babel/types": "^7.26.9",
"@types/gensync": "^1.0.0",
"convert-source-map": "^2.0.0", "convert-source-map": "^2.0.0",
"debug": "^4.1.0", "debug": "^4.1.0",
"gensync": "^1.0.0-beta.2", "gensync": "^1.0.0-beta.2",
@ -95,14 +94,14 @@
} }
}, },
"node_modules/@babel/generator": { "node_modules/@babel/generator": {
"version": "7.26.8", "version": "7.26.9",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.8.tgz", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.9.tgz",
"integrity": "sha512-ef383X5++iZHWAXX0SXQR6ZyQhw/0KtTkrTz61WXRhFM6dhpHulO/RJz79L8S6ugZHJkOOkUrUdxgdF2YiPFnA==", "integrity": "sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/parser": "^7.26.8", "@babel/parser": "^7.26.9",
"@babel/types": "^7.26.8", "@babel/types": "^7.26.9",
"@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25", "@jridgewell/trace-mapping": "^0.3.25",
"jsesc": "^3.0.2" "jsesc": "^3.0.2"
@ -142,18 +141,18 @@
} }
}, },
"node_modules/@babel/helper-create-class-features-plugin": { "node_modules/@babel/helper-create-class-features-plugin": {
"version": "7.25.9", "version": "7.26.9",
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.9.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.26.9.tgz",
"integrity": "sha512-UTZQMvt0d/rSz6KI+qdu7GQze5TIajwTS++GUozlw8VBJDEOAqSXwm1WvmYEZwqdqSGQshRocPDqrt4HBZB3fQ==", "integrity": "sha512-ubbUqCofvxPRurw5L8WTsCLSkQiVpov4Qx0WMA+jUN+nXBK8ADPlJO1grkFw5CWKC5+sZSOfuGMdX1aI1iT9Sg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-annotate-as-pure": "^7.25.9", "@babel/helper-annotate-as-pure": "^7.25.9",
"@babel/helper-member-expression-to-functions": "^7.25.9", "@babel/helper-member-expression-to-functions": "^7.25.9",
"@babel/helper-optimise-call-expression": "^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/helper-skip-transparent-expression-wrappers": "^7.25.9",
"@babel/traverse": "^7.25.9", "@babel/traverse": "^7.26.9",
"semver": "^6.3.1" "semver": "^6.3.1"
}, },
"engines": { "engines": {
@ -363,27 +362,27 @@
} }
}, },
"node_modules/@babel/helpers": { "node_modules/@babel/helpers": {
"version": "7.26.7", "version": "7.26.9",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.7.tgz", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.9.tgz",
"integrity": "sha512-8NHiL98vsi0mbPQmYAGWwfcFaOy4j2HY49fXJCfuDcdE7fMIsH9a7GdaeXpIBsbT7307WU8KCMp5pUVDNL4f9A==", "integrity": "sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/template": "^7.25.9", "@babel/template": "^7.26.9",
"@babel/types": "^7.26.7" "@babel/types": "^7.26.9"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/parser": { "node_modules/@babel/parser": {
"version": "7.26.8", "version": "7.26.9",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.8.tgz", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz",
"integrity": "sha512-TZIQ25pkSoaKEYYaHbbxkfL36GNsQ6iFiBbeuzAkLnXayKR1yP1zFe+NxuZWWsUyvt8icPU9CCq0sgWGXR1GEw==", "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/types": "^7.26.8" "@babel/types": "^7.26.9"
}, },
"bin": { "bin": {
"parser": "bin/babel-parser.js" "parser": "bin/babel-parser.js"
@ -809,13 +808,13 @@
} }
}, },
"node_modules/@babel/plugin-transform-for-of": { "node_modules/@babel/plugin-transform-for-of": {
"version": "7.25.9", "version": "7.26.9",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.25.9.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.26.9.tgz",
"integrity": "sha512-LqHxduHoaGELJl2uhImHwRQudhCM50pT46rIBNvtT/Oql3nqiS3wOwP+5ten7NpYSXrrVLgtZU3DZmPtWZo16A==", "integrity": "sha512-Hry8AusVm8LW5BVFgiyUReuoGzPUpdHQQqJY5bZnbbf+ngOHWuCuYFKw/BqaaWlvEUrF91HMhDtEaI1hZzNbLg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.25.9", "@babel/helper-plugin-utils": "^7.26.5",
"@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9"
}, },
"engines": { "engines": {
@ -1376,9 +1375,9 @@
} }
}, },
"node_modules/@babel/preset-env": { "node_modules/@babel/preset-env": {
"version": "7.26.8", "version": "7.26.9",
"resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.8.tgz", "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.9.tgz",
"integrity": "sha512-um7Sy+2THd697S4zJEfv/U5MHGJzkN2xhtsR3T/SWRbVSic62nbISh51VVfU9JiO/L/Z97QczHTaFVkOU8IzNg==", "integrity": "sha512-vX3qPGE8sEKEAZCWk05k3cpTAE3/nOYca++JA+Rd0z2NCNzabmYvEiSShKzm10zdquOIAVXsy2Ei/DTW34KlKQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1411,7 +1410,7 @@
"@babel/plugin-transform-dynamic-import": "^7.25.9", "@babel/plugin-transform-dynamic-import": "^7.25.9",
"@babel/plugin-transform-exponentiation-operator": "^7.26.3", "@babel/plugin-transform-exponentiation-operator": "^7.26.3",
"@babel/plugin-transform-export-namespace-from": "^7.25.9", "@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-function-name": "^7.25.9",
"@babel/plugin-transform-json-strings": "^7.25.9", "@babel/plugin-transform-json-strings": "^7.25.9",
"@babel/plugin-transform-literals": "^7.25.9", "@babel/plugin-transform-literals": "^7.25.9",
@ -1475,9 +1474,9 @@
} }
}, },
"node_modules/@babel/runtime": { "node_modules/@babel/runtime": {
"version": "7.26.7", "version": "7.26.9",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.7.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz",
"integrity": "sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ==", "integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1488,32 +1487,32 @@
} }
}, },
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.26.8", "version": "7.26.9",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.8.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz",
"integrity": "sha512-iNKaX3ZebKIsCvJ+0jd6embf+Aulaa3vNBqZ41kM7iTWjx5qzWKXGHiJUW3+nTpQ18SG11hdF8OAzKrpXkb96Q==", "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.26.2", "@babel/code-frame": "^7.26.2",
"@babel/parser": "^7.26.8", "@babel/parser": "^7.26.9",
"@babel/types": "^7.26.8" "@babel/types": "^7.26.9"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/traverse": { "node_modules/@babel/traverse": {
"version": "7.26.8", "version": "7.26.9",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.8.tgz", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.9.tgz",
"integrity": "sha512-nic9tRkjYH0oB2dzr/JoGIm+4Q6SuYeLEiIiZDwBscRMYFJ+tMAz98fuel9ZnbXViA2I0HVSSRRK8DW5fjXStA==", "integrity": "sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.26.2", "@babel/code-frame": "^7.26.2",
"@babel/generator": "^7.26.8", "@babel/generator": "^7.26.9",
"@babel/parser": "^7.26.8", "@babel/parser": "^7.26.9",
"@babel/template": "^7.26.8", "@babel/template": "^7.26.9",
"@babel/types": "^7.26.8", "@babel/types": "^7.26.9",
"debug": "^4.3.1", "debug": "^4.3.1",
"globals": "^11.1.0" "globals": "^11.1.0"
}, },
@ -1522,9 +1521,9 @@
} }
}, },
"node_modules/@babel/types": { "node_modules/@babel/types": {
"version": "7.26.8", "version": "7.26.9",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.8.tgz", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz",
"integrity": "sha512-eUuWapzEGWFEpHFxgEaBG8e3n6S8L3MSu0oda755rOfabWPnh0Our1AozNFVUxGFIhbKgd1ksprsoDGMinTOTA==", "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1999,13 +1998,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@ -2014,9 +2006,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.13.1", "version": "22.13.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.9.tgz",
"integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==", "integrity": "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -2255,9 +2247,9 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.14.0", "version": "8.14.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@ -2868,9 +2860,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001699", "version": "1.0.30001702",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001699.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001702.tgz",
"integrity": "sha512-b+uH5BakXZ9Do9iK+CkDmctUSEqZl+SP056vc5usa0PL+ev5OHw003rZXcnjNDv3L8P5j6rwT6C0BPKSikW08w==", "integrity": "sha512-LoPe/D7zioC0REI5W73PeR1e1MLCipRGq/VkovJnd6Df+QVqT+vT33OXCp8QUd7kA7RZrHWxb1B36OQKI/0gOA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -3142,13 +3134,13 @@
} }
}, },
"node_modules/core-js-compat": { "node_modules/core-js-compat": {
"version": "3.40.0", "version": "3.41.0",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.40.0.tgz", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.41.0.tgz",
"integrity": "sha512-0XEDpr5y5mijvw8Lbc6E5AkjrHfp7eEoPlu36SWeAbcL8fn1G1ANe8DBlo2XoNN89oVpxWwOjYIPVzR4ZvsKCQ==", "integrity": "sha512-RFsU9LySVue9RTwdDVX/T0e2Y6jRYWXERKElIjpuEOEnxaXffI0X7RUwVzfYLfzuLXSNJDYoRYUAmRUcyln20A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"browserslist": "^4.24.3" "browserslist": "^4.24.4"
}, },
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@ -3371,9 +3363,9 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.97", "version": "1.5.113",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.97.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.113.tgz",
"integrity": "sha512-HKLtaH02augM7ZOdYRuO19rWDeY+QSJ1VxnXFa/XDFLf07HvM90pALIJFgrO+UVaajI3+aJMMpojoUTLZyQ7JQ==", "integrity": "sha512-wjT2O4hX+wdWPJ76gWSkMhcHAV2PTMX+QetUCPYEdCIe+cxmgzzSSiGRCKW8nuh4mwKZlpv0xvoW7OF2X+wmHg==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@ -3661,13 +3653,6 @@
"node": ">=8.6.0" "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": { "node_modules/fast-levenshtein": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-3.0.0.tgz", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-3.0.0.tgz",
@ -3706,9 +3691,9 @@
} }
}, },
"node_modules/fastq": { "node_modules/fastq": {
"version": "1.19.0", "version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.0.tgz", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
"integrity": "sha512-7SFSRCNjBQIZH/xZR3iy5iQYR8aGBE0h3VG6/cwlbrpdciNYBMotQav8c1XI3HjHH+NikUpP53nPdlZSdWmFzA==", "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
@ -5718,16 +5703,6 @@
"once": "^1.3.1" "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": { "node_modules/puppeteer": {
"version": "9.1.1", "version": "9.1.1",
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-9.1.1.tgz", "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-9.1.1.tgz",
@ -6113,9 +6088,9 @@
} }
}, },
"node_modules/reusify": { "node_modules/reusify": {
"version": "1.0.4", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -6164,9 +6139,9 @@
} }
}, },
"node_modules/rxjs": { "node_modules/rxjs": {
"version": "7.8.1", "version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@ -6186,9 +6161,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/sass": { "node_modules/sass": {
"version": "1.84.0", "version": "1.85.1",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.84.0.tgz", "resolved": "https://registry.npmjs.org/sass/-/sass-1.85.1.tgz",
"integrity": "sha512-XDAbhEPJRxi7H0SxrnOpiXFQoUJHwkR2u3Zc4el+fK/Tt5Hpzw5kkQ59qVDfvdaUq6gCrEZIbySFBM2T9DNKHg==", "integrity": "sha512-Uk8WpxM5v+0cMR0XjX9KfRIacmSG86RH4DCCZjLU2rFh5tyutt9siAXJ7G+YfxQ99Q6wrRMbMlVl6KqUms71ag==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"chokidar": "^4.0.0", "chokidar": "^4.0.0",
@ -6661,9 +6636,9 @@
} }
}, },
"node_modules/sass/node_modules/readdirp": { "node_modules/sass/node_modules/readdirp": {
"version": "4.1.1", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.1.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-h80JrZu/MHUZCyHu5ciuoI0+WxsCxzxJTILn6Fs8rxSnFPh+UVHYfeIxK1nVGugMqkfC4vJcBOYbkfkwYK0+gw==", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 14.18.0" "node": ">= 14.18.0"
@ -6978,9 +6953,9 @@
} }
}, },
"node_modules/terser": { "node_modules/terser": {
"version": "5.38.2", "version": "5.39.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.38.2.tgz", "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz",
"integrity": "sha512-w8CXxxbFA5zfNsR/i8HZq5bvn18AK0O9jj7hyo1YqkovLxEFa0uP0LCVGZRqiRaKRFxXhELBp8SteeAjEnfeJg==", "integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
@ -6997,9 +6972,9 @@
} }
}, },
"node_modules/terser-webpack-plugin": { "node_modules/terser-webpack-plugin": {
"version": "5.3.11", "version": "5.3.14",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.11.tgz", "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz",
"integrity": "sha512-RVCsMfuD0+cTt3EwX8hSl2Ks56EbFHWmhluwcqoPKtBnfjiT6olaq7PRIRfhyU8nnC2MrnDrBLfrD/RGE+cVXQ==", "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -7229,9 +7204,9 @@
} }
}, },
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.1.2", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
"integrity": "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==", "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -7259,16 +7234,6 @@
"browserslist": ">= 4.21.0" "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": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@ -7522,9 +7487,9 @@
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/webpack": { "node_modules/webpack": {
"version": "5.97.1", "version": "5.98.0",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.97.1.tgz", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.98.0.tgz",
"integrity": "sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg==", "integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -7546,9 +7511,9 @@
"loader-runner": "^4.2.0", "loader-runner": "^4.2.0",
"mime-types": "^2.1.27", "mime-types": "^2.1.27",
"neo-async": "^2.6.2", "neo-async": "^2.6.2",
"schema-utils": "^3.2.0", "schema-utils": "^4.3.0",
"tapable": "^2.1.1", "tapable": "^2.1.1",
"terser-webpack-plugin": "^5.3.10", "terser-webpack-plugin": "^5.3.11",
"watchpack": "^2.4.1", "watchpack": "^2.4.1",
"webpack-sources": "^3.2.3" "webpack-sources": "^3.2.3"
}, },
@ -7617,59 +7582,6 @@
"url": "https://github.com/chalk/supports-color?sponsor=1" "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": { "node_modules/whatwg-encoding": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
@ -7842,9 +7754,9 @@
} }
}, },
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "1.1.1", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.0.tgz",
"integrity": "sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==", "integrity": "sha512-KHBC7z61OJeaMGnF3wqNZj+GGNXOyypZviiKpQeiHirG5Ib1ImwcLBH70rbMSkKfSmUNBsdf2PwaEJtKvgmkNw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {

View file

@ -75,6 +75,19 @@ from django.utils.translation import gettext_lazy as _
logger = logging.getLogger(__name__) 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): class FsmModelResource(resources.ModelResource):
"""ModelResource is extended to support importing of tables which """ModelResource is extended to support importing of tables which
have FSMFields. ModelResource is extended with the following changes 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 # only set the available transitions if the user is not restricted
# from editing the domain request; otherwise, the form will be # from editing the domain request; otherwise, the form will be
# readonly and the status field will not have a widget # 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 self.fields["status"].widget.choices = available_transitions
def get_custom_field_transitions(self, instance, field): def get_custom_field_transitions(self, instance, field):
@ -919,7 +932,7 @@ class ListHeaderAdmin(AuditedAdmin, OrderableFieldsMixin):
return filters return filters
class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin): class MyUserAdmin(BaseUserAdmin, ImportExportRegistrarModelAdmin):
"""Custom user admin class to use our inlines.""" """Custom user admin class to use our inlines."""
resource_classes = [UserResource] resource_classes = [UserResource]
@ -1224,7 +1237,7 @@ class HostResource(resources.ModelResource):
model = models.Host model = models.Host
class MyHostAdmin(AuditedAdmin, ImportExportModelAdmin): class MyHostAdmin(AuditedAdmin, ImportExportRegistrarModelAdmin):
"""Custom host admin class to use our inlines.""" """Custom host admin class to use our inlines."""
resource_classes = [HostResource] resource_classes = [HostResource]
@ -1242,7 +1255,7 @@ class HostIpResource(resources.ModelResource):
model = models.HostIP model = models.HostIP
class HostIpAdmin(AuditedAdmin, ImportExportModelAdmin): class HostIpAdmin(AuditedAdmin, ImportExportRegistrarModelAdmin):
"""Custom host ip admin class""" """Custom host ip admin class"""
resource_classes = [HostIpResource] resource_classes = [HostIpResource]
@ -1257,7 +1270,7 @@ class ContactResource(resources.ModelResource):
model = models.Contact model = models.Contact
class ContactAdmin(ListHeaderAdmin, ImportExportModelAdmin): class ContactAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin):
"""Custom contact admin class to add search.""" """Custom contact admin class to add search."""
resource_classes = [ContactResource] resource_classes = [ContactResource]
@ -1391,6 +1404,59 @@ class SeniorOfficialAdmin(ListHeaderAdmin):
# in autocomplete_fields for Senior Official # in autocomplete_fields for Senior Official
ordering = ["first_name", "last_name"] 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): class WebsiteResource(resources.ModelResource):
"""defines how each field in the referenced model should be mapped to the corresponding fields in the """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 model = models.Website
class WebsiteAdmin(ListHeaderAdmin, ImportExportModelAdmin): class WebsiteAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin):
"""Custom website admin class.""" """Custom website admin class."""
resource_classes = [WebsiteResource] resource_classes = [WebsiteResource]
@ -1501,7 +1567,7 @@ class UserPortfolioPermissionAdmin(ListHeaderAdmin):
obj.delete() # Calls the overridden delete method on each instance obj.delete() # Calls the overridden delete method on each instance
class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin): class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin):
"""Custom user domain role admin class.""" """Custom user domain role admin class."""
resource_classes = [UserDomainRoleResource] resource_classes = [UserDomainRoleResource]
@ -1684,6 +1750,63 @@ class DomainInvitationAdmin(BaseInvitationAdmin):
# Override for the delete confirmation page on the domain table (bulk delete action) # 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" 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): 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""" """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) requested_user = get_requested_user(requested_email)
permission_exists = UserPortfolioPermission.objects.filter( 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() ).exists()
if not permission_exists: if not permission_exists:
# if permission does not exist for a user with requested_email, send email # if permission does not exist for a user with requested_email, send email
@ -1856,9 +1979,7 @@ class PortfolioInvitationAdmin(BaseInvitationAdmin):
portfolio=portfolio, portfolio=portfolio,
is_admin_invitation=is_admin_invitation, is_admin_invitation=is_admin_invitation,
): ):
messages.warning( messages.warning(request, "Could not send email notification to existing organization admins.")
self.request, "Could not send email notification to existing organization admins."
)
# if user exists for email, immediately retrieve portfolio invitation upon creation # if user exists for email, immediately retrieve portfolio invitation upon creation
if requested_user is not None: if requested_user is not None:
obj.retrieve() obj.retrieve()
@ -1907,7 +2028,7 @@ class DomainInformationResource(resources.ModelResource):
model = models.DomainInformation model = models.DomainInformation
class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): class DomainInformationAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin):
"""Customize domain information admin class.""" """Customize domain information admin class."""
class GenericOrgFilter(admin.SimpleListFilter): class GenericOrgFilter(admin.SimpleListFilter):
@ -2184,6 +2305,47 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"is_policy_acknowledged", "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 # For each filter_horizontal, init in admin js initFilterHorizontalWidget
# to activate the edit/delete/view buttons # to activate the edit/delete/view buttons
filter_horizontal = ("other_contacts",) filter_horizontal = ("other_contacts",)
@ -2212,6 +2374,10 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
if request.user.has_perm("registrar.full_access_permission"): if request.user.has_perm("registrar.full_access_permission"):
return readonly_fields 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 # Return restrictive Read-only fields for analysts and
# users who might not belong to groups # users who might not belong to groups
readonly_fields.extend([field for field in self.analyst_readonly_fields]) 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" use_sort = db_field.name != "senior_official"
return super().formfield_for_foreignkey(db_field, request, use_admin_sort_fields=use_sort, **kwargs) 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): class DomainRequestResource(FsmModelResource):
"""defines how each field in the referenced model should be mapped to the corresponding fields in the """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 model = models.DomainRequest
class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): class DomainRequestAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin):
"""Custom domain requests admin class.""" """Custom domain requests admin class."""
resource_classes = [DomainRequestResource] resource_classes = [DomainRequestResource]
@ -2295,7 +2493,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
class FederalTypeFilter(admin.SimpleListFilter): class FederalTypeFilter(admin.SimpleListFilter):
"""Custom Federal Type filter that accomodates portfolio feature. """Custom Federal Type filter that accomodates portfolio feature.
If we have a portfolio, use the portfolio's federal type. If not, use the 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" title = "federal type"
parameter_name = "converted_federal_types" parameter_name = "converted_federal_types"
@ -2336,7 +2534,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
if self.value(): if self.value():
return queryset.filter( return queryset.filter(
Q(portfolio__federal_agency__federal_type=self.value()) 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 return queryset
@ -2751,6 +2949,62 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"cisa_representative_email", "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 = [ autocomplete_fields = [
"approved_domain", "approved_domain",
"requested_domain", "requested_domain",
@ -2991,6 +3245,10 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
if request.user.has_perm("registrar.full_access_permission"): if request.user.has_perm("registrar.full_access_permission"):
return readonly_fields 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 # Return restrictive Read-only fields for analysts and
# users who might not belong to groups # users who might not belong to groups
readonly_fields.extend([field for field in self.analyst_readonly_fields]) 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" use_sort = db_field.name != "senior_official"
return super().formfield_for_foreignkey(db_field, request, use_admin_sort_fields=use_sort, **kwargs) 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): def get_queryset(self, request):
"""Custom get_queryset to filter by portfolio if portfolio is in the """Custom get_queryset to filter by portfolio if portfolio is in the
request params.""" request params."""
@ -3183,8 +3460,39 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
if portfolio_id: if portfolio_id:
# Further filter the queryset by the portfolio # Further filter the queryset by the portfolio
qs = qs.filter(portfolio=portfolio_id) 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 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): def get_search_results(self, request, queryset, search_term):
# Call the parent's method to apply default search logic # Call the parent's method to apply default search logic
base_queryset, use_distinct = super().get_search_results(request, queryset, search_term) 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 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): class TransitionDomainAdmin(ListHeaderAdmin):
"""Custom transition domain admin class.""" """Custom transition domain admin class."""
@ -3235,6 +3552,16 @@ class DomainInformationInline(admin.StackedInline):
template = "django/admin/includes/domain_info_inline_stacked.html" template = "django/admin/includes/domain_info_inline_stacked.html"
model = models.DomainInformation 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 # Define methods to display fields from the related portfolio
def portfolio_senior_official(self, obj) -> Optional[SeniorOfficial]: def portfolio_senior_official(self, obj) -> Optional[SeniorOfficial]:
return obj.portfolio.senior_official if obj.portfolio and obj.portfolio.senior_official else None 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)) fieldsets = copy.deepcopy(list(DomainInformationAdmin.fieldsets))
readonly_fields = copy.deepcopy(DomainInformationAdmin.readonly_fields) readonly_fields = copy.deepcopy(DomainInformationAdmin.readonly_fields)
analyst_readonly_fields = copy.deepcopy(DomainInformationAdmin.analyst_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) autocomplete_fields = copy.deepcopy(DomainInformationAdmin.autocomplete_fields)
def get_domain_managers(self, obj): def get_domain_managers(self, obj):
@ -3322,12 +3650,16 @@ class DomainInformationInline(admin.StackedInline):
if not domain_managers: if not domain_managers:
return "No domain managers found." return "No domain managers found."
domain_manager_details = "<table><thead><tr><th>UID</th><th>Name</th><th>Email</th></tr></thead><tbody>" domain_manager_details = "<table><thead><tr>"
if not self.is_omb_analyst:
domain_manager_details += "<th>UID</th>"
domain_manager_details += "<th>Name</th><th>Email</th></tr></thead><tbody>"
for domain_manager in domain_managers: for domain_manager in domain_managers:
full_name = domain_manager.get_formatted_name() full_name = domain_manager.get_formatted_name()
change_url = reverse("admin:registrar_user_change", args=[domain_manager.pk]) change_url = reverse("admin:registrar_user_change", args=[domain_manager.pk])
domain_manager_details += "<tr>" domain_manager_details += "<tr>"
domain_manager_details += f'<td><a href="{change_url}">{escape(domain_manager.username)}</a>' if not self.is_omb_analyst:
domain_manager_details += f'<td><a href="{change_url}">{escape(domain_manager.username)}</a>'
domain_manager_details += f"<td>{escape(full_name)}</td>" domain_manager_details += f"<td>{escape(full_name)}</td>"
domain_manager_details += f"<td>{escape(domain_manager.email)}</td>" domain_manager_details += f"<td>{escape(domain_manager.email)}</td>"
domain_manager_details += "</tr>" domain_manager_details += "</tr>"
@ -3359,7 +3691,8 @@ class DomainInformationInline(admin.StackedInline):
superuser_perm = request.user.has_perm("registrar.full_access_permission") superuser_perm = request.user.has_perm("registrar.full_access_permission")
analyst_perm = request.user.has_perm("registrar.analyst_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 True
return super().has_change_permission(request, obj) return super().has_change_permission(request, obj)
@ -3433,6 +3766,23 @@ class DomainInformationInline(admin.StackedInline):
return modified_fieldsets 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): class DomainResource(FsmModelResource):
"""defines how each field in the referenced model should be mapped to the corresponding fields in the """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 model = models.Domain
class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): class DomainAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin):
"""Custom domain admin class to add extra buttons.""" """Custom domain admin class to add extra buttons."""
resource_classes = [DomainResource] resource_classes = [DomainResource]
@ -3554,7 +3904,7 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
if self.value(): if self.value():
return queryset.filter( return queryset.filter(
Q(domain_info__portfolio__federal_type=self.value()) 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 return queryset
@ -3581,7 +3931,7 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
Q(domain_info__portfolio__isnull=False) & Q(domain_info__portfolio__federal_agency__isnull=False), Q(domain_info__portfolio__isnull=False) & Q(domain_info__portfolio__federal_agency__isnull=False),
then=F("domain_info__portfolio__federal_agency__federal_type"), 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"), default=F("domain_info__federal_agency__federal_type"),
), ),
converted_organization_name=Case( converted_organization_name=Case(
@ -4008,8 +4358,10 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
# Fixes a bug wherein users which are only is_staff # Fixes a bug wherein users which are only is_staff
# can access 'change' when GET, # can access 'change' when GET,
# but cannot access this page when it is a request of type POST. # 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( if (
"registrar.analyst_access_permission" 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 True
return super().has_change_permission(request, obj) return super().has_change_permission(request, obj)
@ -4024,8 +4376,37 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
if portfolio_id: if portfolio_id:
# Further filter the queryset by the portfolio # Further filter the queryset by the portfolio
qs = qs.filter(domain_info__portfolio=portfolio_id) 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 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): class DraftDomainResource(resources.ModelResource):
"""defines how each field in the referenced model should be mapped to the corresponding fields in the """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 model = models.DraftDomain
class DraftDomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): class DraftDomainAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin):
"""Custom draft domain admin class.""" """Custom draft domain admin class."""
resource_classes = [DraftDomainResource] resource_classes = [DraftDomainResource]
@ -4147,7 +4528,7 @@ class PublicContactResource(resources.ModelResource):
self.after_save_instance(instance, using_transactions, dry_run) self.after_save_instance(instance, using_transactions, dry_run)
class PublicContactAdmin(ListHeaderAdmin, ImportExportModelAdmin): class PublicContactAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin):
"""Custom PublicContact admin class.""" """Custom PublicContact admin class."""
resource_classes = [PublicContactResource] resource_classes = [PublicContactResource]
@ -4202,6 +4583,11 @@ class PortfolioAdmin(ListHeaderAdmin):
_meta = Meta() _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" change_form_template = "django/admin/portfolio_change_form.html"
fieldsets = [ fieldsets = [
# created_on is the created_at field # created_on is the created_at field
@ -4283,6 +4669,19 @@ class PortfolioAdmin(ListHeaderAdmin):
# rather than strip it out of our logic. # rather than strip it out of our logic.
analyst_readonly_fields = [] # type: ignore 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): def get_admin_users(self, obj):
# Filter UserPortfolioPermission objects related to the portfolio # Filter UserPortfolioPermission objects related to the portfolio
admin_permissions = self.get_user_portfolio_permission_admins(obj) admin_permissions = self.get_user_portfolio_permission_admins(obj)
@ -4368,6 +4767,8 @@ class PortfolioAdmin(ListHeaderAdmin):
"""Returns the number of administrators for this portfolio""" """Returns the number of administrators for this portfolio"""
admin_count = len(self.get_user_portfolio_permission_admins(obj)) admin_count = len(self.get_user_portfolio_permission_admins(obj))
if admin_count > 0: 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}" url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={obj.id}"
# Create a clickable link with the count # Create a clickable link with the count
return format_html(f'<a href="{url}">{admin_count} admins</a>') return format_html(f'<a href="{url}">{admin_count} admins</a>')
@ -4379,6 +4780,8 @@ class PortfolioAdmin(ListHeaderAdmin):
"""Returns the number of basic members for this portfolio""" """Returns the number of basic members for this portfolio"""
member_count = len(self.get_user_portfolio_permission_non_admins(obj)) member_count = len(self.get_user_portfolio_permission_non_admins(obj))
if member_count > 0: 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}" url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={obj.id}"
# Create a clickable link with the count # Create a clickable link with the count
return format_html(f'<a href="{url}">{member_count} basic members</a>') return format_html(f'<a href="{url}">{member_count} basic members</a>')
@ -4424,12 +4827,35 @@ class PortfolioAdmin(ListHeaderAdmin):
if request.user.has_perm("registrar.full_access_permission"): if request.user.has_perm("registrar.full_access_permission"):
return readonly_fields 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 # Return restrictive Read-only fields for analysts and
# users who might not belong to groups # users who might not belong to groups
readonly_fields.extend([field for field in self.analyst_readonly_fields]) readonly_fields.extend([field for field in self.analyst_readonly_fields])
return 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): def change_view(self, request, object_id, form_url="", extra_context=None):
"""Add related suborganizations and domain groups. """Add related suborganizations and domain groups.
Add the summary for the portfolio members field (list of members that link to change_forms).""" 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) 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): class FederalAgencyResource(resources.ModelResource):
"""defines how each field in the referenced model should be mapped to the corresponding fields in the """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 model = models.FederalAgency
class FederalAgencyAdmin(ListHeaderAdmin, ImportExportModelAdmin): class FederalAgencyAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin):
list_display = ["agency"] list_display = ["agency"]
search_fields = ["agency"] search_fields = ["agency"]
search_help_text = "Search by federal agency." search_help_text = "Search by federal agency."
ordering = ["agency"] ordering = ["agency"]
resource_classes = [FederalAgencyResource] 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): class UserGroupAdmin(AuditedAdmin):
"""Overwrite the generated UserGroup admin class""" """Overwrite the generated UserGroup admin class"""
@ -4539,11 +5029,11 @@ class WaffleFlagAdmin(FlagAdmin):
return super().changelist_view(request, extra_context=extra_context) return super().changelist_view(request, extra_context=extra_context)
class DomainGroupAdmin(ListHeaderAdmin, ImportExportModelAdmin): class DomainGroupAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin):
list_display = ["name", "portfolio"] list_display = ["name", "portfolio"]
class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin): class SuborganizationAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin):
list_display = ["name", "portfolio"] list_display = ["name", "portfolio"]
autocomplete_fields = [ autocomplete_fields = [
@ -4554,6 +5044,38 @@ class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
change_form_template = "django/admin/suborg_change_form.html" 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): def change_view(self, request, object_id, form_url="", extra_context=None):
"""Add suborg's related domains and requests to context""" """Add suborg's related domains and requests to context"""
obj = self.get_object(request, object_id) obj = self.get_object(request, object_id)
@ -4571,6 +5093,30 @@ class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
extra_context = {"domain_requests": domain_requests, "domains": domains} extra_context = {"domain_requests": domain_requests, "domains": domains}
return super().change_view(request, object_id, form_url, extra_context) 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 AllowedEmailAdmin(ListHeaderAdmin):
class Meta: class Meta:

View file

@ -105,8 +105,10 @@ export function initApprovedDomain() {
return; 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 statusSelect = document.getElementById("id_status");
const statusField = document.querySelector("field-status");
const sessionVariableName = "showApprovedDomain"; const sessionVariableName = "showApprovedDomain";
let approvedDomainFormGroup = document.querySelector(".field-approved_domain"); let approvedDomainFormGroup = document.querySelector(".field-approved_domain");
@ -120,18 +122,32 @@ export function initApprovedDomain() {
// Handle showing/hiding the related fields on page load. // Handle showing/hiding the related fields on page load.
function initializeFormGroups() { 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. // Initial handling of these groups.
updateFormGroupVisibility(isStatus); updateFormGroupVisibility(isStatus);
// Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage if (statusSelect) {
statusSelect.addEventListener('change', () => { // Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage
// Show the approved if the status is what we expect. statusSelect.addEventListener('change', () => {
isStatus = statusSelect.value == statusToCheck; // Show the approved if the status is what we expect.
updateFormGroupVisibility(isStatus); isStatus = statusSelect.value == statusToCheck;
addOrRemoveSessionBoolean(sessionVariableName, isStatus); updateFormGroupVisibility(isStatus);
}); addOrRemoveSessionBoolean(sessionVariableName, isStatus);
});
}
// Listen to Back/Forward button navigation and handle approvedDomainFormGroup display based on session storage // 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 // 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 {HTMLElement} modalConfirm - The confirm button in the modal.
* @property {string} apiUrl - The API URL for fetching email content. * @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} 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} sessionVariableName - The session variable name. Used for show/hide on textAreaFormGroup/dropdownFormGroup.
* @property {string} apiErrorMessage - The error message that the ajax call returns. * @property {string} apiErrorMessage - The error message that the ajax call returns.
*/ */
@ -338,6 +355,7 @@ class CustomizableEmailBase {
this.textAreaFormGroup = config.textAreaFormGroup; this.textAreaFormGroup = config.textAreaFormGroup;
this.dropdownFormGroup = config.dropdownFormGroup; this.dropdownFormGroup = config.dropdownFormGroup;
this.statusToCheck = config.statusToCheck; this.statusToCheck = config.statusToCheck;
this.readonlyStatusToCheck = config.readonlyStatusToCheck;
this.sessionVariableName = config.sessionVariableName; this.sessionVariableName = config.sessionVariableName;
// Non-configurable variables // Non-configurable variables
@ -363,19 +381,31 @@ class CustomizableEmailBase {
// Handle showing/hiding the related fields on page load. // Handle showing/hiding the related fields on page load.
initializeFormGroups() { 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. // Initial handling of these groups.
this.updateFormGroupVisibility(isStatus); this.updateFormGroupVisibility(isStatus);
// Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage if (this.statusSelect) {
this.statusSelect.addEventListener('change', () => { // Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage
// Show the action needed field if the status is what we expect. this.statusSelect.addEventListener('change', () => {
// Then track if its shown or hidden in our session cache. // Show the action needed field if the status is what we expect.
isStatus = this.statusSelect.value == this.statusToCheck; // Then track if its shown or hidden in our session cache.
this.updateFormGroupVisibility(isStatus); isStatus = this.statusSelect.value == this.statusToCheck;
addOrRemoveSessionBoolean(this.sessionVariableName, isStatus); this.updateFormGroupVisibility(isStatus);
}); addOrRemoveSessionBoolean(this.sessionVariableName, isStatus);
});
}
// Listen to Back/Forward button navigation and handle rejectionReasonFormGroup display based on session storage // 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 // 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() { initializeDropdown() {
this.dropdown.addEventListener("change", () => { if (this.dropdown) {
let reason = this.dropdown.value; this.dropdown.addEventListener("change", () => {
if (this.initialDropdownValue !== this.dropdown.value || this.initialEmailValue !== this.textarea.value) { let reason = this.dropdown.value;
let searchParams = new URLSearchParams( if (this.initialDropdownValue !== this.dropdown.value || this.initialEmailValue !== this.textarea.value) {
{ let searchParams = new URLSearchParams(
"reason": reason, {
"domain_request_id": this.domainRequestId, "reason": reason,
} "domain_request_id": this.domainRequestId,
); }
// Replace the email content );
fetch(`${this.apiUrl}?${searchParams.toString()}`) // Replace the email content
.then(response => { fetch(`${this.apiUrl}?${searchParams.toString()}`)
return response.json().then(data => data); .then(response => {
}) return response.json().then(data => data);
.then(data => { })
if (data.error) { .then(data => {
console.error("Error in AJAX call: " + data.error); if (data.error) {
}else { console.error("Error in AJAX call: " + data.error);
this.textarea.value = data.email; }else {
} this.textarea.value = data.email;
this.updateUserInterface(reason); }
}) this.updateUserInterface(reason);
.catch(error => { })
console.error(this.apiErrorMessage, error) .catch(error => {
}); console.error(this.apiErrorMessage, error)
} });
}); }
});
}
} }
initializeModalConfirm() { initializeModalConfirm() {
this.modalConfirm.addEventListener("click", () => { // When the modal confirm button is present, add a listener
this.textarea.removeAttribute('readonly'); if (this.modalConfirm) {
this.textarea.focus(); this.modalConfirm.addEventListener("click", () => {
this.textarea.removeAttribute('readonly');
this.textarea.focus();
hideElement(this.directEditButton); hideElement(this.directEditButton);
hideElement(this.modalTrigger); hideElement(this.modalTrigger);
}); });
}
} }
initializeDirectEditButton() { initializeDirectEditButton() {
this.directEditButton.addEventListener("click", () => { // When the direct edit button is present, add a listener
this.textarea.removeAttribute('readonly'); if (this.directEditButton) {
this.textarea.focus(); this.directEditButton.addEventListener("click", () => {
this.textarea.removeAttribute('readonly');
this.textarea.focus();
hideElement(this.directEditButton); hideElement(this.directEditButton);
hideElement(this.modalTrigger); hideElement(this.modalTrigger);
}); });
}
} }
isEmailAlreadySent() { isEmailAlreadySent() {
return this.lastSentEmailContent.value.replace(/\s+/g, '') === this.textarea.value.replace(/\s+/g, ''); 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) { 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 // 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(); this.showPlaceholderNoReason();
@ -468,23 +506,25 @@ class CustomizableEmailBase {
// Helper function that makes overriding the readonly textarea easy // Helper function that makes overriding the readonly textarea easy
showReadonlyTextarea() { showReadonlyTextarea() {
// A triggering selection is selected, all hands on board: if (this.textarea && this.textareaPlaceholder) {
this.textarea.setAttribute('readonly', true); // A triggering selection is selected, all hands on board:
showElement(this.textarea); this.textarea.setAttribute('readonly', true);
hideElement(this.textareaPlaceholder); showElement(this.textarea);
hideElement(this.textareaPlaceholder);
if (this.isEmailAlreadySentConst) { if (this.isEmailAlreadySentConst) {
hideElement(this.directEditButton); hideElement(this.directEditButton);
showElement(this.modalTrigger); showElement(this.modalTrigger);
} else {
showElement(this.directEditButton);
hideElement(this.modalTrigger);
}
if (this.isEmailAlreadySent()) {
this.formLabel.innerHTML = "Email sent to creator:";
} else { } else {
showElement(this.directEditButton); this.formLabel.innerHTML = "Email:";
hideElement(this.modalTrigger); }
}
if (this.isEmailAlreadySent()) {
this.formLabel.innerHTML = "Email sent to creator:";
} else {
this.formLabel.innerHTML = "Email:";
} }
} }
@ -516,9 +556,10 @@ class customActionNeededEmail extends CustomizableEmailBase {
lastSentEmailContent: document.getElementById("last-sent-action-needed-email-content"), lastSentEmailContent: document.getElementById("last-sent-action-needed-email-content"),
modalConfirm: document.getElementById("action-needed-reason__confirm-edit-email"), modalConfirm: document.getElementById("action-needed-reason__confirm-edit-email"),
apiUrl: document.getElementById("get-action-needed-email-for-user-json")?.value || null, apiUrl: document.getElementById("get-action-needed-email-for-user-json")?.value || null,
textAreaFormGroup: document.querySelector('.field-action_needed_reason'), textAreaFormGroup: document.querySelector('.field-action_needed_reason_email'),
dropdownFormGroup: document.querySelector('.field-action_needed_reason_email'), dropdownFormGroup: document.querySelector('.field-action_needed_reason'),
statusToCheck: "action needed", statusToCheck: "action needed",
readonlyStatusToCheck: "Action needed",
sessionVariableName: "showActionNeededReason", sessionVariableName: "showActionNeededReason",
apiErrorMessage: "Error when attempting to grab action needed email: " 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 // Hide/show the email fields depending on the current status
this.initializeFormGroups(); this.initializeFormGroups();
// Setup the textarea, edit button, helper text // 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.initializeDropdown();
this.initializeModalConfirm(); this.initializeModalConfirm();
this.initializeDirectEditButton(); this.initializeDirectEditButton();
@ -560,12 +609,6 @@ export function initActionNeededEmail() {
// Initialize UI // Initialize UI
const customEmail = new customActionNeededEmail(); 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() customEmail.loadActionNeededEmail()
}); });
} }
@ -581,6 +624,7 @@ class customRejectedEmail extends CustomizableEmailBase {
textAreaFormGroup: document.querySelector('.field-rejection_reason'), textAreaFormGroup: document.querySelector('.field-rejection_reason'),
dropdownFormGroup: document.querySelector('.field-rejection_reason_email'), dropdownFormGroup: document.querySelector('.field-rejection_reason_email'),
statusToCheck: "rejected", statusToCheck: "rejected",
readonlyStatusToCheck: "Rejected",
sessionVariableName: "showRejectionReason", sessionVariableName: "showRejectionReason",
errorMessage: "Error when attempting to grab rejected email: " errorMessage: "Error when attempting to grab rejected email: "
}; };
@ -589,7 +633,15 @@ class customRejectedEmail extends CustomizableEmailBase {
loadRejectedEmail() { loadRejectedEmail() {
this.initializeFormGroups(); 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.initializeDropdown();
this.initializeModalConfirm(); this.initializeModalConfirm();
this.initializeDirectEditButton(); this.initializeDirectEditButton();
@ -600,7 +652,7 @@ class customRejectedEmail extends CustomizableEmailBase {
this.showPlaceholder("Email:", "Select a rejection reason to see email"); 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); super.updateUserInterface(reason, excluded_reasons);
} }
} }
@ -619,12 +671,6 @@ export function initRejectedEmail() {
// Initialize UI // Initialize UI
const customEmail = new customRejectedEmail(); 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() customEmail.loadRejectedEmail()
}); });
} }
@ -648,7 +694,6 @@ function handleSuborgFieldsAndButtons() {
// Ensure that every variable is present before proceeding // Ensure that every variable is present before proceeding
if (!requestedSuborganizationField || !suborganizationCity || !suborganizationStateTerritory || !rejectButton) { if (!requestedSuborganizationField || !suborganizationCity || !suborganizationStateTerritory || !rejectButton) {
console.warn("handleSuborganizationSelection() => Could not find required fields.")
return; return;
} }

View file

@ -12,7 +12,9 @@ export function handlePortfolioSelection(
suborgDropdownSelector="#id_sub_organization" suborgDropdownSelector="#id_sub_organization"
) { ) {
// These dropdown are select2 fields so they must be interacted with via jquery // 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 portfolioDropdown = django.jQuery(portfolioDropdownSelector);
const portfolioField = document.querySelector(".field-portfolio");
const suborganizationDropdown = django.jQuery(suborgDropdownSelector); const suborganizationDropdown = django.jQuery(suborgDropdownSelector);
const suborganizationField = document.querySelector(".field-sub_organization"); const suborganizationField = document.querySelector(".field-sub_organization");
const requestedSuborganizationField = document.querySelector(".field-requested_suborganization"); 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. * - Various global field elements (e.g., `suborganizationField`, `seniorOfficialField`, `portfolioOrgTypeFieldSet`) are used.
*/ */
function updatePortfolioFieldsDisplay() { function updatePortfolioFieldsDisplay() {
// Retrieve the selected portfolio ID let portfolio_id = null;
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
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 // A portfolio is selected - update suborganization dropdown and show/hide relevant fields
// Update suborganization dropdown for the selected portfolio if (portfolio_id) {
updateSubOrganizationDropdown(portfolio_id); // Update suborganization dropdown for the selected portfolio
updateSubOrganizationDropdown(portfolio_id);
}
// Show fields relevant to a selected portfolio // Show fields relevant to a selected portfolio
showElement(suborganizationField); if (suborganizationField) showElement(suborganizationField);
hideElement(seniorOfficialField); hideElement(seniorOfficialField);
showElement(portfolioSeniorOfficialField); showElement(portfolioSeniorOfficialField);
@ -427,7 +445,7 @@ export function handlePortfolioSelection(
// No portfolio is selected - reverse visibility of fields // No portfolio is selected - reverse visibility of fields
// Hide suborganization field as no portfolio is selected // Hide suborganization field as no portfolio is selected
hideElement(suborganizationField); if (suborganizationField) hideElement(suborganizationField);
// Show fields that are relevant when no portfolio is selected // Show fields that are relevant when no portfolio is selected
showElement(seniorOfficialField); 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. * This function ensures the form dynamically reflects whether a specific suborganization is being selected or requested.
*/ */
function updateSuborganizationFieldsDisplay() { 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(); let suborganization_id = suborganizationDropdown.val();
if (portfolio_id && !suborganization_id) { if (portfolio_selected && !suborganization_id) {
// Show suborganization request fields // Show suborganization request fields
if (requestedSuborganizationField) showElement(requestedSuborganizationField); if (requestedSuborganizationField) showElement(requestedSuborganizationField);
if (suborganizationCity) showElement(suborganizationCity); if (suborganizationCity) showElement(suborganizationCity);

View file

@ -21,6 +21,8 @@ function handlePortfolioFields(){
const federalTypeField = document.querySelector(".field-federal_type"); const federalTypeField = document.querySelector(".field-federal_type");
const urbanizationField = document.querySelector(".field-urbanization"); const urbanizationField = document.querySelector(".field-urbanization");
const stateTerritoryDropdown = document.getElementById("id_state_territory"); 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 seniorOfficialAddUrl = document.getElementById("senior-official-add-url").value;
const seniorOfficialApi = document.getElementById("senior_official_from_agency_json_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; 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 * 2. else show org name, hide federal agency, hide federal type if applicable
*/ */
function handleOrganizationTypeChange() { function handleOrganizationTypeChange() {
if (organizationTypeDropdown && organizationNameField) { if (organizationTypeField && organizationNameField) {
let selectedValue = organizationTypeDropdown.value; let selectedValue = organizationTypeDropdown ? organizationTypeDropdown.value : organizationTypeReadonly.innerText;
if (selectedValue === "federal") { if (selectedValue === "federal" || selectedValue === "Federal") {
hideElement(organizationNameField); hideElement(organizationNameField);
showElement(federalAgencyField); showElement(federalAgencyField);
if (federalTypeField) { if (federalTypeField) {
@ -207,8 +209,8 @@ function handlePortfolioFields(){
* Handle urbanization * Handle urbanization
*/ */
function handleStateTerritoryChange() { function handleStateTerritoryChange() {
let selectedValue = stateTerritoryDropdown.value; let selectedValue = stateTerritoryDropdown ? stateTerritoryDropdown.value : stateTerritoryReadonly.innerText;
if (selectedValue === "PR") { if (selectedValue === "PR" || selectedValue === "Puerto Rico (PR)") {
showElement(urbanizationField) showElement(urbanizationField)
} else { } else {
hideElement(urbanizationField) hideElement(urbanizationField)
@ -265,7 +267,7 @@ function handlePortfolioFields(){
* Initializes necessary data and display configurations for the portfolio fields. * Initializes necessary data and display configurations for the portfolio fields.
*/ */
function initializePortfolioSettings() { function initializePortfolioSettings() {
if (urbanizationField && stateTerritoryDropdown) { if (urbanizationField && stateTerritoryField) {
handleStateTerritoryChange(); handleStateTerritoryChange();
} }
handleOrganizationTypeChange(); handleOrganizationTypeChange();
@ -285,9 +287,11 @@ function handlePortfolioFields(){
handleStateTerritoryChange(); handleStateTerritoryChange();
}); });
} }
organizationTypeDropdown.addEventListener("change", function() { if (organizationTypeDropdown) {
handleOrganizationTypeChange(); organizationTypeDropdown.addEventListener("change", function() {
}); handleOrganizationTypeChange();
});
}
} }
// Run initial setup functions // Run initial setup functions

View file

@ -12,6 +12,9 @@ logger = logging.getLogger(__name__)
# Constants for clarity # Constants for clarity
ALL = "all" ALL = "all"
IS_STAFF = "is_staff" 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_MANAGER = "is_domain_manager"
IS_DOMAIN_REQUEST_CREATOR = "is_domain_request_creator" IS_DOMAIN_REQUEST_CREATOR = "is_domain_request_creator"
IS_STAFF_MANAGING_DOMAIN = "is_staff_managing_domain" IS_STAFF_MANAGING_DOMAIN = "is_staff_managing_domain"
@ -108,6 +111,9 @@ def _user_has_permission(user, request, rules, **kwargs):
# Define permission checks # Define permission checks
permission_checks = [ permission_checks = [
(IS_STAFF, lambda: user.is_staff), (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, IS_DOMAIN_MANAGER,
lambda: (not user.is_org_user(request) and _is_domain_manager(user, **kwargs)) lambda: (not user.is_org_user(request) and _is_domain_manager(user, **kwargs))

View file

@ -86,7 +86,6 @@ class RequestingEntityForm(RegistrarForm):
return {} return {}
# get the domain request as a dict, per usual method # 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 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 # set sub_organization to 'other' if is_requesting_new_suborganization is True
if isinstance(obj, DomainRequest) and obj.is_requesting_new_suborganization(): if isinstance(obj, DomainRequest) and obj.is_requesting_new_suborganization():
domain_request_dict["sub_organization"] = "other" domain_request_dict["sub_organization"] = "other"

View file

@ -22,6 +22,7 @@ from registrar.models.utility.portfolio_helper import (
get_domains_display, get_domains_display,
get_members_description_display, get_members_description_display,
get_members_display, get_members_display,
get_portfolio_invitation_associations,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -459,7 +460,14 @@ class PortfolioNewMemberForm(BasePortfolioMemberForm):
if hasattr(e, "code"): if hasattr(e, "code"):
field = "email" if "email" in self.fields else None field = "email" if "email" in self.fields else None
if e.code == "has_existing_permissions": 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 override_error = True
elif e.code == "has_existing_invitations": elif e.code == "has_existing_invitations":
self.add_error( self.add_error(

View file

@ -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,
),
]

View file

@ -245,6 +245,7 @@ class Domain(TimeStampedModel, DomainHelper):
is called in the validate function on the request/domain page is called in the validate function on the request/domain page
throws- RegistryError or InvalidDomainError""" throws- RegistryError or InvalidDomainError"""
if not cls.string_could_be_domain(domain): if not cls.string_could_be_domain(domain):
logger.warning("Not a valid domain: %s" % str(domain)) logger.warning("Not a valid domain: %s" % str(domain))
# throw invalid domain error so that it can be caught in # throw invalid domain error so that it can be caught in

View file

@ -449,7 +449,9 @@ class DomainInformation(TimeStampedModel):
def converted_federal_type(self): def converted_federal_type(self):
if self.portfolio: if self.portfolio:
return self.portfolio.federal_type return self.portfolio.federal_type
return self.federal_type elif self.federal_agency:
return self.federal_agency.federal_type
return None
@property @property
def converted_senior_official(self): def converted_senior_official(self):

View file

@ -1528,7 +1528,9 @@ class DomainRequest(TimeStampedModel):
def converted_federal_type(self): def converted_federal_type(self):
if self.portfolio: if self.portfolio:
return self.portfolio.federal_type return self.portfolio.federal_type
return self.federal_type elif self.federal_agency:
return self.federal_agency.federal_type
return None
@property @property
def converted_address_line1(self): def converted_address_line1(self):

View file

@ -141,6 +141,99 @@ class UserGroup(Group):
except Exception as e: except Exception as e:
logger.error(f"Error creating analyst permissions group: {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 cant 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): def create_full_access_group(apps, schema_editor):
"""This method gets run from a data migration.""" """This method gets run from a data migration."""

View file

@ -257,9 +257,6 @@ def validate_user_portfolio_permission(user_portfolio_permission):
Raises: Raises:
ValidationError: If any of the validation rules are violated. 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) has_portfolio = bool(user_portfolio_permission.portfolio_id)
portfolio_permissions = set(user_portfolio_permission._get_portfolio_permissions()) 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. == # # == Validate the multiple_porfolios flag. == #
if not flag_is_active_for_user(user_portfolio_permission.user, "multiple_portfolios"): if not flag_is_active_for_user(user_portfolio_permission.user, "multiple_portfolios"):
existing_permissions = UserPortfolioPermission.objects.exclude(id=user_portfolio_permission.id).filter( existing_permissions, existing_invitations = get_user_portfolio_permission_associations(
user=user_portfolio_permission.user user_portfolio_permission
) )
if existing_permissions.exists(): if existing_permissions.exists():
raise ValidationError( raise ValidationError(
@ -296,10 +293,6 @@ def validate_user_portfolio_permission(user_portfolio_permission):
code="has_existing_permissions", 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(): if existing_invitations.exists():
raise ValidationError( raise ValidationError(
"This user is already assigned to a portfolio invitation. " "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): def validate_portfolio_invitation(portfolio_invitation):
""" """
Validates a PortfolioInvitation instance. Located in portfolio_helper to avoid circular imports Validates a PortfolioInvitation instance. Located in portfolio_helper to avoid circular imports
@ -324,7 +343,6 @@ def validate_portfolio_invitation(portfolio_invitation):
Raises: Raises:
ValidationError: If any of the validation rules are violated. ValidationError: If any of the validation rules are violated.
""" """
PortfolioInvitation = apps.get_model("registrar.PortfolioInvitation")
UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission") UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
User = get_user_model() User = get_user_model()
@ -351,17 +369,12 @@ def validate_portfolio_invitation(portfolio_invitation):
) )
# == Validate the multiple_porfolios flag. == # # == 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. # If user returns None, then we check for global assignment of multiple_portfolios.
# Otherwise we just check on the user. # Otherwise we just check on the user.
if not flag_is_active_for_user(user, "multiple_portfolios"): if not flag_is_active_for_user(user, "multiple_portfolios"):
existing_permissions = UserPortfolioPermission.objects.filter(user=user) existing_permissions, existing_invitations = get_portfolio_invitation_associations(portfolio_invitation)
existing_invitations = PortfolioInvitation.objects.filter(email=portfolio_invitation.email).exclude(
Q(id=portfolio_invitation.id) | Q(status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED)
)
if existing_permissions.exists(): if existing_permissions.exists():
raise ValidationError( raise ValidationError(
"This user is already assigned to a portfolio. " "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): def cleanup_after_portfolio_member_deletion(portfolio, email, user=None):
""" """
Cleans up after removing a portfolio member or a portfolio invitation. Cleans up after removing a portfolio member or a portfolio invitation.

View file

@ -63,6 +63,7 @@
</table> </table>
</div> </div>
{% endfor %} {% endfor %}
{% if perms.registrar.analyst_access_permission or perms.full_access_permission %}
<div class="module"> <div class="module">
<table class="width-full"> <table class="width-full">
<caption class="text-bold">Analytics</caption> <caption class="text-bold">Analytics</caption>
@ -78,6 +79,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{% endif %}
{% else %} {% else %}
<p>{% translate 'You dont have permission to view or edit anything.' %}</p> <p>{% translate 'You dont have permission to view or edit anything.' %}</p>
{% endif %} {% endif %}

View file

@ -11,13 +11,15 @@
{% block field_sets %} {% block field_sets %}
<div class="display-flex flex-row flex-justify submit-row"> <div class="display-flex flex-row flex-justify submit-row">
<div class="flex-align-self-start button-list-mobile"> <div class="flex-align-self-start button-list-mobile">
{% if not adminform.form.is_omb_analyst %}
<input id="manageDomainSubmitButton" type="submit" value="Manage domain" name="_edit_domain"> <input id="manageDomainSubmitButton" type="submit" value="Manage domain" name="_edit_domain">
{# Dja has margin styles defined on inputs as is. Lets work with it, rather than fight it. #} {# Dja has margin styles defined on inputs as is. Lets work with it, rather than fight it. #}
<span class="mini-spacer"></span> <span class="mini-spacer"></span>
<input type="submit" value="Get registry status" name="_get_status"> <input type="submit" value="Get registry status" name="_get_status">
{% endif %}
</div> </div>
<div class="desktop:flex-align-self-end"> <div class="desktop:flex-align-self-end">
{% if original.state != original.State.DELETED %} {% if original.state != original.State.DELETED and not adminform.form.is_omb_analyst %}
<a class="text-middle" href="#toggle-extend-expiration-alert" aria-controls="toggle-extend-expiration-alert" data-open-modal> <a class="text-middle" href="#toggle-extend-expiration-alert" aria-controls="toggle-extend-expiration-alert" data-open-modal>
Extend expiration date Extend expiration date
</a> </a>
@ -31,9 +33,11 @@
<input type="submit" value="Remove hold" name="_remove_client_hold" class="custom-link-button"> <input type="submit" value="Remove hold" name="_remove_client_hold" class="custom-link-button">
{% endif %} {% endif %}
{% if original.state == original.State.READY or original.state == original.State.ON_HOLD %} {% if original.state == original.State.READY or original.state == original.State.ON_HOLD %}
{% if not adminform.form.is_omb_analyst %}
<span class="margin-left-05 margin-right-05 text-middle"> | </span> <span class="margin-left-05 margin-right-05 text-middle"> | </span>
{% endif %} {% endif %}
{% if original.state != original.State.DELETED %} {% endif %}
{% if original.state != original.State.DELETED and not adminform.form.is_omb_analyst %}
<a class="text-middle" href="#toggle-remove-from-registry" aria-controls="toggle-remove-from-registry" data-open-modal> <a class="text-middle" href="#toggle-remove-from-registry" aria-controls="toggle-remove-from-registry" data-open-modal>
Remove from registry Remove from registry
</a> </a>

View file

@ -6,7 +6,11 @@
{% if show_formatted_name %} {% if show_formatted_name %}
{% if user.get_formatted_name %} {% if user.get_formatted_name %}
<a class="contact_info_name" href="{% url 'admin:registrar_contact_change' user.id %}">{{ user.get_formatted_name }}</a> {% if adminform.form.show_contact_as_plain_text %}
{{ user.get_formatted_name }}
{% else %}
<a class="contact_info_name" href="{% url 'admin:registrar_contact_change' user.id %}">{{ user.get_formatted_name }}</a>
{% endif %}
{% else %} {% else %}
None None
{% endif %} {% endif %}

View file

@ -69,7 +69,11 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% elif field.field.name == "portfolio_senior_official" %} {% elif field.field.name == "portfolio_senior_official" %}
<div class="readonly"> <div class="readonly">
{% if original_object.portfolio.senior_official %} {% if original_object.portfolio.senior_official %}
<a href="{% url 'admin:registrar_seniorofficial_change' original_object.portfolio.senior_official.id %}">{{ field.contents }}</a> {% if adminform.form.show_contact_as_plain_text %}
{{ field.contents|striptags }}
{% else %}
<a href="{% url 'admin:registrar_seniorofficial_change' original_object.portfolio.senior_official.id %}">{{ field.contents }}</a>
{% endif %}
{% else %} {% else %}
No senior official found.<br> No senior official found.<br>
{% endif %} {% endif %}
@ -78,7 +82,11 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% if all_contacts.count > 2 %} {% if all_contacts.count > 2 %}
<div class="readonly"> <div class="readonly">
{% for contact in all_contacts %} {% for contact in all_contacts %}
<a href="{% url 'admin:registrar_contact_change' contact.id %}">{{ contact.get_formatted_name }}</a>{% if not forloop.last %}, {% endif %} {% if adminform.form.show_contact_as_plain_text %}
{{ contact.get_formatted_name }}{% if not forloop.last %}, {% endif %}
{% else %}
<a href="{% url 'admin:registrar_contact_change' contact.id %}">{{ contact.get_formatted_name }}</a>{% if not forloop.last %}, {% endif %}
{% endif %}
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
@ -153,6 +161,10 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
<p>No additional members found.</p> <p>No additional members found.</p>
{% endif %} {% endif %}
</div> </div>
{% elif field.field.name == "creator" and adminform.form.show_contact_as_plain_text %}
<div class="readonly">{{ field.contents|striptags }}</div>
{% elif field.field.name == "senior_official" and adminform.form.show_contact_as_plain_text %}
<div class="readonly">{{ field.contents|striptags }}</div>
{% else %} {% else %}
<div class="readonly">{{ field.contents }}</div> <div class="readonly">{{ field.contents }}</div>
{% endif %} {% endif %}

View file

@ -16,7 +16,11 @@
{% for admin in admins %} {% for admin in admins %}
{% url 'admin:registrar_userportfoliopermission_change' admin.pk as url %} {% url 'admin:registrar_userportfoliopermission_change' admin.pk as url %}
<tr> <tr>
<td><a href={{url}}>{{ admin.user.get_formatted_name}}</a></td> {% if adminform.form.is_omb_analyst %}
<td>{{ admin.user.get_formatted_name }}</td>
{% else %}
<td><a href={{url}}>{{ admin.user.get_formatted_name}}</a></td>
{% endif %}
<td>{{ admin.user.title }}</td> <td>{{ admin.user.title }}</td>
<td> <td>
{% if admin.user.email %} {% if admin.user.email %}

View file

@ -30,6 +30,9 @@
<a href={{ url }}>No senior official found. Create one now.</a> <a href={{ url }}>No senior official found. Create one now.</a>
</div> </div>
{% endif %} {% endif %}
{% elif field.field.name == "creator" and adminform.form.show_contact_as_plain_text %}
<div class="readonly">{{ field.contents|striptags }}</div>
{% else %} {% else %}
<div class="readonly">{{ field.contents }}</div> <div class="readonly">{{ field.contents }}</div>
{% endif %} {% endif %}

View file

@ -58,7 +58,7 @@
{% if request.path|endswith:"renewal"%} {% if request.path|endswith:"renewal"%}
<h1>Renew {{domain.name}} </h1> <h1>Renew {{domain.name}} </h1>
{%else%} {%else%}
<h1 class="break-word">Domain Overview</h1> <h1 class="break-word">Domain overview</h1>
{% endif%} {% endif%}
{% endblock %} {# domain_content #} {% endblock %} {# domain_content #}

View file

@ -99,7 +99,7 @@
{% if domain.dnssecdata is not None %} {% if domain.dnssecdata is not None %}
{% include "includes/summary_item.html" with title='DNSSEC' value='Enabled' edit_link=url editable=is_editable %} {% include "includes/summary_item.html" with title='DNSSEC' value='Enabled' edit_link=url editable=is_editable %}
{% else %} {% 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 %} {% endif %}
{% if portfolio %} {% if portfolio %}

View file

@ -57,7 +57,7 @@ THANK YOU
The .gov team The .gov team
.Gov blog <https://get.gov/updates/> .Gov blog <https://get.gov/updates/>
Domain management <{{ manage_url }}}> Domain management <{{ manage_url }}>
Get.gov <https://get.gov> Get.gov <https://get.gov>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <https://cisa.gov/> The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <https://cisa.gov/>

View file

@ -1010,6 +1010,27 @@ def create_user(**kwargs):
return user 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(): def create_test_user():
username = "test_user" username = "test_user"
first_name = "First" first_name = "First"

View file

@ -3,6 +3,7 @@ from django.utils import timezone
from django.test import TestCase, RequestFactory, Client from django.test import TestCase, RequestFactory, Client
from django.contrib.admin.sites import AdminSite from django.contrib.admin.sites import AdminSite
from registrar import models from registrar import models
from registrar.utility.constants import BranchChoices
from registrar.utility.email import EmailSendingError from registrar.utility.email import EmailSendingError
from registrar.utility.errors import MissingEmailError from registrar.utility.errors import MissingEmailError
from waffle.testutils import override_flag from waffle.testutils import override_flag
@ -57,6 +58,7 @@ from .common import (
MockDbForSharedTests, MockDbForSharedTests,
AuditedAdminMockData, AuditedAdminMockData,
completed_domain_request, completed_domain_request,
create_omb_analyst_user,
create_test_user, create_test_user,
generic_domain_object, generic_domain_object,
less_console_noise, less_console_noise,
@ -136,18 +138,25 @@ class TestDomainInvitationAdmin(WebTest):
csrf_checks = False csrf_checks = False
@classmethod @classmethod
def setUpClass(self): def setUpClass(cls):
super().setUpClass() super().setUpClass()
self.site = AdminSite() cls.site = AdminSite()
self.factory = RequestFactory() cls.factory = RequestFactory()
self.superuser = create_superuser()
def setUp(self): def setUp(self):
super().setUp() 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.admin = ListHeaderAdmin(model=DomainInvitationAdmin, admin_site=AdminSite())
self.domain = Domain.objects.create(name="example.com") 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) 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""" """Create a client object"""
self.client = Client(HTTP_HOST="localhost:8080") self.client = Client(HTTP_HOST="localhost:8080")
self.client.force_login(self.superuser) self.client.force_login(self.superuser)
@ -159,10 +168,124 @@ class TestDomainInvitationAdmin(WebTest):
DomainInvitation.objects.all().delete() DomainInvitation.objects.all().delete()
DomainInformation.objects.all().delete() DomainInformation.objects.all().delete()
Portfolio.objects.all().delete() Portfolio.objects.all().delete()
self.fed_agency.delete()
Domain.objects.all().delete() Domain.objects.all().delete()
Contact.objects.all().delete() Contact.objects.all().delete()
User.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 @less_console_noise_decorator
def test_has_model_description(self): def test_has_model_description(self):
"""Tests if this model has a model description on the table view""" """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.client = Client(HTTP_HOST="localhost:8080")
self.superuser = create_superuser() self.superuser = create_superuser()
self.testuser = create_test_user() self.testuser = create_test_user()
self.omb_analyst = create_omb_analyst_user()
self.portfolio = Portfolio.objects.create(organization_name="Test Portfolio", creator=self.superuser) self.portfolio = Portfolio.objects.create(organization_name="Test Portfolio", creator=self.superuser)
def tearDown(self): def tearDown(self):
@ -1148,6 +1272,26 @@ class TestUserPortfolioPermissionAdmin(TestCase):
User.objects.all().delete() User.objects.all().delete()
UserPortfolioPermission.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 @less_console_noise_decorator
def test_has_change_form_description(self): def test_has_change_form_description(self):
"""Tests if this model has a model description on the change form view""" """Tests if this model has a model description on the change form view"""
@ -1204,6 +1348,7 @@ class TestPortfolioInvitationAdmin(TestCase):
def setUp(self): def setUp(self):
"""Create a client object""" """Create a client object"""
self.client = Client(HTTP_HOST="localhost:8080") 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) self.portfolio = Portfolio.objects.create(organization_name="Test Portfolio", creator=self.superuser)
def tearDown(self): def tearDown(self):
@ -1217,6 +1362,26 @@ class TestPortfolioInvitationAdmin(TestCase):
def tearDownClass(self): def tearDownClass(self):
User.objects.all().delete() 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 @less_console_noise_decorator
def test_has_model_description(self): def test_has_model_description(self):
"""Tests if this model has a model description on the table view""" """Tests if this model has a model description on the table view"""
@ -1791,6 +1956,8 @@ class TestHostAdmin(TestCase):
cls.factory = RequestFactory() cls.factory = RequestFactory()
cls.admin = MyHostAdmin(model=Host, admin_site=cls.site) cls.admin = MyHostAdmin(model=Host, admin_site=cls.site)
cls.superuser = create_superuser() cls.superuser = create_superuser()
cls.staffuser = create_user()
cls.omb_analyst = create_omb_analyst_user()
def setUp(self): def setUp(self):
"""Setup environment for a mock admin user""" """Setup environment for a mock admin user"""
@ -1806,6 +1973,20 @@ class TestHostAdmin(TestCase):
def tearDownClass(cls): def tearDownClass(cls):
User.objects.all().delete() 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 @less_console_noise_decorator
def test_has_model_description(self): def test_has_model_description(self):
"""Tests if this model has a model description on the table view""" """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.admin = DomainInformationAdmin(model=DomainInformation, admin_site=cls.site)
cls.superuser = create_superuser() cls.superuser = create_superuser()
cls.staffuser = create_user() cls.staffuser = create_user()
cls.omb_analyst = create_omb_analyst_user()
cls.mock_data_generator = AuditedAdminMockData() cls.mock_data_generator = AuditedAdminMockData()
cls.test_helper = GenericTestHelper( cls.test_helper = GenericTestHelper(
factory=cls.factory, factory=cls.factory,
@ -1881,12 +2063,24 @@ class TestDomainInformationAdmin(TestCase):
def setUp(self): def setUp(self):
self.client = Client(HTTP_HOST="localhost:8080") 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): def tearDown(self):
"""Delete all Users, Domains, and UserDomainRoles""" """Delete all Users, Domains, and UserDomainRoles"""
DomainInformation.objects.all().delete() DomainInformation.objects.all().delete()
DomainRequest.objects.all().delete() DomainRequest.objects.all().delete()
Domain.objects.all().delete() Domain.objects.all().delete()
DomainInformation.objects.all().delete()
Portfolio.objects.all().delete()
self.fed_agency.delete()
Contact.objects.all().delete() Contact.objects.all().delete()
@classmethod @classmethod
@ -1894,6 +2088,56 @@ class TestDomainInformationAdmin(TestCase):
User.objects.all().delete() User.objects.all().delete()
SeniorOfficial.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 @less_console_noise_decorator
def test_domain_information_senior_official_is_alphabetically_sorted(self): def test_domain_information_senior_official_is_alphabetically_sorted(self):
"""Tests if the senior offical dropdown is alphanetically sorted in the django admin display""" """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.factory = RequestFactory()
cls.admin = UserDomainRoleAdmin(model=UserDomainRole, admin_site=cls.site) cls.admin = UserDomainRoleAdmin(model=UserDomainRole, admin_site=cls.site)
cls.superuser = create_superuser() cls.superuser = create_superuser()
cls.staffuser = create_user()
cls.omb_analyst = create_omb_analyst_user()
cls.test_helper = GenericTestHelper( cls.test_helper = GenericTestHelper(
factory=cls.factory, factory=cls.factory,
user=cls.superuser, user=cls.superuser,
@ -2285,6 +2531,31 @@ class TestUserDomainRoleAdmin(WebTest):
super().tearDownClass() super().tearDownClass()
User.objects.all().delete() 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 @less_console_noise_decorator
def test_has_model_description(self): def test_has_model_description(self):
"""Tests if this model has a model description on the table view""" """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.admin = MyUserAdmin(model=get_user_model(), admin_site=admin_site)
cls.superuser = create_superuser() cls.superuser = create_superuser()
cls.staffuser = create_user() cls.staffuser = create_user()
cls.omb_analyst = create_omb_analyst_user()
cls.test_helper = GenericTestHelper(admin=cls.admin) cls.test_helper = GenericTestHelper(admin=cls.admin)
def setUp(self): def setUp(self):
@ -2596,6 +2868,13 @@ class TestMyUserAdmin(MockDbForSharedTests, WebTest):
super().tearDownClass() super().tearDownClass()
User.objects.all().delete() 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 @less_console_noise_decorator
def test_has_model_description(self): def test_has_model_description(self):
"""Tests if this model has a model description on the table view""" """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.admin = ContactAdmin(model=Contact, admin_site=None)
cls.superuser = create_superuser() cls.superuser = create_superuser()
cls.staffuser = create_user() cls.staffuser = create_user()
cls.omb_analyst = create_omb_analyst_user()
def setUp(self): def setUp(self):
super().setUp() super().setUp()
@ -3236,6 +3516,13 @@ class TestContactAdmin(TestCase):
super().tearDownClass() super().tearDownClass()
User.objects.all().delete() 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 @less_console_noise_decorator
def test_has_model_description(self): def test_has_model_description(self):
"""Tests if this model has a model description on the table view""" """Tests if this model has a model description on the table view"""
@ -3282,6 +3569,7 @@ class TestVerifiedByStaffAdmin(TestCase):
super().setUpClass() super().setUpClass()
cls.site = AdminSite() cls.site = AdminSite()
cls.superuser = create_superuser() cls.superuser = create_superuser()
cls.omb_analyst = create_omb_analyst_user()
cls.admin = VerifiedByStaffAdmin(model=VerifiedByStaff, admin_site=cls.site) cls.admin = VerifiedByStaffAdmin(model=VerifiedByStaff, admin_site=cls.site)
cls.factory = RequestFactory() cls.factory = RequestFactory()
cls.test_helper = GenericTestHelper(admin=cls.admin) cls.test_helper = GenericTestHelper(admin=cls.admin)
@ -3299,18 +3587,20 @@ class TestVerifiedByStaffAdmin(TestCase):
super().tearDownClass() super().tearDownClass()
User.objects.all().delete() 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 @less_console_noise_decorator
def test_has_model_description(self): def test_has_model_description(self):
"""Tests if this model has a model description on the table view""" """Tests if this model has a model description on the table view"""
self.client.force_login(self.superuser) self.client.force_login(self.superuser)
response = self.client.get( response = self.client.get(reverse("admin:registrar_verifiedbystaff_changelist"))
"/admin/registrar/verifiedbystaff/",
follow=True,
)
# Make sure that the page is loaded correctly # Make sure that the page is loaded correctly
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# Test for a description snippet # Test for a description snippet
self.assertContains( self.assertContains(
response, "This table contains users who have been allowed to bypass " "identity proofing through Login.gov" 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() super().setUp()
self.site = AdminSite() self.site = AdminSite()
self.superuser = create_superuser() self.superuser = create_superuser()
self.omb_analyst = create_omb_analyst_user()
self.admin = WebsiteAdmin(model=Website, admin_site=self.site) self.admin = WebsiteAdmin(model=Website, admin_site=self.site)
self.factory = RequestFactory() self.factory = RequestFactory()
self.client = Client(HTTP_HOST="localhost:8080") self.client = Client(HTTP_HOST="localhost:8080")
@ -3375,15 +3666,18 @@ class TestWebsiteAdmin(TestCase):
Website.objects.all().delete() Website.objects.all().delete()
User.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 @less_console_noise_decorator
def test_has_model_description(self): def test_has_model_description(self):
"""Tests if this model has a model description on the table view""" """Tests if this model has a model description on the table view"""
self.client.force_login(self.superuser) self.client.force_login(self.superuser)
response = self.client.get( response = self.client.get(reverse("admin:registrar_website_changelist"))
"/admin/registrar/website/",
follow=True,
)
# Make sure that the page is loaded correctly # Make sure that the page is loaded correctly
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@ -3392,13 +3686,14 @@ class TestWebsiteAdmin(TestCase):
self.assertContains(response, "Show more") self.assertContains(response, "Show more")
class TestDraftDomain(TestCase): class TestDraftDomainAdmin(TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
super().setUpClass() super().setUpClass()
cls.site = AdminSite() cls.site = AdminSite()
cls.superuser = create_superuser() cls.superuser = create_superuser()
cls.omb_analyst = create_omb_analyst_user()
cls.admin = DraftDomainAdmin(model=DraftDomain, admin_site=cls.site) cls.admin = DraftDomainAdmin(model=DraftDomain, admin_site=cls.site)
cls.factory = RequestFactory() cls.factory = RequestFactory()
cls.test_helper = GenericTestHelper(admin=cls.admin) cls.test_helper = GenericTestHelper(admin=cls.admin)
@ -3416,15 +3711,18 @@ class TestDraftDomain(TestCase):
super().tearDownClass() super().tearDownClass()
User.objects.all().delete() 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 @less_console_noise_decorator
def test_has_model_description(self): def test_has_model_description(self):
"""Tests if this model has a model description on the table view""" """Tests if this model has a model description on the table view"""
self.client.force_login(self.superuser) self.client.force_login(self.superuser)
response = self.client.get( response = self.client.get(reverse("admin:registrar_draftdomain_changelist"))
"/admin/registrar/draftdomain/",
follow=True,
)
# Make sure that the page is loaded correctly # Make sure that the page is loaded correctly
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@ -3435,13 +3733,21 @@ class TestDraftDomain(TestCase):
self.assertContains(response, "Show more") self.assertContains(response, "Show more")
class TestFederalAgency(TestCase): class TestFederalAgencyAdmin(TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
super().setUpClass() super().setUpClass()
cls.site = AdminSite() cls.site = AdminSite()
cls.superuser = create_superuser() 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.admin = FederalAgencyAdmin(model=FederalAgency, admin_site=cls.site)
cls.factory = RequestFactory() cls.factory = RequestFactory()
cls.test_helper = GenericTestHelper(admin=cls.admin) cls.test_helper = GenericTestHelper(admin=cls.admin)
@ -3454,6 +3760,100 @@ class TestFederalAgency(TestCase):
super().tearDownClass() super().tearDownClass()
User.objects.all().delete() 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 @less_console_noise_decorator
def test_has_model_description(self): def test_has_model_description(self):
"""Tests if this model has a model description on the table view""" """Tests if this model has a model description on the table view"""
@ -3471,11 +3871,12 @@ class TestFederalAgency(TestCase):
self.assertContains(response, "Show more") self.assertContains(response, "Show more")
class TestPublicContact(TestCase): class TestPublicContactAdmin(TestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.site = AdminSite() self.site = AdminSite()
self.superuser = create_superuser() self.superuser = create_superuser()
self.omb_analyst = create_omb_analyst_user()
self.admin = PublicContactAdmin(model=PublicContact, admin_site=self.site) self.admin = PublicContactAdmin(model=PublicContact, admin_site=self.site)
self.factory = RequestFactory() self.factory = RequestFactory()
self.client = Client(HTTP_HOST="localhost:8080") self.client = Client(HTTP_HOST="localhost:8080")
@ -3486,16 +3887,19 @@ class TestPublicContact(TestCase):
PublicContact.objects.all().delete() PublicContact.objects.all().delete()
User.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 @less_console_noise_decorator
def test_has_model_description(self): def test_has_model_description(self):
"""Tests if this model has a model description on the table view""" """Tests if this model has a model description on the table view"""
p = "adminpass" p = "adminpass"
self.client.login(username="superuser", password=p) self.client.login(username="superuser", password=p)
response = self.client.get( response = self.client.get(reverse("admin:registrar_publiccontact_changelist"))
"/admin/registrar/publiccontact/",
follow=True,
)
# Make sure that the page is loaded correctly # Make sure that the page is loaded correctly
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@ -3504,11 +3908,12 @@ class TestPublicContact(TestCase):
self.assertContains(response, "Show more") self.assertContains(response, "Show more")
class TestTransitionDomain(TestCase): class TestTransitionDomainAdmin(TestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.site = AdminSite() self.site = AdminSite()
self.superuser = create_superuser() self.superuser = create_superuser()
self.omb_analyst = create_omb_analyst_user()
self.admin = TransitionDomainAdmin(model=TransitionDomain, admin_site=self.site) self.admin = TransitionDomainAdmin(model=TransitionDomain, admin_site=self.site)
self.factory = RequestFactory() self.factory = RequestFactory()
self.client = Client(HTTP_HOST="localhost:8080") self.client = Client(HTTP_HOST="localhost:8080")
@ -3519,15 +3924,18 @@ class TestTransitionDomain(TestCase):
PublicContact.objects.all().delete() PublicContact.objects.all().delete()
User.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 @less_console_noise_decorator
def test_has_model_description(self): def test_has_model_description(self):
"""Tests if this model has a model description on the table view""" """Tests if this model has a model description on the table view"""
self.client.force_login(self.superuser) self.client.force_login(self.superuser)
response = self.client.get( response = self.client.get(reverse("admin:registrar_transitiondomain_changelist"))
"/admin/registrar/transitiondomain/",
follow=True,
)
# Make sure that the page is loaded correctly # Make sure that the page is loaded correctly
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@ -3536,11 +3944,12 @@ class TestTransitionDomain(TestCase):
self.assertContains(response, "Show more") self.assertContains(response, "Show more")
class TestUserGroup(TestCase): class TestUserGroupAdmin(TestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.site = AdminSite() self.site = AdminSite()
self.superuser = create_superuser() self.superuser = create_superuser()
self.omb_analyst = create_omb_analyst_user()
self.admin = UserGroupAdmin(model=UserGroup, admin_site=self.site) self.admin = UserGroupAdmin(model=UserGroup, admin_site=self.site)
self.factory = RequestFactory() self.factory = RequestFactory()
self.client = Client(HTTP_HOST="localhost:8080") self.client = Client(HTTP_HOST="localhost:8080")
@ -3550,15 +3959,18 @@ class TestUserGroup(TestCase):
super().tearDown() super().tearDown()
User.objects.all().delete() 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 @less_console_noise_decorator
def test_has_model_description(self): def test_has_model_description(self):
"""Tests if this model has a model description on the table view""" """Tests if this model has a model description on the table view"""
self.client.force_login(self.superuser) self.client.force_login(self.superuser)
response = self.client.get( response = self.client.get(reverse("admin:registrar_usergroup_changelist"))
"/admin/registrar/usergroup/",
follow=True,
)
# Make sure that the page is loaded correctly # Make sure that the page is loaded correctly
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@ -3575,12 +3987,23 @@ class TestPortfolioAdmin(TestCase):
super().setUpClass() super().setUpClass()
cls.site = AdminSite() cls.site = AdminSite()
cls.superuser = create_superuser() 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.admin = PortfolioAdmin(model=Portfolio, admin_site=cls.site)
cls.factory = RequestFactory() cls.factory = RequestFactory()
def setUp(self): def setUp(self):
self.client = Client(HTTP_HOST="localhost:8080") 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): def tearDown(self):
Suborganization.objects.all().delete() Suborganization.objects.all().delete()
@ -3588,8 +4011,118 @@ class TestPortfolioAdmin(TestCase):
DomainRequest.objects.all().delete() DomainRequest.objects.all().delete()
Domain.objects.all().delete() Domain.objects.all().delete()
Portfolio.objects.all().delete() Portfolio.objects.all().delete()
self.feb_agency.delete()
User.objects.all().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 @less_console_noise_decorator
def test_created_on_display(self): def test_created_on_display(self):
"""Tests the custom created on which is a reskin of the created_at field""" """Tests the custom created on which is a reskin of the created_at field"""
@ -3777,6 +4310,7 @@ class TestTransferUser(WebTest):
super().setUpClass() super().setUpClass()
cls.site = AdminSite() cls.site = AdminSite()
cls.superuser = create_superuser() cls.superuser = create_superuser()
cls.omb_analyst = create_omb_analyst_user()
cls.admin = PortfolioAdmin(model=Portfolio, admin_site=cls.site) cls.admin = PortfolioAdmin(model=Portfolio, admin_site=cls.site)
cls.factory = RequestFactory() cls.factory = RequestFactory()
@ -3797,6 +4331,13 @@ class TestTransferUser(WebTest):
Portfolio.objects.all().delete() Portfolio.objects.all().delete()
UserDomainRole.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 @less_console_noise_decorator
def test_transfer_user_shows_current_and_selected_user_information(self): 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""" """Assert we pull the current user info and display it on the transfer page"""

View file

@ -17,14 +17,17 @@ from registrar.models import (
Host, Host,
Portfolio, Portfolio,
) )
from registrar.models.federal_agency import FederalAgency
from registrar.models.public_contact import PublicContact from registrar.models.public_contact import PublicContact
from registrar.models.user_domain_role import UserDomainRole from registrar.models.user_domain_role import UserDomainRole
from registrar.utility.constants import BranchChoices
from .common import ( from .common import (
MockSESClient, MockSESClient,
completed_domain_request, completed_domain_request,
less_console_noise, less_console_noise,
create_superuser, create_superuser,
create_user, create_user,
create_omb_analyst_user,
create_ready_domain, create_ready_domain,
MockEppLib, MockEppLib,
GenericTestHelper, GenericTestHelper,
@ -48,7 +51,9 @@ class TestDomainAdminAsStaff(MockEppLib):
@classmethod @classmethod
def setUpClass(self): def setUpClass(self):
super().setUpClass() super().setUpClass()
self.superuser = create_superuser()
self.staffuser = create_user() self.staffuser = create_user()
self.omb_analyst = create_omb_analyst_user()
self.site = AdminSite() self.site = AdminSite()
self.admin = DomainAdmin(model=Domain, admin_site=self.site) self.admin = DomainAdmin(model=Domain, admin_site=self.site)
self.factory = RequestFactory() self.factory = RequestFactory()
@ -56,6 +61,24 @@ class TestDomainAdminAsStaff(MockEppLib):
def setUp(self): def setUp(self):
self.client = Client(HTTP_HOST="localhost:8080") self.client = Client(HTTP_HOST="localhost:8080")
self.client.force_login(self.staffuser) 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() super().setUp()
def tearDown(self): def tearDown(self):
@ -65,12 +88,134 @@ class TestDomainAdminAsStaff(MockEppLib):
Domain.objects.all().delete() Domain.objects.all().delete()
DomainInformation.objects.all().delete() DomainInformation.objects.all().delete()
DomainRequest.objects.all().delete() DomainRequest.objects.all().delete()
Portfolio.objects.all().delete()
self.fed_agency.delete()
@classmethod @classmethod
def tearDownClass(self): def tearDownClass(self):
User.objects.all().delete() User.objects.all().delete()
super().tearDownClass() 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 @less_console_noise_decorator
def test_staff_can_see_cisa_region_federal(self): def test_staff_can_see_cisa_region_federal(self):
"""Tests if staff can see CISA Region: N/A""" """Tests if staff can see CISA Region: N/A"""

View file

@ -1,6 +1,8 @@
from datetime import datetime from datetime import datetime
from django.forms import ValidationError from django.forms import ValidationError
from django.utils import timezone from django.utils import timezone
from registrar.models.federal_agency import FederalAgency
from registrar.utility.constants import BranchChoices
from waffle.testutils import override_flag from waffle.testutils import override_flag
import re import re
from django.test import RequestFactory, Client, TestCase, override_settings from django.test import RequestFactory, Client, TestCase, override_settings
@ -37,6 +39,7 @@ from .common import (
less_console_noise, less_console_noise,
create_superuser, create_superuser,
create_user, create_user,
create_omb_analyst_user,
multiple_unalphabetical_domain_objects, multiple_unalphabetical_domain_objects,
MockEppLib, MockEppLib,
GenericTestHelper, GenericTestHelper,
@ -68,6 +71,7 @@ class TestDomainRequestAdmin(MockEppLib):
self.admin = DomainRequestAdmin(model=DomainRequest, admin_site=self.site) self.admin = DomainRequestAdmin(model=DomainRequest, admin_site=self.site)
self.superuser = create_superuser() self.superuser = create_superuser()
self.staffuser = create_user() self.staffuser = create_user()
self.ombanalyst = create_omb_analyst_user()
self.client = Client(HTTP_HOST="localhost:8080") self.client = Client(HTTP_HOST="localhost:8080")
self.test_helper = GenericTestHelper( self.test_helper = GenericTestHelper(
factory=self.factory, factory=self.factory,
@ -80,6 +84,12 @@ class TestDomainRequestAdmin(MockEppLib):
allowed_emails = [AllowedEmail(email="mayor@igorville.gov"), AllowedEmail(email="help@get.gov")] allowed_emails = [AllowedEmail(email="mayor@igorville.gov"), AllowedEmail(email="help@get.gov")]
AllowedEmail.objects.bulk_create(allowed_emails) 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): def tearDown(self):
super().tearDown() super().tearDown()
Host.objects.all().delete() Host.objects.all().delete()
@ -92,6 +102,7 @@ class TestDomainRequestAdmin(MockEppLib):
SeniorOfficial.objects.all().delete() SeniorOfficial.objects.all().delete()
Suborganization.objects.all().delete() Suborganization.objects.all().delete()
Portfolio.objects.all().delete() Portfolio.objects.all().delete()
self.fed_agency.delete()
self.mock_client.EMAILS_SENT.clear() self.mock_client.EMAILS_SENT.clear()
@classmethod @classmethod
@ -100,6 +111,71 @@ class TestDomainRequestAdmin(MockEppLib):
User.objects.all().delete() User.objects.all().delete()
AllowedEmail.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) @override_flag("organization_feature", active=True)
@less_console_noise_decorator @less_console_noise_decorator
def test_clean_validates_duplicate_suborganization(self): def test_clean_validates_duplicate_suborganization(self):
@ -2076,6 +2152,86 @@ class TestDomainRequestAdmin(MockEppLib):
self.assertEqual(readonly_fields, expected_fields) 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): def test_saving_when_restricted_creator(self):
with less_console_noise(): with less_console_noise():
# Create an instance of the model # Create an instance of the model

View file

@ -72,7 +72,7 @@ class CsvReportsTest(MockDbForSharedTests):
fake_open = mock_open() fake_open = mock_open()
expected_file_content = [ expected_file_content = [
call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), 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("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
@ -94,7 +94,7 @@ class CsvReportsTest(MockDbForSharedTests):
fake_open = mock_open() fake_open = mock_open()
expected_file_content = [ expected_file_content = [
call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), 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("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("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," "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,"
"Portfolio 1 Federal Agency,Portfolio 1 Federal Agency,,, ,,(blank)," "Portfolio 1 Federal Agency,Portfolio 1 Federal Agency,,, ,,(blank),"
'"big_lebowski@dude.co, info@example.com, meoward@rocks.com",woofwardthethird@rocks.com\n' '"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),," "adomain10.gov,Ready,2024-04-03,(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,"
"squeaker@rocks.com\n" "squeaker@rocks.com\n"
"bdomain4.gov,Unknown,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\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" "sdomain8.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n"
"xdomain7.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" "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" "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 # sorted alphabetially by domain name
expected_content = ( expected_content = (
"Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" "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" "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n"
"adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n" "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n"
"ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\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 # sorted alphabetially by domain name
expected_content = ( expected_content = (
"Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" "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" "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n"
"adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n" "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n"
"ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\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" "State,Status,Expiration date, Deleted\n"
"cdomain1.gov,Federal-Executive,Portfolio1FederalAgency,Portfolio1FederalAgency,Ready,(blank)\n" "cdomain1.gov,Federal-Executive,Portfolio1FederalAgency,Portfolio1FederalAgency,Ready,(blank)\n"
"adomain10.gov,Federal,ArmedForcesRetirementHome,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" "zdomain12.gov,Interstate,Ready,(blank)\n"
"zdomain9.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-01\n" "zdomain9.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-01\n"
"sdomain8.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-02\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" "3,2,1,0,0,0,0,0,0,0\n"
"\n" "\n"
"Domain name,Domain type,Domain managers,Invited domain managers\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",' 'cdomain1.gov,Federal - Executive,"big_lebowski@dude.co, info@example.com, meoward@rocks.com",'
"woofwardthethird@rocks.com\n" "woofwardthethird@rocks.com\n"
"zdomain12.gov,Interstate,meoward@rocks.com,\n" "zdomain12.gov,Interstate,meoward@rocks.com,\n"
@ -716,7 +716,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
expected_content = ( expected_content = (
"Domain request,Domain type,Federal type\n" "Domain request,Domain type,Federal type\n"
"city3.gov,Federal,Executive\n" "city3.gov,Federal,Executive\n"
"city4.gov,City,Executive\n" "city4.gov,City,\n"
"city6.gov,Federal,Executive\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," "SO last name,SO email,SO title/role,Request purpose,Request additional details,Other contacts,"
"CISA regional representative,Current websites,Investigator\n" "CISA regional representative,Current websites,Investigator\n"
# Content # 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," "city1.gov,Testy,Tester,testy@town.com,Chief Tester,Purpose of the site,There is more,"
"Testy Tester testy2@town.com,,city.com,\n" "Testy Tester testy2@town.com,,city.com,\n"
"city2.gov,In review,Federal,Yes,Executive,Portfolio 1 Federal Agency,Portfolio 1 Federal Agency," "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, ' 'There is more,"Meow Tester24 te2@town.com, Testy1232 Tester24 te2@town.com, '
'Testy Tester testy2@town.com",' 'Testy Tester testy2@town.com",'
'test@igorville.com,"city.com, https://www.example2.com, https://www.example.com",\n' '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," "Tester,testy@town.com,"
"Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more," "Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more,"
"Testy Tester testy2@town.com," "Testy Tester testy2@town.com,"

View file

@ -3930,17 +3930,59 @@ class TestPortfolioInviteNewMemberView(MockEppLib, WebTest):
response = self.client.post( response = self.client.post(
reverse("new-member"), reverse("new-member"),
{ {
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
"email": self.user.email, "email": self.user.email,
}, },
follow=True,
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
with open("debug_response.html", "w") as f:
f.write(response.content.decode("utf-8"))
# Verify messages # Verify messages
self.assertContains( self.assertContains(
response, 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 # Validate Database has not changed

View file

@ -1,4 +1,5 @@
from registrar.models.domain_request import DomainRequest from registrar.models.domain_request import DomainRequest
from django.conf import settings
from django.template.loader import get_template from django.template.loader import get_template
from django.utils.html import format_html from django.utils.html import format_html
from django.urls import reverse from django.urls import reverse
@ -35,8 +36,13 @@ def _get_default_email(domain_request, file_path, reason, excluded_reasons=None)
return None return None
recipient = domain_request.creator 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 # 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 = get_template(file_path).render(context=context)
email_body_text_cleaned = email_body_text.strip().lstrip("\n") if email_body_text else None email_body_text_cleaned = email_body_text.strip().lstrip("\n") if email_body_text else None

View file

@ -579,8 +579,8 @@ class DomainExport(BaseExport):
Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False), Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False),
then=F("portfolio__federal_agency__federal_type"), then=F("portfolio__federal_agency__federal_type"),
), ),
# Otherwise, return the natively assigned value # Otherwise, return the federal type from federal agency
default=F("federal_type"), default=F("federal_agency__federal_type"),
output_field=CharField(), output_field=CharField(),
), ),
"converted_organization_name": Case( "converted_organization_name": Case(
@ -1654,8 +1654,8 @@ class DomainRequestExport(BaseExport):
Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False), Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False),
then=F("portfolio__federal_agency__federal_type"), then=F("portfolio__federal_agency__federal_type"),
), ),
# Otherwise, return the natively assigned value # Otherwise, return the federal type from federal agency
default=F("federal_type"), default=F("federal_agency__federal_type"),
output_field=CharField(), output_field=CharField(),
), ),
"converted_organization_name": Case( "converted_organization_name": Case(

View file

@ -227,7 +227,6 @@ class DomainRequestWizard(TemplateView):
creator=self.request.user, creator=self.request.user,
portfolio=portfolio, portfolio=portfolio,
) )
# Question for reviewers: we should probably be doing this right? # Question for reviewers: we should probably be doing this right?
if portfolio and not self._domain_request.generic_org_type: if portfolio and not self._domain_request.generic_org_type:
self._domain_request.generic_org_type = portfolio.organization_type self._domain_request.generic_org_type = portfolio.organization_type
@ -598,7 +597,6 @@ class RequestingEntity(DomainRequestWizard):
"suborganization_state_territory": None, "suborganization_state_territory": None,
} }
) )
super().save(forms) super().save(forms)
@ -997,11 +995,9 @@ class Finished(DomainRequestWizard):
forms = [] # type: ignore forms = [] # type: ignore
def get(self, request, *args, **kwargs): 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 # clean up this wizard session, because we are done with it
del self.storage 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) @grant_access(IS_DOMAIN_REQUEST_CREATOR, HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT)

View file

@ -970,7 +970,7 @@ class PortfolioAddMemberView(DetailView, FormMixin):
portfolio = form.cleaned_data["portfolio"] portfolio = form.cleaned_data["portfolio"]
is_admin_invitation = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in form.cleaned_data["roles"] 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() permission_exists = UserPortfolioPermission.objects.filter(user=requested_user, portfolio=portfolio).exists()
try: try:
if not requested_user or not permission_exists: if not requested_user or not permission_exists:

View file

@ -6,7 +6,7 @@ from django.shortcuts import render
from django.contrib import admin from django.contrib import admin
from django.db.models import Avg, F 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 from .. import models
import datetime import datetime
from django.utils import timezone from django.utils import timezone
@ -16,7 +16,7 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@grant_access(IS_STAFF) @grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS)
class AnalyticsView(View): class AnalyticsView(View):
def get(self, request): def get(self, request):
thirty_days_ago = datetime.datetime.today() - datetime.timedelta(days=30) thirty_days_ago = datetime.datetime.today() - datetime.timedelta(days=30)
@ -176,7 +176,7 @@ class AnalyticsView(View):
return render(request, "admin/analytics.html", context) return render(request, "admin/analytics.html", context)
@grant_access(IS_STAFF) @grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS)
class ExportDataType(View): class ExportDataType(View):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
# match the CSV example with all the fields # match the CSV example with all the fields
@ -227,7 +227,7 @@ class ExportMembersPortfolio(View):
return response return response
@grant_access(IS_STAFF) @grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS)
class ExportDataFull(View): class ExportDataFull(View):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
# Smaller export based on 1 # Smaller export based on 1
@ -237,7 +237,7 @@ class ExportDataFull(View):
return response return response
@grant_access(IS_STAFF) @grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS)
class ExportDataFederal(View): class ExportDataFederal(View):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
# Federal only # Federal only
@ -247,7 +247,7 @@ class ExportDataFederal(View):
return response return response
@grant_access(IS_STAFF) @grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS)
class ExportDomainRequestDataFull(View): class ExportDomainRequestDataFull(View):
"""Generates a downloaded report containing all Domain Requests (except started)""" """Generates a downloaded report containing all Domain Requests (except started)"""
@ -259,7 +259,7 @@ class ExportDomainRequestDataFull(View):
return response return response
@grant_access(IS_STAFF) @grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS)
class ExportDataDomainsGrowth(View): class ExportDataDomainsGrowth(View):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
start_date = request.GET.get("start_date", "") start_date = request.GET.get("start_date", "")
@ -272,7 +272,7 @@ class ExportDataDomainsGrowth(View):
return response return response
@grant_access(IS_STAFF) @grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS)
class ExportDataRequestsGrowth(View): class ExportDataRequestsGrowth(View):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
start_date = request.GET.get("start_date", "") start_date = request.GET.get("start_date", "")
@ -285,7 +285,7 @@ class ExportDataRequestsGrowth(View):
return response return response
@grant_access(IS_STAFF) @grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS)
class ExportDataManagedDomains(View): class ExportDataManagedDomains(View):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
start_date = request.GET.get("start_date", "") start_date = request.GET.get("start_date", "")
@ -297,7 +297,7 @@ class ExportDataManagedDomains(View):
return response return response
@grant_access(IS_STAFF) @grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS)
class ExportDataUnmanagedDomains(View): class ExportDataUnmanagedDomains(View):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
start_date = request.GET.get("start_date", "") start_date = request.GET.get("start_date", "")

View file

@ -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.shortcuts import render, get_object_or_404, redirect
from django.views import View 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 import Domain
from registrar.models.domain_request import DomainRequest from registrar.models.domain_request import DomainRequest
from registrar.models.user import User from registrar.models.user import User
@ -19,7 +19,7 @@ from registrar.utility.db_helpers import ignore_unique_violation
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@grant_access(IS_STAFF) @grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS)
class TransferUserView(View): class TransferUserView(View):
"""Transfer user methods that set up the transfer_user template and handle the forms on it.""" """Transfer user methods that set up the transfer_user template and handle the forms on it."""

View file

@ -1,7 +1,7 @@
import logging import logging
from django.http import JsonResponse from django.http import JsonResponse
from django.forms.models import model_to_dict 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.models import FederalAgency, SeniorOfficial, DomainRequest
from registrar.utility.admin_helpers import get_action_needed_reason_default_email, get_rejection_reason_default_email from registrar.utility.admin_helpers import get_action_needed_reason_default_email, get_rejection_reason_default_email
from registrar.models.portfolio import Portfolio from registrar.models.portfolio import Portfolio
@ -10,16 +10,10 @@ from registrar.utility.constants import BranchChoices
logger = logging.getLogger(__name__) 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): def get_senior_official_from_federal_agency_json(request):
"""Returns federal_agency information as a JSON""" """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_name = request.GET.get("agency_name")
agency = FederalAgency.objects.filter(agency=agency_name).first() agency = FederalAgency.objects.filter(agency=agency_name).first()
senior_official = SeniorOfficial.objects.filter(federal_agency=agency).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) 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): def get_portfolio_json(request):
"""Returns portfolio information as a JSON""" """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") portfolio_id = request.GET.get("id")
try: try:
portfolio = Portfolio.objects.get(id=portfolio_id) portfolio = Portfolio.objects.get(id=portfolio_id)
@ -93,16 +81,10 @@ def get_portfolio_json(request):
return JsonResponse(portfolio_dict) 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): def get_suborganization_list_json(request):
"""Returns suborganization list information for a portfolio as a JSON""" """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") portfolio_id = request.GET.get("portfolio_id")
try: try:
portfolio = Portfolio.objects.get(id=portfolio_id) portfolio = Portfolio.objects.get(id=portfolio_id)
@ -115,17 +97,11 @@ def get_suborganization_list_json(request):
return JsonResponse({"results": results, "pagination": {"more": False}}) 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): def get_federal_and_portfolio_types_from_federal_agency_json(request):
"""Returns specific portfolio information as a JSON. Request must have """Returns specific portfolio information as a JSON. Request must have
both agency_name and organization_type.""" 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 federal_type = None
portfolio_type = None portfolio_type = None
@ -143,16 +119,10 @@ def get_federal_and_portfolio_types_from_federal_agency_json(request):
return JsonResponse(response_data) 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): def get_action_needed_email_for_user_json(request):
"""Returns a default action needed email for a given user""" """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") reason = request.GET.get("reason")
domain_request_id = request.GET.get("domain_request_id") domain_request_id = request.GET.get("domain_request_id")
if not reason: if not reason:
@ -167,16 +137,10 @@ def get_action_needed_email_for_user_json(request):
return JsonResponse({"email": email}, status=200) 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): def get_rejection_email_for_user_json(request):
"""Returns a default rejection email for a given user""" """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") reason = request.GET.get("reason")
domain_request_id = request.GET.get("domain_request_id") domain_request_id = request.GET.get("domain_request_id")
if not reason: if not reason: