mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-29 22:16:33 +02:00
Merge branch 'main' of https://github.com/cisagov/manage.get.gov into rh/3597-same-name-domain
This commit is contained in:
commit
75adddec8a
87 changed files with 5177 additions and 1504 deletions
76
.github/ISSUE_TEMPLATE/design-issue.yml
vendored
Normal file
76
.github/ISSUE_TEMPLATE/design-issue.yml
vendored
Normal file
|
@ -0,0 +1,76 @@
|
|||
name: Design Issue / story
|
||||
description: Specifically for design and content tickets
|
||||
labels: design, content
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
id: title-help
|
||||
attributes:
|
||||
value: |
|
||||
> Titles should be short, descriptive, and compelling. Use sentence case: don't capitalize words unnecessarily.
|
||||
- type: textarea
|
||||
id: issue-description
|
||||
attributes:
|
||||
label: Issue description
|
||||
description: |
|
||||
Describe the issue so that someone who wasn't present for its discovery can understand why it matters. For stories, use the user story format (e.g., As a user, I want, so that). Use full sentences, plain language, and [good formatting](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax).
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
id: acceptance-criteria-design
|
||||
attributes:
|
||||
value: |
|
||||
### Acceptance criteria for design updates
|
||||
- [ ] Used components from the Figma design system wherein possible
|
||||
- [ ] Merged draft design(s) into the official [registrar](https://www.figma.com/design/xFtGLHVrhp0lvwh0gYWnbx/.gov-registar?m=auto) or [get.gov](https://www.figma.com/design/qeWM03sfjXgHBHB23rsD6X/get.gov?m=auto&t=NevyFHpikXLWEwaL-6) Figma pages
|
||||
- [ ] (If applicable) Updated components from the [Figma design system](https://www.figma.com/design/G2HANRHy8pnlY5ENvmpKY8/.gov-Design-System?m=auto) if there's any inconsistencies with production
|
||||
<br>
|
||||
- type: markdown
|
||||
id: acceptance-criteria-content
|
||||
attributes:
|
||||
value: |
|
||||
### Acceptance criteria for content updates
|
||||
- [ ] **Followed the [content guide](https://docs.google.com/document/d/1U-TRx3ecCFZ-qtrCk7EYmF_nmfKh6kd7FV95Pa4iWeM/edit?usp=sharing) instructions, including:**
|
||||
- [ ] Use official terms in the [word list](https://docs.google.com/document/d/1U-TRx3ecCFZ-qtrCk7EYmF_nmfKh6kd7FV95Pa4iWeM/edit?tab=t.0#heading=h.2et92p0) wherein possible, and avoiding synonyms or alternative terms
|
||||
- [ ] Use [content blocks](https://docs.google.com/document/d/1U-TRx3ecCFZ-qtrCk7EYmF_nmfKh6kd7FV95Pa4iWeM/edit?tab=t.0#heading=h.23ckvvd) whenever possible (consider creating new content blocks for new content that will be referenced heavily)
|
||||
- [ ] Check for readibility using the [Hemingway Editor](https://hemingwayapp.com/)
|
||||
- [ ] Any external links have an [external link icon](https://docs.google.com/document/d/1U-TRx3ecCFZ-qtrCk7EYmF_nmfKh6kd7FV95Pa4iWeM/edit?tab=t.0#heading=h.4i7ojhp) and open in a new tab
|
||||
|
||||
- [ ] **Instructions specific to get.gov ([instructions for how to update the public site](https://docs.google.com/document/d/1U-TRx3ecCFZ-qtrCk7EYmF_nmfKh6kd7FV95Pa4iWeM/edit?tab=t.0#heading=h.1pxezwc)):**
|
||||
- [ ] Update content in the [get.gov design file](https://www.figma.com/design/qeWM03sfjXgHBHB23rsD6X/get.gov?m=auto&t=NevyFHpikXLWEwaL-6) in Figma
|
||||
- [ ] Update the [relevant pages](https://drive.google.com/drive/u/1/folders/1kPsaM6wTli5yjTx1k1QZNlSzYZ-usW4x) in Google Drive's Content folder
|
||||
- [ ] Links [open in a new window](https://docs.google.com/document/d/1U-TRx3ecCFZ-qtrCk7EYmF_nmfKh6kd7FV95Pa4iWeM/edit?tab=t.0#heading=h.g0jcbi63s6zm) if the user will need to reference text on the public site while viewing the link
|
||||
|
||||
- [ ] **Instructions specific to the manage.get.gov ([instructions for how to update the registrar](https://docs.google.com/document/d/1U-TRx3ecCFZ-qtrCk7EYmF_nmfKh6kd7FV95Pa4iWeM/edit?tab=t.0#heading=h.1hmsyys)):**
|
||||
- [ ] Update content in the [registrar design file](https://www.figma.com/design/xFtGLHVrhp0lvwh0gYWnbx/.gov-registar?m=auto) in Figma
|
||||
- [ ] Update the [relevant pages](https://drive.google.com/drive/folders/1dlv_w9zT-W_TStG-7icag6lqcQi86WUs?usp=drive_link) in Google Drive's Content folder
|
||||
- [ ] Links [open in a new window](https://docs.google.com/document/d/1U-TRx3ecCFZ-qtrCk7EYmF_nmfKh6kd7FV95Pa4iWeM/edit?tab=t.0#heading=h.g0jcbi63s6zm)
|
||||
|
||||
- [ ] **Instructions specific to emails:**
|
||||
- [ ] TBD
|
||||
<br>
|
||||
- type: textarea
|
||||
id: additional-acceptance-criteria
|
||||
attributes:
|
||||
label: Additional acceptance criteria
|
||||
description: "If known, add more statements that would need to be true for this issue to be considered resolved. Use a [task list](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/about-task-lists#creating-task-lists) if appropriate."
|
||||
placeholder: "- [ ]"
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: "Share any other thoughts, like how this might be implemented or fixed. Screenshots and links to documents/discussions are welcome."
|
||||
- type: textarea
|
||||
id: links-to-other-issues
|
||||
attributes:
|
||||
label: Links to other issues
|
||||
description: |
|
||||
"Use a dash (`-`) to start the line. Add an issue by typing "`#`" then the issue number. Add information to describe any dependancies, blockers, etc. (e.g., 🚧 [construction] Blocks, ⛔️ [no_entry] Is blocked by, 🔄 [arrows_counterclockwise] Relates to). If this is a parent issue, use sub-issues instead of linking other issues here."
|
||||
placeholder: "- 🔄 Relates to..."
|
||||
- type: markdown
|
||||
id: note
|
||||
attributes:
|
||||
value: |
|
||||
> We may edit the text in this issue to document our understanding and clarify the product work.
|
||||
|
||||
|
2
.github/ISSUE_TEMPLATE/issue-default.yml
vendored
2
.github/ISSUE_TEMPLATE/issue-default.yml
vendored
|
@ -19,7 +19,7 @@ body:
|
|||
id: acceptance-criteria
|
||||
attributes:
|
||||
label: Acceptance criteria
|
||||
description: "If known, share 1-3 statements that would need to be true for this issue to be considered resolved. Use a [task list](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/about-task-lists#creating-task-lists) if appropriate."
|
||||
description: "If known, share 1-3 statements that would need to be true for this issue to be considered resolved. Use a [task list](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/about-task-lists#creating-task-lists) if appropriate. Designers: [Additional considerations](https://github.com/cisagov/manage.get.gov/wiki/Design-work-considerations) are listed in the wiki and can be adapted to ACs here."
|
||||
placeholder: "- [ ]"
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
|
|
|
@ -207,6 +207,17 @@ Linters:
|
|||
docker-compose exec app ./manage.py lint
|
||||
```
|
||||
|
||||
### Get availability for domain requests to work locally
|
||||
|
||||
If you're on local (localhost:8080) and want to submit a domain request, and keep getting the "We’re experiencing a system error. Please wait a few minutes and try again. If you continue to get this error, contact help@get.gov." error, you can get past the availability check by updating the available() function in registrar/models/domain.py to return True and comment everything else out - see below for reference!
|
||||
|
||||
```
|
||||
@classmethod
|
||||
def available(cls, domain: str) -> bool:
|
||||
# Comment everything else out in the function
|
||||
return True
|
||||
```
|
||||
|
||||
### Testing behind logged in pages
|
||||
|
||||
To test behind logged in pages with external tools, like `pa11y-ci` or `OWASP Zap`, add
|
||||
|
@ -305,15 +316,15 @@ You can also compile the **Sass** at any time using `npx gulp compile`. Similarl
|
|||
|
||||
We use the [CSS Block Element Modifier (BEM)](https://getbem.com/naming/) naming convention for our custom classes. This is in line with how USWDS [approaches](https://designsystem.digital.gov/whats-new/updates/2019/04/08/introducing-uswds-2-0/) their CSS class architecture and helps keep our code cohesive and readable.
|
||||
|
||||
### Upgrading USWDS and other JavaScript packages
|
||||
### Updating USWDS
|
||||
|
||||
1. Version numbers can be manually controlled in `package.json`. Edit that, if desired.
|
||||
2. Now run `docker-compose run node npm update`.
|
||||
3. Then run `docker-compose up` to recompile and recopy the assets, or run `docker-compose updateUswds` if your docker is already up.
|
||||
4. Make note of the dotgov changes in uswds-edited.js.
|
||||
5. Copy over the newly compiled code from uswds.js into uswds-edited.js.
|
||||
6. Put back the dotgov changes you made note of into uswds-edited.js.
|
||||
7. Examine the results in the running application (remember to empty your cache!) and commit `package.json` and `package-lock.json` if all is well.
|
||||
2. Now run `npx gulp updateUswds`. Refer to [official docs](https://designsystem.digital.gov/documentation/getting-started/developers/phase-two-compile/) to see what this is doing.
|
||||
3. Make note of the dotgov changes in uswds-edited.js (Ctrl-F DOTGOV for modifications to USWDS compiled code).
|
||||
4. Copy over the newly compiled code from uswds.js into uswds-edited.js.
|
||||
5. Put back the dotgov changes you made note of into uswds-edited.js.
|
||||
6. Examine the results in the running application (remember to empty your cache!) and commit `package.json` and `package-lock.json` if all is well.
|
||||
7. Read the [release notes](https://github.com/uswds/uswds/releases) for the new versions installed, note 'Breaking' and 'Markup change' and make adjustments to the code base as needed.
|
||||
|
||||
## Finite State Machines
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ verify_ssl = true
|
|||
name = "pypi"
|
||||
|
||||
[packages]
|
||||
django = "4.2.17"
|
||||
django = "4.2.20"
|
||||
cfenv = "*"
|
||||
django-cors-headers = "*"
|
||||
pycryptodomex = "*"
|
||||
|
@ -34,6 +34,7 @@ tblib = "*"
|
|||
django-admin-multiple-choice-list-filter = "*"
|
||||
django-import-export = "*"
|
||||
django-waffle = "*"
|
||||
cryptography = "*"
|
||||
|
||||
[dev-packages]
|
||||
django-debug-toolbar = "*"
|
||||
|
|
797
src/Pipfile.lock
generated
797
src/Pipfile.lock
generated
File diff suppressed because it is too large
Load diff
292
src/package-lock.json
generated
292
src/package-lock.json
generated
|
@ -63,23 +63,22 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/core": {
|
||||
"version": "7.26.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.8.tgz",
|
||||
"integrity": "sha512-l+lkXCHS6tQEc5oUpK28xBOZ6+HwaH7YwoYQbLFiYb4nS2/l1tKnZEtEWkD0GuiYdvArf9qBS0XlQGXzPMsNqQ==",
|
||||
"version": "7.26.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.9.tgz",
|
||||
"integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.26.2",
|
||||
"@babel/generator": "^7.26.8",
|
||||
"@babel/generator": "^7.26.9",
|
||||
"@babel/helper-compilation-targets": "^7.26.5",
|
||||
"@babel/helper-module-transforms": "^7.26.0",
|
||||
"@babel/helpers": "^7.26.7",
|
||||
"@babel/parser": "^7.26.8",
|
||||
"@babel/template": "^7.26.8",
|
||||
"@babel/traverse": "^7.26.8",
|
||||
"@babel/types": "^7.26.8",
|
||||
"@types/gensync": "^1.0.0",
|
||||
"@babel/helpers": "^7.26.9",
|
||||
"@babel/parser": "^7.26.9",
|
||||
"@babel/template": "^7.26.9",
|
||||
"@babel/traverse": "^7.26.9",
|
||||
"@babel/types": "^7.26.9",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"debug": "^4.1.0",
|
||||
"gensync": "^1.0.0-beta.2",
|
||||
|
@ -95,14 +94,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.26.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.8.tgz",
|
||||
"integrity": "sha512-ef383X5++iZHWAXX0SXQR6ZyQhw/0KtTkrTz61WXRhFM6dhpHulO/RJz79L8S6ugZHJkOOkUrUdxgdF2YiPFnA==",
|
||||
"version": "7.26.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.9.tgz",
|
||||
"integrity": "sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.26.8",
|
||||
"@babel/types": "^7.26.8",
|
||||
"@babel/parser": "^7.26.9",
|
||||
"@babel/types": "^7.26.9",
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.25",
|
||||
"jsesc": "^3.0.2"
|
||||
|
@ -142,18 +141,18 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/helper-create-class-features-plugin": {
|
||||
"version": "7.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.9.tgz",
|
||||
"integrity": "sha512-UTZQMvt0d/rSz6KI+qdu7GQze5TIajwTS++GUozlw8VBJDEOAqSXwm1WvmYEZwqdqSGQshRocPDqrt4HBZB3fQ==",
|
||||
"version": "7.26.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.26.9.tgz",
|
||||
"integrity": "sha512-ubbUqCofvxPRurw5L8WTsCLSkQiVpov4Qx0WMA+jUN+nXBK8ADPlJO1grkFw5CWKC5+sZSOfuGMdX1aI1iT9Sg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-annotate-as-pure": "^7.25.9",
|
||||
"@babel/helper-member-expression-to-functions": "^7.25.9",
|
||||
"@babel/helper-optimise-call-expression": "^7.25.9",
|
||||
"@babel/helper-replace-supers": "^7.25.9",
|
||||
"@babel/helper-replace-supers": "^7.26.5",
|
||||
"@babel/helper-skip-transparent-expression-wrappers": "^7.25.9",
|
||||
"@babel/traverse": "^7.25.9",
|
||||
"@babel/traverse": "^7.26.9",
|
||||
"semver": "^6.3.1"
|
||||
},
|
||||
"engines": {
|
||||
|
@ -363,27 +362,27 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/helpers": {
|
||||
"version": "7.26.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.7.tgz",
|
||||
"integrity": "sha512-8NHiL98vsi0mbPQmYAGWwfcFaOy4j2HY49fXJCfuDcdE7fMIsH9a7GdaeXpIBsbT7307WU8KCMp5pUVDNL4f9A==",
|
||||
"version": "7.26.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.9.tgz",
|
||||
"integrity": "sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.25.9",
|
||||
"@babel/types": "^7.26.7"
|
||||
"@babel/template": "^7.26.9",
|
||||
"@babel/types": "^7.26.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.26.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.8.tgz",
|
||||
"integrity": "sha512-TZIQ25pkSoaKEYYaHbbxkfL36GNsQ6iFiBbeuzAkLnXayKR1yP1zFe+NxuZWWsUyvt8icPU9CCq0sgWGXR1GEw==",
|
||||
"version": "7.26.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz",
|
||||
"integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.26.8"
|
||||
"@babel/types": "^7.26.9"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
|
@ -809,13 +808,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-for-of": {
|
||||
"version": "7.25.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.25.9.tgz",
|
||||
"integrity": "sha512-LqHxduHoaGELJl2uhImHwRQudhCM50pT46rIBNvtT/Oql3nqiS3wOwP+5ten7NpYSXrrVLgtZU3DZmPtWZo16A==",
|
||||
"version": "7.26.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.26.9.tgz",
|
||||
"integrity": "sha512-Hry8AusVm8LW5BVFgiyUReuoGzPUpdHQQqJY5bZnbbf+ngOHWuCuYFKw/BqaaWlvEUrF91HMhDtEaI1hZzNbLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.25.9",
|
||||
"@babel/helper-plugin-utils": "^7.26.5",
|
||||
"@babel/helper-skip-transparent-expression-wrappers": "^7.25.9"
|
||||
},
|
||||
"engines": {
|
||||
|
@ -1376,9 +1375,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/preset-env": {
|
||||
"version": "7.26.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.8.tgz",
|
||||
"integrity": "sha512-um7Sy+2THd697S4zJEfv/U5MHGJzkN2xhtsR3T/SWRbVSic62nbISh51VVfU9JiO/L/Z97QczHTaFVkOU8IzNg==",
|
||||
"version": "7.26.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.9.tgz",
|
||||
"integrity": "sha512-vX3qPGE8sEKEAZCWk05k3cpTAE3/nOYca++JA+Rd0z2NCNzabmYvEiSShKzm10zdquOIAVXsy2Ei/DTW34KlKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
@ -1411,7 +1410,7 @@
|
|||
"@babel/plugin-transform-dynamic-import": "^7.25.9",
|
||||
"@babel/plugin-transform-exponentiation-operator": "^7.26.3",
|
||||
"@babel/plugin-transform-export-namespace-from": "^7.25.9",
|
||||
"@babel/plugin-transform-for-of": "^7.25.9",
|
||||
"@babel/plugin-transform-for-of": "^7.26.9",
|
||||
"@babel/plugin-transform-function-name": "^7.25.9",
|
||||
"@babel/plugin-transform-json-strings": "^7.25.9",
|
||||
"@babel/plugin-transform-literals": "^7.25.9",
|
||||
|
@ -1475,9 +1474,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.26.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.7.tgz",
|
||||
"integrity": "sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ==",
|
||||
"version": "7.26.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz",
|
||||
"integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
@ -1488,32 +1487,32 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.26.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.8.tgz",
|
||||
"integrity": "sha512-iNKaX3ZebKIsCvJ+0jd6embf+Aulaa3vNBqZ41kM7iTWjx5qzWKXGHiJUW3+nTpQ18SG11hdF8OAzKrpXkb96Q==",
|
||||
"version": "7.26.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz",
|
||||
"integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.26.2",
|
||||
"@babel/parser": "^7.26.8",
|
||||
"@babel/types": "^7.26.8"
|
||||
"@babel/parser": "^7.26.9",
|
||||
"@babel/types": "^7.26.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse": {
|
||||
"version": "7.26.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.8.tgz",
|
||||
"integrity": "sha512-nic9tRkjYH0oB2dzr/JoGIm+4Q6SuYeLEiIiZDwBscRMYFJ+tMAz98fuel9ZnbXViA2I0HVSSRRK8DW5fjXStA==",
|
||||
"version": "7.26.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.9.tgz",
|
||||
"integrity": "sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.26.2",
|
||||
"@babel/generator": "^7.26.8",
|
||||
"@babel/parser": "^7.26.8",
|
||||
"@babel/template": "^7.26.8",
|
||||
"@babel/types": "^7.26.8",
|
||||
"@babel/generator": "^7.26.9",
|
||||
"@babel/parser": "^7.26.9",
|
||||
"@babel/template": "^7.26.9",
|
||||
"@babel/types": "^7.26.9",
|
||||
"debug": "^4.3.1",
|
||||
"globals": "^11.1.0"
|
||||
},
|
||||
|
@ -1522,9 +1521,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.26.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.8.tgz",
|
||||
"integrity": "sha512-eUuWapzEGWFEpHFxgEaBG8e3n6S8L3MSu0oda755rOfabWPnh0Our1AozNFVUxGFIhbKgd1ksprsoDGMinTOTA==",
|
||||
"version": "7.26.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz",
|
||||
"integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
@ -1999,13 +1998,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/gensync": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/gensync/-/gensync-1.0.4.tgz",
|
||||
"integrity": "sha512-C3YYeRQWp2fmq9OryX+FoDy8nXS6scQ7dPptD8LnFDAUNcKWJjXQKDNJD3HVm+kOUsXhTOkpi69vI4EuAr95bA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
|
@ -2014,9 +2006,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.13.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz",
|
||||
"integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==",
|
||||
"version": "22.13.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.9.tgz",
|
||||
"integrity": "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
@ -2255,9 +2247,9 @@
|
|||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.14.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
|
||||
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
|
||||
"version": "8.14.1",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
|
||||
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
@ -2868,9 +2860,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001699",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001699.tgz",
|
||||
"integrity": "sha512-b+uH5BakXZ9Do9iK+CkDmctUSEqZl+SP056vc5usa0PL+ev5OHw003rZXcnjNDv3L8P5j6rwT6C0BPKSikW08w==",
|
||||
"version": "1.0.30001702",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001702.tgz",
|
||||
"integrity": "sha512-LoPe/D7zioC0REI5W73PeR1e1MLCipRGq/VkovJnd6Df+QVqT+vT33OXCp8QUd7kA7RZrHWxb1B36OQKI/0gOA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
@ -3142,13 +3134,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/core-js-compat": {
|
||||
"version": "3.40.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.40.0.tgz",
|
||||
"integrity": "sha512-0XEDpr5y5mijvw8Lbc6E5AkjrHfp7eEoPlu36SWeAbcL8fn1G1ANe8DBlo2XoNN89oVpxWwOjYIPVzR4ZvsKCQ==",
|
||||
"version": "3.41.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.41.0.tgz",
|
||||
"integrity": "sha512-RFsU9LySVue9RTwdDVX/T0e2Y6jRYWXERKElIjpuEOEnxaXffI0X7RUwVzfYLfzuLXSNJDYoRYUAmRUcyln20A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"browserslist": "^4.24.3"
|
||||
"browserslist": "^4.24.4"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
|
@ -3371,9 +3363,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.97",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.97.tgz",
|
||||
"integrity": "sha512-HKLtaH02augM7ZOdYRuO19rWDeY+QSJ1VxnXFa/XDFLf07HvM90pALIJFgrO+UVaajI3+aJMMpojoUTLZyQ7JQ==",
|
||||
"version": "1.5.113",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.113.tgz",
|
||||
"integrity": "sha512-wjT2O4hX+wdWPJ76gWSkMhcHAV2PTMX+QetUCPYEdCIe+cxmgzzSSiGRCKW8nuh4mwKZlpv0xvoW7OF2X+wmHg==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
|
@ -3661,13 +3653,6 @@
|
|||
"node": ">=8.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-json-stable-stringify": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-levenshtein": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-3.0.0.tgz",
|
||||
|
@ -3706,9 +3691,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.19.0",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.0.tgz",
|
||||
"integrity": "sha512-7SFSRCNjBQIZH/xZR3iy5iQYR8aGBE0h3VG6/cwlbrpdciNYBMotQav8c1XI3HjHH+NikUpP53nPdlZSdWmFzA==",
|
||||
"version": "1.19.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
||||
"integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
|
@ -5718,16 +5703,6 @@
|
|||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/puppeteer": {
|
||||
"version": "9.1.1",
|
||||
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-9.1.1.tgz",
|
||||
|
@ -6113,9 +6088,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
|
||||
"integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
@ -6164,9 +6139,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
|
||||
"integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==",
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
|
@ -6186,9 +6161,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.84.0",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.84.0.tgz",
|
||||
"integrity": "sha512-XDAbhEPJRxi7H0SxrnOpiXFQoUJHwkR2u3Zc4el+fK/Tt5Hpzw5kkQ59qVDfvdaUq6gCrEZIbySFBM2T9DNKHg==",
|
||||
"version": "1.85.1",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.85.1.tgz",
|
||||
"integrity": "sha512-Uk8WpxM5v+0cMR0XjX9KfRIacmSG86RH4DCCZjLU2rFh5tyutt9siAXJ7G+YfxQ99Q6wrRMbMlVl6KqUms71ag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
|
@ -6661,9 +6636,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/sass/node_modules/readdirp": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.1.tgz",
|
||||
"integrity": "sha512-h80JrZu/MHUZCyHu5ciuoI0+WxsCxzxJTILn6Fs8rxSnFPh+UVHYfeIxK1nVGugMqkfC4vJcBOYbkfkwYK0+gw==",
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.18.0"
|
||||
|
@ -6978,9 +6953,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/terser": {
|
||||
"version": "5.38.2",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.38.2.tgz",
|
||||
"integrity": "sha512-w8CXxxbFA5zfNsR/i8HZq5bvn18AK0O9jj7hyo1YqkovLxEFa0uP0LCVGZRqiRaKRFxXhELBp8SteeAjEnfeJg==",
|
||||
"version": "5.39.0",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz",
|
||||
"integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
|
@ -6997,9 +6972,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/terser-webpack-plugin": {
|
||||
"version": "5.3.11",
|
||||
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.11.tgz",
|
||||
"integrity": "sha512-RVCsMfuD0+cTt3EwX8hSl2Ks56EbFHWmhluwcqoPKtBnfjiT6olaq7PRIRfhyU8nnC2MrnDrBLfrD/RGE+cVXQ==",
|
||||
"version": "5.3.14",
|
||||
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz",
|
||||
"integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
@ -7229,9 +7204,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz",
|
||||
"integrity": "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==",
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
|
||||
"integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
@ -7259,16 +7234,6 @@
|
|||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uri-js": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
|
@ -7522,9 +7487,9 @@
|
|||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/webpack": {
|
||||
"version": "5.97.1",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.97.1.tgz",
|
||||
"integrity": "sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg==",
|
||||
"version": "5.98.0",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.98.0.tgz",
|
||||
"integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
@ -7546,9 +7511,9 @@
|
|||
"loader-runner": "^4.2.0",
|
||||
"mime-types": "^2.1.27",
|
||||
"neo-async": "^2.6.2",
|
||||
"schema-utils": "^3.2.0",
|
||||
"schema-utils": "^4.3.0",
|
||||
"tapable": "^2.1.1",
|
||||
"terser-webpack-plugin": "^5.3.10",
|
||||
"terser-webpack-plugin": "^5.3.11",
|
||||
"watchpack": "^2.4.1",
|
||||
"webpack-sources": "^3.2.3"
|
||||
},
|
||||
|
@ -7617,59 +7582,6 @@
|
|||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack/node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
"json-schema-traverse": "^0.4.1",
|
||||
"uri-js": "^4.2.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack/node_modules/ajv-keywords": {
|
||||
"version": "3.5.2",
|
||||
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
|
||||
"integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"ajv": "^6.9.1"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack/node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/webpack/node_modules/schema-utils": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
|
||||
"integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.8",
|
||||
"ajv": "^6.12.5",
|
||||
"ajv-keywords": "^3.5.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.13.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-encoding": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
||||
|
@ -7842,9 +7754,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz",
|
||||
"integrity": "sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==",
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.0.tgz",
|
||||
"integrity": "sha512-KHBC7z61OJeaMGnF3wqNZj+GGNXOyypZviiKpQeiHirG5Ib1ImwcLBH70rbMSkKfSmUNBsdf2PwaEJtKvgmkNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
|
@ -76,6 +76,19 @@ from django.utils.translation import gettext_lazy as _
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ImportExportRegistrarModelAdmin(ImportExportModelAdmin):
|
||||
|
||||
def has_import_permission(self, request):
|
||||
return request.user.has_perm("registrar.analyst_access_permission") or request.user.has_perm(
|
||||
"registrar.full_access_permission"
|
||||
)
|
||||
|
||||
def has_export_permission(self, request):
|
||||
return request.user.has_perm("registrar.analyst_access_permission") or request.user.has_perm(
|
||||
"registrar.full_access_permission"
|
||||
)
|
||||
|
||||
|
||||
class FsmModelResource(resources.ModelResource):
|
||||
"""ModelResource is extended to support importing of tables which
|
||||
have FSMFields. ModelResource is extended with the following changes
|
||||
|
@ -466,7 +479,7 @@ class DomainRequestAdminForm(forms.ModelForm):
|
|||
# only set the available transitions if the user is not restricted
|
||||
# from editing the domain request; otherwise, the form will be
|
||||
# readonly and the status field will not have a widget
|
||||
if not domain_request.creator.is_restricted():
|
||||
if not domain_request.creator.is_restricted() and "status" in self.fields:
|
||||
self.fields["status"].widget.choices = available_transitions
|
||||
|
||||
def get_custom_field_transitions(self, instance, field):
|
||||
|
@ -920,7 +933,7 @@ class ListHeaderAdmin(AuditedAdmin, OrderableFieldsMixin):
|
|||
return filters
|
||||
|
||||
|
||||
class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
|
||||
class MyUserAdmin(BaseUserAdmin, ImportExportRegistrarModelAdmin):
|
||||
"""Custom user admin class to use our inlines."""
|
||||
|
||||
resource_classes = [UserResource]
|
||||
|
@ -1225,7 +1238,7 @@ class HostResource(resources.ModelResource):
|
|||
model = models.Host
|
||||
|
||||
|
||||
class MyHostAdmin(AuditedAdmin, ImportExportModelAdmin):
|
||||
class MyHostAdmin(AuditedAdmin, ImportExportRegistrarModelAdmin):
|
||||
"""Custom host admin class to use our inlines."""
|
||||
|
||||
resource_classes = [HostResource]
|
||||
|
@ -1243,7 +1256,7 @@ class HostIpResource(resources.ModelResource):
|
|||
model = models.HostIP
|
||||
|
||||
|
||||
class HostIpAdmin(AuditedAdmin, ImportExportModelAdmin):
|
||||
class HostIpAdmin(AuditedAdmin, ImportExportRegistrarModelAdmin):
|
||||
"""Custom host ip admin class"""
|
||||
|
||||
resource_classes = [HostIpResource]
|
||||
|
@ -1258,7 +1271,7 @@ class ContactResource(resources.ModelResource):
|
|||
model = models.Contact
|
||||
|
||||
|
||||
class ContactAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||
class ContactAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin):
|
||||
"""Custom contact admin class to add search."""
|
||||
|
||||
resource_classes = [ContactResource]
|
||||
|
@ -1392,6 +1405,59 @@ class SeniorOfficialAdmin(ListHeaderAdmin):
|
|||
# in autocomplete_fields for Senior Official
|
||||
ordering = ["first_name", "last_name"]
|
||||
|
||||
readonly_fields = []
|
||||
|
||||
# Even though this is empty, I will leave it as a stub for easy changes in the future
|
||||
# rather than strip it out of our logic.
|
||||
analyst_readonly_fields = [] # type: ignore
|
||||
|
||||
omb_analyst_readonly_fields = [
|
||||
"first_name",
|
||||
"last_name",
|
||||
"title",
|
||||
"phone",
|
||||
"email",
|
||||
"federal_agency",
|
||||
]
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
"""Set the read-only state on form elements.
|
||||
We have conditions that determine which fields are read-only:
|
||||
admin user permissions and analyst (cisa or omb) status, so
|
||||
we'll use the baseline readonly_fields and extend it as needed.
|
||||
"""
|
||||
readonly_fields = list(self.readonly_fields)
|
||||
|
||||
if request.user.has_perm("registrar.full_access_permission"):
|
||||
return readonly_fields
|
||||
# Return restrictive Read-only fields for OMB analysts
|
||||
if request.user.groups.filter(name="omb_analysts_group").exists():
|
||||
readonly_fields.extend([field for field in self.omb_analyst_readonly_fields])
|
||||
return readonly_fields
|
||||
# Return restrictive Read-only fields for analysts and
|
||||
# users who might not belong to groups
|
||||
readonly_fields.extend([field for field in self.analyst_readonly_fields])
|
||||
return readonly_fields
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Restrict queryset based on user permissions."""
|
||||
qs = super().get_queryset(request)
|
||||
|
||||
# Check if user is in OMB analysts group
|
||||
if request.user.groups.filter(name="omb_analysts_group").exists():
|
||||
return qs.filter(federal_agency__federal_type=BranchChoices.EXECUTIVE)
|
||||
|
||||
return qs # Return full queryset if the user doesn't have the restriction
|
||||
|
||||
def has_view_permission(self, request, obj=None):
|
||||
"""Restrict view permissions based on group membership and model attributes."""
|
||||
if request.user.has_perm("registrar.full_access_permission"):
|
||||
return True
|
||||
if obj:
|
||||
if request.user.groups.filter(name="omb_analysts_group").exists():
|
||||
return obj.federal_agency and obj.federal_agency.federal_type == BranchChoices.EXECUTIVE
|
||||
return super().has_view_permission(request, obj)
|
||||
|
||||
|
||||
class WebsiteResource(resources.ModelResource):
|
||||
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
|
||||
|
@ -1401,7 +1467,7 @@ class WebsiteResource(resources.ModelResource):
|
|||
model = models.Website
|
||||
|
||||
|
||||
class WebsiteAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||
class WebsiteAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin):
|
||||
"""Custom website admin class."""
|
||||
|
||||
resource_classes = [WebsiteResource]
|
||||
|
@ -1502,7 +1568,7 @@ class UserPortfolioPermissionAdmin(ListHeaderAdmin):
|
|||
obj.delete() # Calls the overridden delete method on each instance
|
||||
|
||||
|
||||
class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||
class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin):
|
||||
"""Custom user domain role admin class."""
|
||||
|
||||
resource_classes = [UserDomainRoleResource]
|
||||
|
@ -1685,6 +1751,63 @@ class DomainInvitationAdmin(BaseInvitationAdmin):
|
|||
# Override for the delete confirmation page on the domain table (bulk delete action)
|
||||
delete_selected_confirmation_template = "django/admin/domain_invitation_delete_selected_confirmation.html"
|
||||
|
||||
def get_annotated_queryset(self, queryset):
|
||||
return queryset.annotate(
|
||||
converted_generic_org_type=Case(
|
||||
# When portfolio is present, use its value instead
|
||||
When(
|
||||
domain__domain_info__portfolio__isnull=False,
|
||||
then=F("domain__domain_info__portfolio__organization_type"),
|
||||
),
|
||||
# Otherwise, return the natively assigned value
|
||||
default=F("domain__domain_info__generic_org_type"),
|
||||
),
|
||||
converted_federal_type=Case(
|
||||
# When portfolio is present, use its value instead
|
||||
When(
|
||||
Q(domain__domain_info__portfolio__isnull=False)
|
||||
& Q(domain__domain_info__portfolio__federal_agency__isnull=False),
|
||||
then=F("domain__domain_info__portfolio__federal_agency__federal_type"),
|
||||
),
|
||||
# Otherwise, return the federal agency's federal_type
|
||||
default=F("domain__domain_info__federal_agency__federal_type"),
|
||||
),
|
||||
)
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Restrict queryset based on user permissions."""
|
||||
qs = super().get_queryset(request)
|
||||
|
||||
# Check if user is in OMB analysts group
|
||||
if request.user.groups.filter(name="omb_analysts_group").exists():
|
||||
annotated_qs = self.get_annotated_queryset(qs)
|
||||
return annotated_qs.filter(
|
||||
converted_generic_org_type=DomainRequest.OrganizationChoices.FEDERAL,
|
||||
converted_federal_type=BranchChoices.EXECUTIVE,
|
||||
)
|
||||
|
||||
return qs # Return full queryset if the user doesn't have the restriction
|
||||
|
||||
def has_view_permission(self, request, obj=None):
|
||||
"""Restrict view permissions based on group membership and model attributes."""
|
||||
if request.user.has_perm("registrar.full_access_permission"):
|
||||
return True
|
||||
if obj:
|
||||
if request.user.groups.filter(name="omb_analysts_group").exists():
|
||||
return (
|
||||
obj.domain.domain_info.converted_generic_org_type == DomainRequest.OrganizationChoices.FEDERAL
|
||||
and obj.domain.domain_info.converted_federal_type == BranchChoices.EXECUTIVE
|
||||
)
|
||||
return super().has_view_permission(request, obj)
|
||||
|
||||
# Select domain invitations to change -> Domain invitations
|
||||
def changelist_view(self, request, extra_context=None):
|
||||
if extra_context is None:
|
||||
extra_context = {}
|
||||
extra_context["tabtitle"] = "Domain invitations"
|
||||
# Get the filtered values
|
||||
return super().changelist_view(request, extra_context=extra_context)
|
||||
|
||||
def change_view(self, request, object_id, form_url="", extra_context=None):
|
||||
"""Override the change_view to add the invitation obj for the change_form_object_tools template"""
|
||||
|
||||
|
@ -1847,7 +1970,7 @@ class PortfolioInvitationAdmin(BaseInvitationAdmin):
|
|||
requested_user = get_requested_user(requested_email)
|
||||
|
||||
permission_exists = UserPortfolioPermission.objects.filter(
|
||||
user__email=requested_email, portfolio=portfolio, user__email__isnull=False
|
||||
user__email__iexact=requested_email, portfolio=portfolio, user__email__isnull=False
|
||||
).exists()
|
||||
if not permission_exists:
|
||||
# if permission does not exist for a user with requested_email, send email
|
||||
|
@ -1857,9 +1980,7 @@ class PortfolioInvitationAdmin(BaseInvitationAdmin):
|
|||
portfolio=portfolio,
|
||||
is_admin_invitation=is_admin_invitation,
|
||||
):
|
||||
messages.warning(
|
||||
self.request, "Could not send email notification to existing organization admins."
|
||||
)
|
||||
messages.warning(request, "Could not send email notification to existing organization admins.")
|
||||
# if user exists for email, immediately retrieve portfolio invitation upon creation
|
||||
if requested_user is not None:
|
||||
obj.retrieve()
|
||||
|
@ -1908,7 +2029,7 @@ class DomainInformationResource(resources.ModelResource):
|
|||
model = models.DomainInformation
|
||||
|
||||
|
||||
class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||
class DomainInformationAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin):
|
||||
"""Customize domain information admin class."""
|
||||
|
||||
class GenericOrgFilter(admin.SimpleListFilter):
|
||||
|
@ -2185,6 +2306,47 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
"is_policy_acknowledged",
|
||||
]
|
||||
|
||||
# Read only that we'll leverage for OMB Analysts
|
||||
omb_analyst_readonly_fields = [
|
||||
"federal_agency",
|
||||
"creator",
|
||||
"about_your_organization",
|
||||
"anything_else",
|
||||
"cisa_representative_first_name",
|
||||
"cisa_representative_last_name",
|
||||
"cisa_representative_email",
|
||||
"domain_request",
|
||||
"notes",
|
||||
"senior_official",
|
||||
"organization_type",
|
||||
"organization_name",
|
||||
"state_territory",
|
||||
"address_line1",
|
||||
"address_line2",
|
||||
"city",
|
||||
"zipcode",
|
||||
"urbanization",
|
||||
"portfolio_organization_type",
|
||||
"portfolio_federal_type",
|
||||
"portfolio_organization_name",
|
||||
"portfolio_federal_agency",
|
||||
"portfolio_state_territory",
|
||||
"portfolio_address_line1",
|
||||
"portfolio_address_line2",
|
||||
"portfolio_city",
|
||||
"portfolio_zipcode",
|
||||
"portfolio_urbanization",
|
||||
"organization_type",
|
||||
"federal_type",
|
||||
"federal_agency",
|
||||
"tribe_name",
|
||||
"federally_recognized_tribe",
|
||||
"state_recognized_tribe",
|
||||
"about_your_organization",
|
||||
"portfolio",
|
||||
"sub_organization",
|
||||
]
|
||||
|
||||
# For each filter_horizontal, init in admin js initFilterHorizontalWidget
|
||||
# to activate the edit/delete/view buttons
|
||||
filter_horizontal = ("other_contacts",)
|
||||
|
@ -2213,6 +2375,10 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
|
||||
if request.user.has_perm("registrar.full_access_permission"):
|
||||
return readonly_fields
|
||||
# Return restrictive Read-only fields for OMB analysts
|
||||
if request.user.groups.filter(name="omb_analysts_group").exists():
|
||||
readonly_fields.extend([field for field in self.omb_analyst_readonly_fields])
|
||||
return readonly_fields
|
||||
# Return restrictive Read-only fields for analysts and
|
||||
# users who might not belong to groups
|
||||
readonly_fields.extend([field for field in self.analyst_readonly_fields])
|
||||
|
@ -2229,6 +2395,38 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
use_sort = db_field.name != "senior_official"
|
||||
return super().formfield_for_foreignkey(db_field, request, use_admin_sort_fields=use_sort, **kwargs)
|
||||
|
||||
def get_annotated_queryset(self, queryset):
|
||||
return queryset.annotate(
|
||||
conv_generic_org_type=Case(
|
||||
# When portfolio is present, use its value instead
|
||||
When(portfolio__isnull=False, then=F("portfolio__organization_type")),
|
||||
# Otherwise, return the natively assigned value
|
||||
default=F("generic_org_type"),
|
||||
),
|
||||
conv_federal_type=Case(
|
||||
# When portfolio is present, use its value instead
|
||||
When(
|
||||
Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False),
|
||||
then=F("portfolio__federal_agency__federal_type"),
|
||||
),
|
||||
# Otherwise, return the federal_type from federal agency
|
||||
default=F("federal_agency__federal_type"),
|
||||
),
|
||||
)
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Custom get_queryset to filter by portfolio if portfolio is in the
|
||||
request params."""
|
||||
qs = super().get_queryset(request)
|
||||
# Check if user is in OMB analysts group
|
||||
if request.user.groups.filter(name="omb_analysts_group").exists():
|
||||
annotated_qs = self.get_annotated_queryset(qs)
|
||||
return annotated_qs.filter(
|
||||
conv_generic_org_type=DomainRequest.OrganizationChoices.FEDERAL,
|
||||
conv_federal_type=BranchChoices.EXECUTIVE,
|
||||
)
|
||||
return qs
|
||||
|
||||
|
||||
class DomainRequestResource(FsmModelResource):
|
||||
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
|
||||
|
@ -2238,7 +2436,7 @@ class DomainRequestResource(FsmModelResource):
|
|||
model = models.DomainRequest
|
||||
|
||||
|
||||
class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||
class DomainRequestAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin):
|
||||
"""Custom domain requests admin class."""
|
||||
|
||||
resource_classes = [DomainRequestResource]
|
||||
|
@ -2296,7 +2494,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
class FederalTypeFilter(admin.SimpleListFilter):
|
||||
"""Custom Federal Type filter that accomodates portfolio feature.
|
||||
If we have a portfolio, use the portfolio's federal type. If not, use the
|
||||
organization in the Domain Request object."""
|
||||
organization in the Domain Request object's federal agency."""
|
||||
|
||||
title = "federal type"
|
||||
parameter_name = "converted_federal_types"
|
||||
|
@ -2337,7 +2535,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
if self.value():
|
||||
return queryset.filter(
|
||||
Q(portfolio__federal_agency__federal_type=self.value())
|
||||
| Q(portfolio__isnull=True, federal_type=self.value())
|
||||
| Q(portfolio__isnull=True, federal_agency__federal_type=self.value())
|
||||
)
|
||||
return queryset
|
||||
|
||||
|
@ -2752,6 +2950,62 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
"cisa_representative_email",
|
||||
]
|
||||
|
||||
# Read only that we'll leverage for OMB Analysts
|
||||
omb_analyst_readonly_fields = [
|
||||
"federal_agency",
|
||||
"creator",
|
||||
"about_your_organization",
|
||||
"requested_domain",
|
||||
"approved_domain",
|
||||
"alternative_domains",
|
||||
"purpose",
|
||||
"no_other_contacts_rationale",
|
||||
"anything_else",
|
||||
"is_policy_acknowledged",
|
||||
"cisa_representative_first_name",
|
||||
"cisa_representative_last_name",
|
||||
"cisa_representative_email",
|
||||
"status",
|
||||
"investigator",
|
||||
"notes",
|
||||
"senior_official",
|
||||
"organization_type",
|
||||
"organization_name",
|
||||
"state_territory",
|
||||
"address_line1",
|
||||
"address_line2",
|
||||
"city",
|
||||
"zipcode",
|
||||
"urbanization",
|
||||
"portfolio_organization_type",
|
||||
"portfolio_federal_type",
|
||||
"portfolio_organization_name",
|
||||
"portfolio_federal_agency",
|
||||
"portfolio_state_territory",
|
||||
"portfolio_address_line1",
|
||||
"portfolio_address_line2",
|
||||
"portfolio_city",
|
||||
"portfolio_zipcode",
|
||||
"portfolio_urbanization",
|
||||
"is_election_board",
|
||||
"organization_type",
|
||||
"federal_type",
|
||||
"federal_agency",
|
||||
"tribe_name",
|
||||
"federally_recognized_tribe",
|
||||
"state_recognized_tribe",
|
||||
"about_your_organization",
|
||||
"rejection_reason",
|
||||
"rejection_reason_email",
|
||||
"action_needed_reason",
|
||||
"action_needed_reason_email",
|
||||
"portfolio",
|
||||
"sub_organization",
|
||||
"requested_suborganization",
|
||||
"suborganization_city",
|
||||
"suborganization_state_territory",
|
||||
]
|
||||
|
||||
autocomplete_fields = [
|
||||
"approved_domain",
|
||||
"requested_domain",
|
||||
|
@ -3003,6 +3257,10 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
|
||||
if request.user.has_perm("registrar.full_access_permission"):
|
||||
return readonly_fields
|
||||
# Return restrictive Read-only fields for OMB analysts
|
||||
if request.user.groups.filter(name="omb_analysts_group").exists():
|
||||
readonly_fields.extend([field for field in self.omb_analyst_readonly_fields])
|
||||
return readonly_fields
|
||||
# Return restrictive Read-only fields for analysts and
|
||||
# users who might not belong to groups
|
||||
readonly_fields.extend([field for field in self.analyst_readonly_fields])
|
||||
|
@ -3186,6 +3444,25 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
use_sort = db_field.name != "senior_official"
|
||||
return super().formfield_for_foreignkey(db_field, request, use_admin_sort_fields=use_sort, **kwargs)
|
||||
|
||||
def get_annotated_queryset(self, queryset):
|
||||
return queryset.annotate(
|
||||
conv_generic_org_type=Case(
|
||||
# When portfolio is present, use its value instead
|
||||
When(portfolio__isnull=False, then=F("portfolio__organization_type")),
|
||||
# Otherwise, return the natively assigned value
|
||||
default=F("generic_org_type"),
|
||||
),
|
||||
conv_federal_type=Case(
|
||||
# When portfolio is present, use its value instead
|
||||
When(
|
||||
Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False),
|
||||
then=F("portfolio__federal_agency__federal_type"),
|
||||
),
|
||||
# Otherwise, return federal type from federal agency
|
||||
default=F("federal_agency__federal_type"),
|
||||
),
|
||||
)
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Custom get_queryset to filter by portfolio if portfolio is in the
|
||||
request params."""
|
||||
|
@ -3195,8 +3472,39 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
if portfolio_id:
|
||||
# Further filter the queryset by the portfolio
|
||||
qs = qs.filter(portfolio=portfolio_id)
|
||||
# Check if user is in OMB analysts group
|
||||
if request.user.groups.filter(name="omb_analysts_group").exists():
|
||||
annotated_qs = self.get_annotated_queryset(qs)
|
||||
return annotated_qs.filter(
|
||||
conv_generic_org_type=DomainRequest.OrganizationChoices.FEDERAL,
|
||||
conv_federal_type=BranchChoices.EXECUTIVE,
|
||||
)
|
||||
return qs
|
||||
|
||||
def has_view_permission(self, request, obj=None):
|
||||
"""Restrict view permissions based on group membership and model attributes."""
|
||||
if request.user.has_perm("registrar.full_access_permission"):
|
||||
return True
|
||||
if obj:
|
||||
if request.user.groups.filter(name="omb_analysts_group").exists():
|
||||
return (
|
||||
obj.converted_generic_org_type == DomainRequest.OrganizationChoices.FEDERAL
|
||||
and obj.converted_federal_type == BranchChoices.EXECUTIVE
|
||||
)
|
||||
return super().has_view_permission(request, obj)
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
"""Restrict update permissions based on group membership and model attributes."""
|
||||
if request.user.has_perm("registrar.full_access_permission"):
|
||||
return True
|
||||
if obj:
|
||||
if request.user.groups.filter(name="omb_analysts_group").exists():
|
||||
return (
|
||||
obj.converted_generic_org_type == DomainRequest.OrganizationChoices.FEDERAL
|
||||
and obj.converted_federal_type == BranchChoices.EXECUTIVE
|
||||
)
|
||||
return super().has_change_permission(request, obj)
|
||||
|
||||
def get_search_results(self, request, queryset, search_term):
|
||||
# Call the parent's method to apply default search logic
|
||||
base_queryset, use_distinct = super().get_search_results(request, queryset, search_term)
|
||||
|
@ -3216,6 +3524,15 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
|
||||
return combined_queryset, use_distinct
|
||||
|
||||
def get_form(self, request, obj=None, **kwargs):
|
||||
"""Pass the 'is_omb_analyst' attribute to the form."""
|
||||
form = super().get_form(request, obj, **kwargs)
|
||||
|
||||
# Store attribute in the form for template access
|
||||
form.show_contact_as_plain_text = request.user.groups.filter(name="omb_analysts_group").exists()
|
||||
|
||||
return form
|
||||
|
||||
|
||||
class TransitionDomainAdmin(ListHeaderAdmin):
|
||||
"""Custom transition domain admin class."""
|
||||
|
@ -3247,6 +3564,16 @@ class DomainInformationInline(admin.StackedInline):
|
|||
template = "django/admin/includes/domain_info_inline_stacked.html"
|
||||
model = models.DomainInformation
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize the admin class and define a default value for is_omb_analyst."""
|
||||
super().__init__(*args, **kwargs)
|
||||
self.is_omb_analyst = False # Default value in case it's accessed before being set
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Ensure self.is_omb_analyst is set early."""
|
||||
self.is_omb_analyst = request.user.groups.filter(name="omb_analysts_group").exists()
|
||||
return super().get_queryset(request)
|
||||
|
||||
# Define methods to display fields from the related portfolio
|
||||
def portfolio_senior_official(self, obj) -> Optional[SeniorOfficial]:
|
||||
return obj.portfolio.senior_official if obj.portfolio and obj.portfolio.senior_official else None
|
||||
|
@ -3314,6 +3641,7 @@ class DomainInformationInline(admin.StackedInline):
|
|||
fieldsets = copy.deepcopy(list(DomainInformationAdmin.fieldsets))
|
||||
readonly_fields = copy.deepcopy(DomainInformationAdmin.readonly_fields)
|
||||
analyst_readonly_fields = copy.deepcopy(DomainInformationAdmin.analyst_readonly_fields)
|
||||
omb_analyst_readonly_fields = copy.deepcopy(DomainInformationAdmin.omb_analyst_readonly_fields)
|
||||
autocomplete_fields = copy.deepcopy(DomainInformationAdmin.autocomplete_fields)
|
||||
|
||||
def get_domain_managers(self, obj):
|
||||
|
@ -3334,11 +3662,15 @@ class DomainInformationInline(admin.StackedInline):
|
|||
if not domain_managers:
|
||||
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:
|
||||
full_name = domain_manager.get_formatted_name()
|
||||
change_url = reverse("admin:registrar_user_change", args=[domain_manager.pk])
|
||||
domain_manager_details += "<tr>"
|
||||
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(domain_manager.email)}</td>"
|
||||
|
@ -3371,7 +3703,8 @@ class DomainInformationInline(admin.StackedInline):
|
|||
|
||||
superuser_perm = request.user.has_perm("registrar.full_access_permission")
|
||||
analyst_perm = request.user.has_perm("registrar.analyst_access_permission")
|
||||
if analyst_perm and not superuser_perm:
|
||||
omb_analyst_perm = request.user.groups.filter(name="omb_analysts_group").exists()
|
||||
if (analyst_perm or omb_analyst_perm) and not superuser_perm:
|
||||
return True
|
||||
return super().has_change_permission(request, obj)
|
||||
|
||||
|
@ -3445,6 +3778,23 @@ class DomainInformationInline(admin.StackedInline):
|
|||
|
||||
return modified_fieldsets
|
||||
|
||||
def get_form(self, request, obj=None, **kwargs):
|
||||
"""Pass the 'is_omb_analyst' attribute to the form."""
|
||||
form = super().get_form(request, obj, **kwargs)
|
||||
|
||||
# Store attribute in the form for template access
|
||||
self.is_omb_analyst = request.user.groups.filter(name="omb_analysts_group").exists()
|
||||
form.show_contact_as_plain_text = self.is_omb_analyst
|
||||
form.is_omb_analyst = self.is_omb_analyst
|
||||
|
||||
return form
|
||||
|
||||
def get_formset(self, request, obj=None, **kwargs):
|
||||
"""Attach request to the formset so that it can be available in the form"""
|
||||
formset = super().get_formset(request, obj, **kwargs)
|
||||
formset.form.request = request # Attach request to form
|
||||
return formset
|
||||
|
||||
|
||||
class DomainResource(FsmModelResource):
|
||||
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
|
||||
|
@ -3454,7 +3804,7 @@ class DomainResource(FsmModelResource):
|
|||
model = models.Domain
|
||||
|
||||
|
||||
class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||
class DomainAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin):
|
||||
"""Custom domain admin class to add extra buttons."""
|
||||
|
||||
resource_classes = [DomainResource]
|
||||
|
@ -3566,7 +3916,7 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
if self.value():
|
||||
return queryset.filter(
|
||||
Q(domain_info__portfolio__federal_type=self.value())
|
||||
| Q(domain_info__portfolio__isnull=True, domain_info__federal_type=self.value())
|
||||
| Q(domain_info__portfolio__isnull=True, domain_info__federal_agency__federal_type=self.value())
|
||||
)
|
||||
return queryset
|
||||
|
||||
|
@ -3593,7 +3943,7 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
Q(domain_info__portfolio__isnull=False) & Q(domain_info__portfolio__federal_agency__isnull=False),
|
||||
then=F("domain_info__portfolio__federal_agency__federal_type"),
|
||||
),
|
||||
# Otherwise, return the natively assigned value
|
||||
# Otherwise, return federal type from federal agency
|
||||
default=F("domain_info__federal_agency__federal_type"),
|
||||
),
|
||||
converted_organization_name=Case(
|
||||
|
@ -4020,8 +4370,10 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
# Fixes a bug wherein users which are only is_staff
|
||||
# can access 'change' when GET,
|
||||
# but cannot access this page when it is a request of type POST.
|
||||
if request.user.has_perm("registrar.full_access_permission") or request.user.has_perm(
|
||||
"registrar.analyst_access_permission"
|
||||
if (
|
||||
request.user.has_perm("registrar.full_access_permission")
|
||||
or request.user.has_perm("registrar.analyst_access_permission")
|
||||
or request.user.groups.filter(name="omb_analysts_group").exists()
|
||||
):
|
||||
return True
|
||||
return super().has_change_permission(request, obj)
|
||||
|
@ -4036,8 +4388,37 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
if portfolio_id:
|
||||
# Further filter the queryset by the portfolio
|
||||
qs = qs.filter(domain_info__portfolio=portfolio_id)
|
||||
# Check if user is in OMB analysts group
|
||||
if request.user.groups.filter(name="omb_analysts_group").exists():
|
||||
return qs.filter(
|
||||
converted_generic_org_type=DomainRequest.OrganizationChoices.FEDERAL,
|
||||
converted_federal_type=BranchChoices.EXECUTIVE,
|
||||
)
|
||||
return qs
|
||||
|
||||
def has_view_permission(self, request, obj=None):
|
||||
"""Restrict view permissions based on group membership and model attributes."""
|
||||
if request.user.has_perm("registrar.full_access_permission"):
|
||||
return True
|
||||
if obj:
|
||||
if request.user.groups.filter(name="omb_analysts_group").exists():
|
||||
return (
|
||||
obj.domain_info.converted_generic_org_type == DomainRequest.OrganizationChoices.FEDERAL
|
||||
and obj.domain_info.converted_federal_type == BranchChoices.EXECUTIVE
|
||||
)
|
||||
return super().has_view_permission(request, obj)
|
||||
|
||||
def get_form(self, request, obj=None, **kwargs):
|
||||
"""Pass the 'is_omb_analyst' attribute to the form."""
|
||||
form = super().get_form(request, obj, **kwargs)
|
||||
|
||||
# Store attribute in the form for template access
|
||||
is_omb_analyst = request.user.groups.filter(name="omb_analysts_group").exists()
|
||||
form.show_contact_as_plain_text = is_omb_analyst
|
||||
form.is_omb_analyst = is_omb_analyst
|
||||
|
||||
return form
|
||||
|
||||
|
||||
class DraftDomainResource(resources.ModelResource):
|
||||
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
|
||||
|
@ -4047,7 +4428,7 @@ class DraftDomainResource(resources.ModelResource):
|
|||
model = models.DraftDomain
|
||||
|
||||
|
||||
class DraftDomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||
class DraftDomainAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin):
|
||||
"""Custom draft domain admin class."""
|
||||
|
||||
resource_classes = [DraftDomainResource]
|
||||
|
@ -4159,7 +4540,7 @@ class PublicContactResource(resources.ModelResource):
|
|||
self.after_save_instance(instance, using_transactions, dry_run)
|
||||
|
||||
|
||||
class PublicContactAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||
class PublicContactAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin):
|
||||
"""Custom PublicContact admin class."""
|
||||
|
||||
resource_classes = [PublicContactResource]
|
||||
|
@ -4214,6 +4595,11 @@ class PortfolioAdmin(ListHeaderAdmin):
|
|||
|
||||
_meta = Meta()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize the admin class and define a default value for is_omb_analyst."""
|
||||
super().__init__(*args, **kwargs)
|
||||
self.is_omb_analyst = False # Default value in case it's accessed before being set
|
||||
|
||||
change_form_template = "django/admin/portfolio_change_form.html"
|
||||
fieldsets = [
|
||||
# created_on is the created_at field
|
||||
|
@ -4295,6 +4681,19 @@ class PortfolioAdmin(ListHeaderAdmin):
|
|||
# rather than strip it out of our logic.
|
||||
analyst_readonly_fields = [] # type: ignore
|
||||
|
||||
omb_analyst_readonly_fields = [
|
||||
"notes",
|
||||
"organization_type",
|
||||
"organization_name",
|
||||
"federal_agency",
|
||||
"state_territory",
|
||||
"address_line1",
|
||||
"address_line2",
|
||||
"city",
|
||||
"zipcode",
|
||||
"urbanization",
|
||||
]
|
||||
|
||||
def get_admin_users(self, obj):
|
||||
# Filter UserPortfolioPermission objects related to the portfolio
|
||||
admin_permissions = self.get_user_portfolio_permission_admins(obj)
|
||||
|
@ -4380,6 +4779,8 @@ class PortfolioAdmin(ListHeaderAdmin):
|
|||
"""Returns the number of administrators for this portfolio"""
|
||||
admin_count = len(self.get_user_portfolio_permission_admins(obj))
|
||||
if admin_count > 0:
|
||||
if self.is_omb_analyst:
|
||||
return format_html(f"{admin_count} administrators")
|
||||
url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={obj.id}"
|
||||
# Create a clickable link with the count
|
||||
return format_html(f'<a href="{url}">{admin_count} admins</a>')
|
||||
|
@ -4391,6 +4792,8 @@ class PortfolioAdmin(ListHeaderAdmin):
|
|||
"""Returns the number of basic members for this portfolio"""
|
||||
member_count = len(self.get_user_portfolio_permission_non_admins(obj))
|
||||
if member_count > 0:
|
||||
if self.is_omb_analyst:
|
||||
return format_html(f"{member_count} members")
|
||||
url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={obj.id}"
|
||||
# Create a clickable link with the count
|
||||
return format_html(f'<a href="{url}">{member_count} basic members</a>')
|
||||
|
@ -4436,12 +4839,35 @@ class PortfolioAdmin(ListHeaderAdmin):
|
|||
|
||||
if request.user.has_perm("registrar.full_access_permission"):
|
||||
return readonly_fields
|
||||
|
||||
# Return restrictive Read-only fields for OMB analysts
|
||||
if request.user.groups.filter(name="omb_analysts_group").exists():
|
||||
readonly_fields.extend([field for field in self.omb_analyst_readonly_fields])
|
||||
return readonly_fields
|
||||
# Return restrictive Read-only fields for analysts and
|
||||
# users who might not belong to groups
|
||||
readonly_fields.extend([field for field in self.analyst_readonly_fields])
|
||||
return readonly_fields
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Restrict queryset based on user permissions."""
|
||||
qs = super().get_queryset(request)
|
||||
|
||||
# Check if user is in OMB analysts group
|
||||
if request.user.groups.filter(name="omb_analysts_group").exists():
|
||||
self.is_omb_analyst = True
|
||||
return qs.filter(federal_agency__federal_type=BranchChoices.EXECUTIVE)
|
||||
|
||||
return qs # Return full queryset if the user doesn't have the restriction
|
||||
|
||||
def has_view_permission(self, request, obj=None):
|
||||
"""Restrict view permissions based on group membership and model attributes."""
|
||||
if request.user.has_perm("registrar.full_access_permission"):
|
||||
return True
|
||||
if obj:
|
||||
if request.user.groups.filter(name="omb_analysts_group").exists():
|
||||
return obj.federal_type == BranchChoices.EXECUTIVE
|
||||
return super().has_view_permission(request, obj)
|
||||
|
||||
def change_view(self, request, object_id, form_url="", extra_context=None):
|
||||
"""Add related suborganizations and domain groups.
|
||||
Add the summary for the portfolio members field (list of members that link to change_forms)."""
|
||||
|
@ -4486,6 +4912,17 @@ class PortfolioAdmin(ListHeaderAdmin):
|
|||
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
def get_form(self, request, obj=None, **kwargs):
|
||||
"""Pass the 'is_omb_analyst' attribute to the form."""
|
||||
form = super().get_form(request, obj, **kwargs)
|
||||
|
||||
# Store attribute in the form for template access
|
||||
self.is_omb_analyst = request.user.groups.filter(name="omb_analysts_group").exists()
|
||||
form.show_contact_as_plain_text = self.is_omb_analyst
|
||||
form.is_omb_analyst = self.is_omb_analyst
|
||||
|
||||
return form
|
||||
|
||||
|
||||
class FederalAgencyResource(resources.ModelResource):
|
||||
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
|
||||
|
@ -4495,13 +4932,66 @@ class FederalAgencyResource(resources.ModelResource):
|
|||
model = models.FederalAgency
|
||||
|
||||
|
||||
class FederalAgencyAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||
class FederalAgencyAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin):
|
||||
list_display = ["agency"]
|
||||
search_fields = ["agency"]
|
||||
search_help_text = "Search by federal agency."
|
||||
ordering = ["agency"]
|
||||
resource_classes = [FederalAgencyResource]
|
||||
|
||||
# Readonly fields for analysts and superusers
|
||||
readonly_fields = []
|
||||
|
||||
# Read only that we'll leverage for CISA Analysts
|
||||
analyst_readonly_fields = [] # type: ignore
|
||||
|
||||
# Read only that we'll leverage for OMB Analysts
|
||||
omb_analyst_readonly_fields = [
|
||||
"agency",
|
||||
"federal_type",
|
||||
"acronym",
|
||||
"is_fceb",
|
||||
]
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Restrict queryset based on user permissions."""
|
||||
qs = super().get_queryset(request)
|
||||
|
||||
# Check if user is in OMB analysts group
|
||||
if request.user.groups.filter(name="omb_analysts_group").exists():
|
||||
return qs.filter(
|
||||
federal_type=BranchChoices.EXECUTIVE,
|
||||
)
|
||||
|
||||
return qs # Return full queryset if the user doesn't have the restriction
|
||||
|
||||
def has_view_permission(self, request, obj=None):
|
||||
"""Restrict view permissions based on group membership and model attributes."""
|
||||
if request.user.has_perm("registrar.full_access_permission"):
|
||||
return True
|
||||
if obj:
|
||||
if request.user.groups.filter(name="omb_analysts_group").exists():
|
||||
return obj.federal_type == BranchChoices.EXECUTIVE
|
||||
return super().has_view_permission(request, obj)
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
"""Set the read-only state on form elements.
|
||||
We have 2 conditions that determine which fields are read-only:
|
||||
admin user permissions and the domain request creator's status, so
|
||||
we'll use the baseline readonly_fields and extend it as needed.
|
||||
"""
|
||||
readonly_fields = list(self.readonly_fields)
|
||||
if request.user.has_perm("registrar.full_access_permission"):
|
||||
return readonly_fields
|
||||
# Return restrictive Read-only fields for OMB analysts
|
||||
if request.user.groups.filter(name="omb_analysts_group").exists():
|
||||
readonly_fields.extend([field for field in self.omb_analyst_readonly_fields])
|
||||
return readonly_fields
|
||||
# Return restrictive Read-only fields for analysts and
|
||||
# users who might not belong to groups
|
||||
readonly_fields.extend([field for field in self.analyst_readonly_fields])
|
||||
return readonly_fields
|
||||
|
||||
|
||||
class UserGroupAdmin(AuditedAdmin):
|
||||
"""Overwrite the generated UserGroup admin class"""
|
||||
|
@ -4551,11 +5041,11 @@ class WaffleFlagAdmin(FlagAdmin):
|
|||
return super().changelist_view(request, extra_context=extra_context)
|
||||
|
||||
|
||||
class DomainGroupAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||
class DomainGroupAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin):
|
||||
list_display = ["name", "portfolio"]
|
||||
|
||||
|
||||
class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||
class SuborganizationAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin):
|
||||
|
||||
list_display = ["name", "portfolio"]
|
||||
autocomplete_fields = [
|
||||
|
@ -4566,6 +5056,38 @@ class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
|
||||
change_form_template = "django/admin/suborg_change_form.html"
|
||||
|
||||
readonly_fields = []
|
||||
|
||||
# Even though this is empty, I will leave it as a stub for easy changes in the future
|
||||
# rather than strip it out of our logic.
|
||||
analyst_readonly_fields = [] # type: ignore
|
||||
|
||||
omb_analyst_readonly_fields = [
|
||||
"name",
|
||||
"portfolio",
|
||||
"city",
|
||||
"state_territory",
|
||||
]
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
"""Set the read-only state on form elements.
|
||||
We have conditions that determine which fields are read-only:
|
||||
admin user permissions and analyst (cisa or omb) status, so
|
||||
we'll use the baseline readonly_fields and extend it as needed.
|
||||
"""
|
||||
readonly_fields = list(self.readonly_fields)
|
||||
|
||||
if request.user.has_perm("registrar.full_access_permission"):
|
||||
return readonly_fields
|
||||
# Return restrictive Read-only fields for OMB analysts
|
||||
if request.user.groups.filter(name="omb_analysts_group").exists():
|
||||
readonly_fields.extend([field for field in self.omb_analyst_readonly_fields])
|
||||
return readonly_fields
|
||||
# Return restrictive Read-only fields for analysts and
|
||||
# users who might not belong to groups
|
||||
readonly_fields.extend([field for field in self.analyst_readonly_fields])
|
||||
return readonly_fields
|
||||
|
||||
def change_view(self, request, object_id, form_url="", extra_context=None):
|
||||
"""Add suborg's related domains and requests to context"""
|
||||
obj = self.get_object(request, object_id)
|
||||
|
@ -4583,6 +5105,30 @@ class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
extra_context = {"domain_requests": domain_requests, "domains": domains}
|
||||
return super().change_view(request, object_id, form_url, extra_context)
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Custom get_queryset to filter for OMB analysts."""
|
||||
qs = super().get_queryset(request)
|
||||
# Check if user is in OMB analysts group
|
||||
if request.user.groups.filter(name="omb_analysts_group").exists():
|
||||
return qs.filter(
|
||||
portfolio__organization_type=DomainRequest.OrganizationChoices.FEDERAL,
|
||||
portfolio__federal_agency__federal_type=BranchChoices.EXECUTIVE,
|
||||
)
|
||||
return qs
|
||||
|
||||
def has_view_permission(self, request, obj=None):
|
||||
"""Restrict view permissions based on group membership and model attributes."""
|
||||
if request.user.has_perm("registrar.full_access_permission"):
|
||||
return True
|
||||
if obj:
|
||||
if request.user.groups.filter(name="omb_analysts_group").exists():
|
||||
return (
|
||||
obj.portfolio
|
||||
and obj.portfolio.federal_agency
|
||||
and obj.portfolio.federal_agency.federal_type == BranchChoices.EXECUTIVE
|
||||
)
|
||||
return super().has_view_permission(request, obj)
|
||||
|
||||
|
||||
class AllowedEmailAdmin(ListHeaderAdmin):
|
||||
class Meta:
|
||||
|
|
|
@ -105,8 +105,10 @@ export function initApprovedDomain() {
|
|||
return;
|
||||
}
|
||||
|
||||
const statusToCheck = "approved";
|
||||
const statusToCheck = "approved"; // when checking against a select
|
||||
const readonlyStatusToCheck = "Approved"; // when checking against a readonly div display value
|
||||
const statusSelect = document.getElementById("id_status");
|
||||
const statusField = document.querySelector("field-status");
|
||||
const sessionVariableName = "showApprovedDomain";
|
||||
let approvedDomainFormGroup = document.querySelector(".field-approved_domain");
|
||||
|
||||
|
@ -120,11 +122,24 @@ export function initApprovedDomain() {
|
|||
|
||||
// Handle showing/hiding the related fields on page load.
|
||||
function initializeFormGroups() {
|
||||
let isStatus = statusSelect.value == statusToCheck;
|
||||
// Status is either in a select or in a readonly div. Both
|
||||
// cases are handled below.
|
||||
let isStatus = false;
|
||||
if (statusSelect) {
|
||||
isStatus = statusSelect.value == statusToCheck;
|
||||
} else {
|
||||
// statusSelect does not exist, indicating readonly
|
||||
if (statusField) {
|
||||
let readonlyDiv = statusField.querySelector("div.readonly");
|
||||
let readonlyStatusText = readonlyDiv.textContent.trim();
|
||||
isStatus = readonlyStatusText == readonlyStatusToCheck;
|
||||
}
|
||||
}
|
||||
|
||||
// Initial handling of these groups.
|
||||
updateFormGroupVisibility(isStatus);
|
||||
|
||||
if (statusSelect) {
|
||||
// Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage
|
||||
statusSelect.addEventListener('change', () => {
|
||||
// Show the approved if the status is what we expect.
|
||||
|
@ -132,6 +147,7 @@ export function initApprovedDomain() {
|
|||
updateFormGroupVisibility(isStatus);
|
||||
addOrRemoveSessionBoolean(sessionVariableName, isStatus);
|
||||
});
|
||||
}
|
||||
|
||||
// Listen to Back/Forward button navigation and handle approvedDomainFormGroup display based on session storage
|
||||
// When you navigate using forward/back after changing status but not saving, when you land back on the DA page the
|
||||
|
@ -322,6 +338,7 @@ class CustomizableEmailBase {
|
|||
* @property {HTMLElement} modalConfirm - The confirm button in the modal.
|
||||
* @property {string} apiUrl - The API URL for fetching email content.
|
||||
* @property {string} statusToCheck - The status to check against. Used for show/hide on textAreaFormGroup/dropdownFormGroup.
|
||||
* @property {string} readonlyStatusToCheck - The status to check against when readonly. Used for show/hide on textAreaFormGroup/dropdownFormGroup.
|
||||
* @property {string} sessionVariableName - The session variable name. Used for show/hide on textAreaFormGroup/dropdownFormGroup.
|
||||
* @property {string} apiErrorMessage - The error message that the ajax call returns.
|
||||
*/
|
||||
|
@ -338,6 +355,7 @@ class CustomizableEmailBase {
|
|||
this.textAreaFormGroup = config.textAreaFormGroup;
|
||||
this.dropdownFormGroup = config.dropdownFormGroup;
|
||||
this.statusToCheck = config.statusToCheck;
|
||||
this.readonlyStatusToCheck = config.readonlyStatusToCheck;
|
||||
this.sessionVariableName = config.sessionVariableName;
|
||||
|
||||
// Non-configurable variables
|
||||
|
@ -363,11 +381,22 @@ class CustomizableEmailBase {
|
|||
|
||||
// Handle showing/hiding the related fields on page load.
|
||||
initializeFormGroups() {
|
||||
let isStatus = this.statusSelect.value == this.statusToCheck;
|
||||
let isStatus = false;
|
||||
if (this.statusSelect) {
|
||||
isStatus = this.statusSelect.value == this.statusToCheck;
|
||||
} else {
|
||||
// statusSelect does not exist, indicating readonly
|
||||
if (this.dropdownFormGroup) {
|
||||
let readonlyDiv = this.dropdownFormGroup.querySelector("div.readonly");
|
||||
let readonlyStatusText = readonlyDiv.textContent.trim();
|
||||
isStatus = readonlyStatusText == this.readonlyStatusToCheck;
|
||||
}
|
||||
}
|
||||
|
||||
// Initial handling of these groups.
|
||||
this.updateFormGroupVisibility(isStatus);
|
||||
|
||||
if (this.statusSelect) {
|
||||
// Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage
|
||||
this.statusSelect.addEventListener('change', () => {
|
||||
// Show the action needed field if the status is what we expect.
|
||||
|
@ -376,6 +405,7 @@ class CustomizableEmailBase {
|
|||
this.updateFormGroupVisibility(isStatus);
|
||||
addOrRemoveSessionBoolean(this.sessionVariableName, isStatus);
|
||||
});
|
||||
}
|
||||
|
||||
// Listen to Back/Forward button navigation and handle rejectionReasonFormGroup display based on session storage
|
||||
// When you navigate using forward/back after changing status but not saving, when you land back on the DA page the
|
||||
|
@ -403,6 +433,7 @@ class CustomizableEmailBase {
|
|||
}
|
||||
|
||||
initializeDropdown() {
|
||||
if (this.dropdown) {
|
||||
this.dropdown.addEventListener("change", () => {
|
||||
let reason = this.dropdown.value;
|
||||
if (this.initialDropdownValue !== this.dropdown.value || this.initialEmailValue !== this.textarea.value) {
|
||||
|
@ -431,8 +462,11 @@ class CustomizableEmailBase {
|
|||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
initializeModalConfirm() {
|
||||
// When the modal confirm button is present, add a listener
|
||||
if (this.modalConfirm) {
|
||||
this.modalConfirm.addEventListener("click", () => {
|
||||
this.textarea.removeAttribute('readonly');
|
||||
this.textarea.focus();
|
||||
|
@ -440,8 +474,11 @@ class CustomizableEmailBase {
|
|||
hideElement(this.modalTrigger);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
initializeDirectEditButton() {
|
||||
// When the direct edit button is present, add a listener
|
||||
if (this.directEditButton) {
|
||||
this.directEditButton.addEventListener("click", () => {
|
||||
this.textarea.removeAttribute('readonly');
|
||||
this.textarea.focus();
|
||||
|
@ -449,12 +486,13 @@ class CustomizableEmailBase {
|
|||
hideElement(this.modalTrigger);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
isEmailAlreadySent() {
|
||||
return this.lastSentEmailContent.value.replace(/\s+/g, '') === this.textarea.value.replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
updateUserInterface(reason=this.dropdown.value, excluded_reasons=["other"]) {
|
||||
updateUserInterface(reason, excluded_reasons=["other"]) {
|
||||
if (!reason) {
|
||||
// No reason selected, we will set the label to "Email", show the "Make a selection" placeholder, hide the trigger, textarea, hide the help text
|
||||
this.showPlaceholderNoReason();
|
||||
|
@ -468,6 +506,7 @@ class CustomizableEmailBase {
|
|||
|
||||
// Helper function that makes overriding the readonly textarea easy
|
||||
showReadonlyTextarea() {
|
||||
if (this.textarea && this.textareaPlaceholder) {
|
||||
// A triggering selection is selected, all hands on board:
|
||||
this.textarea.setAttribute('readonly', true);
|
||||
showElement(this.textarea);
|
||||
|
@ -487,6 +526,7 @@ class CustomizableEmailBase {
|
|||
this.formLabel.innerHTML = "Email:";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function that makes overriding the placeholder reason easy
|
||||
showPlaceholderNoReason() {
|
||||
|
@ -516,9 +556,10 @@ class customActionNeededEmail extends CustomizableEmailBase {
|
|||
lastSentEmailContent: document.getElementById("last-sent-action-needed-email-content"),
|
||||
modalConfirm: document.getElementById("action-needed-reason__confirm-edit-email"),
|
||||
apiUrl: document.getElementById("get-action-needed-email-for-user-json")?.value || null,
|
||||
textAreaFormGroup: document.querySelector('.field-action_needed_reason'),
|
||||
dropdownFormGroup: document.querySelector('.field-action_needed_reason_email'),
|
||||
textAreaFormGroup: document.querySelector('.field-action_needed_reason_email'),
|
||||
dropdownFormGroup: document.querySelector('.field-action_needed_reason'),
|
||||
statusToCheck: "action needed",
|
||||
readonlyStatusToCheck: "Action needed",
|
||||
sessionVariableName: "showActionNeededReason",
|
||||
apiErrorMessage: "Error when attempting to grab action needed email: "
|
||||
}
|
||||
|
@ -529,7 +570,15 @@ class customActionNeededEmail extends CustomizableEmailBase {
|
|||
// Hide/show the email fields depending on the current status
|
||||
this.initializeFormGroups();
|
||||
// Setup the textarea, edit button, helper text
|
||||
this.updateUserInterface();
|
||||
let reason = null;
|
||||
if (this.dropdown) {
|
||||
reason = this.dropdown.value;
|
||||
} else if (this.dropdownFormGroup && this.dropdownFormGroup.querySelector("div.readonly")) {
|
||||
if (this.dropdownFormGroup.querySelector("div.readonly").textContent) {
|
||||
reason = this.dropdownFormGroup.querySelector("div.readonly").textContent.trim()
|
||||
}
|
||||
}
|
||||
this.updateUserInterface(reason);
|
||||
this.initializeDropdown();
|
||||
this.initializeModalConfirm();
|
||||
this.initializeDirectEditButton();
|
||||
|
@ -560,12 +609,6 @@ export function initActionNeededEmail() {
|
|||
// Initialize UI
|
||||
const customEmail = new customActionNeededEmail();
|
||||
|
||||
// Check that every variable was setup correctly
|
||||
const nullItems = Object.entries(customEmail.config).filter(([key, value]) => value === null).map(([key]) => key);
|
||||
if (nullItems.length > 0) {
|
||||
console.error(`Failed to load customActionNeededEmail(). Some variables were null: ${nullItems.join(", ")}`)
|
||||
return;
|
||||
}
|
||||
customEmail.loadActionNeededEmail()
|
||||
});
|
||||
}
|
||||
|
@ -581,6 +624,7 @@ class customRejectedEmail extends CustomizableEmailBase {
|
|||
textAreaFormGroup: document.querySelector('.field-rejection_reason'),
|
||||
dropdownFormGroup: document.querySelector('.field-rejection_reason_email'),
|
||||
statusToCheck: "rejected",
|
||||
readonlyStatusToCheck: "Rejected",
|
||||
sessionVariableName: "showRejectionReason",
|
||||
errorMessage: "Error when attempting to grab rejected email: "
|
||||
};
|
||||
|
@ -589,7 +633,15 @@ class customRejectedEmail extends CustomizableEmailBase {
|
|||
|
||||
loadRejectedEmail() {
|
||||
this.initializeFormGroups();
|
||||
this.updateUserInterface();
|
||||
let reason = null;
|
||||
if (this.dropdown) {
|
||||
reason = this.dropdown.value;
|
||||
} else if (this.dropdownFormGroup && this.dropdownFormGroup.querySelector("div.readonly")) {
|
||||
if (this.dropdownFormGroup.querySelector("div.readonly").textContent) {
|
||||
reason = this.dropdownFormGroup.querySelector("div.readonly").textContent.trim()
|
||||
}
|
||||
}
|
||||
this.updateUserInterface(reason);
|
||||
this.initializeDropdown();
|
||||
this.initializeModalConfirm();
|
||||
this.initializeDirectEditButton();
|
||||
|
@ -600,7 +652,7 @@ class customRejectedEmail extends CustomizableEmailBase {
|
|||
this.showPlaceholder("Email:", "Select a rejection reason to see email");
|
||||
}
|
||||
|
||||
updateUserInterface(reason=this.dropdown.value, excluded_reasons=[]) {
|
||||
updateUserInterface(reason, excluded_reasons=[]) {
|
||||
super.updateUserInterface(reason, excluded_reasons);
|
||||
}
|
||||
}
|
||||
|
@ -619,12 +671,6 @@ export function initRejectedEmail() {
|
|||
|
||||
// Initialize UI
|
||||
const customEmail = new customRejectedEmail();
|
||||
// Check that every variable was setup correctly
|
||||
const nullItems = Object.entries(customEmail.config).filter(([key, value]) => value === null).map(([key]) => key);
|
||||
if (nullItems.length > 0) {
|
||||
console.error(`Failed to load customRejectedEmail(). Some variables were null: ${nullItems.join(", ")}`)
|
||||
return;
|
||||
}
|
||||
customEmail.loadRejectedEmail()
|
||||
});
|
||||
}
|
||||
|
@ -648,7 +694,6 @@ function handleSuborgFieldsAndButtons() {
|
|||
|
||||
// Ensure that every variable is present before proceeding
|
||||
if (!requestedSuborganizationField || !suborganizationCity || !suborganizationStateTerritory || !rejectButton) {
|
||||
console.warn("handleSuborganizationSelection() => Could not find required fields.")
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -12,7 +12,9 @@ export function handlePortfolioSelection(
|
|||
suborgDropdownSelector="#id_sub_organization"
|
||||
) {
|
||||
// These dropdown are select2 fields so they must be interacted with via jquery
|
||||
// In the event that these fields are readonly, need a variable to reference their row
|
||||
const portfolioDropdown = django.jQuery(portfolioDropdownSelector);
|
||||
const portfolioField = document.querySelector(".field-portfolio");
|
||||
const suborganizationDropdown = django.jQuery(suborgDropdownSelector);
|
||||
const suborganizationField = document.querySelector(".field-sub_organization");
|
||||
const requestedSuborganizationField = document.querySelector(".field-requested_suborganization");
|
||||
|
@ -394,17 +396,33 @@ export function handlePortfolioSelection(
|
|||
* - Various global field elements (e.g., `suborganizationField`, `seniorOfficialField`, `portfolioOrgTypeFieldSet`) are used.
|
||||
*/
|
||||
function updatePortfolioFieldsDisplay() {
|
||||
let portfolio_id = null;
|
||||
let portfolio_selected = false;
|
||||
// portfolio will be either readonly or a dropdown, handle both cases
|
||||
if (portfolioDropdown.length) { // need to test length since the query will always be defined, even if not in DOM
|
||||
// Retrieve the selected portfolio ID
|
||||
let portfolio_id = portfolioDropdown.val();
|
||||
|
||||
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_selected) {
|
||||
// A portfolio is selected - update suborganization dropdown and show/hide relevant fields
|
||||
|
||||
if (portfolio_id) {
|
||||
// Update suborganization dropdown for the selected portfolio
|
||||
updateSubOrganizationDropdown(portfolio_id);
|
||||
}
|
||||
|
||||
// Show fields relevant to a selected portfolio
|
||||
showElement(suborganizationField);
|
||||
if (suborganizationField) showElement(suborganizationField);
|
||||
hideElement(seniorOfficialField);
|
||||
showElement(portfolioSeniorOfficialField);
|
||||
|
||||
|
@ -427,7 +445,7 @@ export function handlePortfolioSelection(
|
|||
// No portfolio is selected - reverse visibility of fields
|
||||
|
||||
// Hide suborganization field as no portfolio is selected
|
||||
hideElement(suborganizationField);
|
||||
if (suborganizationField) hideElement(suborganizationField);
|
||||
|
||||
// Show fields that are relevant when no portfolio is selected
|
||||
showElement(seniorOfficialField);
|
||||
|
@ -468,10 +486,22 @@ export function handlePortfolioSelection(
|
|||
* This function ensures the form dynamically reflects whether a specific suborganization is being selected or requested.
|
||||
*/
|
||||
function updateSuborganizationFieldsDisplay() {
|
||||
let portfolio_id = portfolioDropdown.val();
|
||||
let portfolio_selected = false;
|
||||
// portfolio will be either readonly or a dropdown, handle both cases
|
||||
if (portfolioDropdown.length) { // need to test length since the query will always be defined, even if not in DOM
|
||||
// Retrieve the selected portfolio ID
|
||||
if (portfolioDropdown.val()) {
|
||||
portfolio_selected = true;
|
||||
}
|
||||
} else {
|
||||
// get readonly field value
|
||||
if (portfolioField.querySelector(".readonly").innerText != "-") {
|
||||
portfolio_selected = true;
|
||||
}
|
||||
}
|
||||
let suborganization_id = suborganizationDropdown.val();
|
||||
|
||||
if (portfolio_id && !suborganization_id) {
|
||||
if (portfolio_selected && !suborganization_id) {
|
||||
// Show suborganization request fields
|
||||
if (requestedSuborganizationField) showElement(requestedSuborganizationField);
|
||||
if (suborganizationCity) showElement(suborganizationCity);
|
||||
|
|
|
@ -21,6 +21,8 @@ function handlePortfolioFields(){
|
|||
const federalTypeField = document.querySelector(".field-federal_type");
|
||||
const urbanizationField = document.querySelector(".field-urbanization");
|
||||
const stateTerritoryDropdown = document.getElementById("id_state_territory");
|
||||
const stateTerritoryField = document.querySelector(".field-state_territory");
|
||||
const stateTerritoryReadonly = stateTerritoryField.querySelector(".readonly");
|
||||
const seniorOfficialAddUrl = document.getElementById("senior-official-add-url").value;
|
||||
const seniorOfficialApi = document.getElementById("senior_official_from_agency_json_url").value;
|
||||
const federalPortfolioApi = document.getElementById("federal_and_portfolio_types_from_agency_json_url").value;
|
||||
|
@ -85,9 +87,9 @@ function handlePortfolioFields(){
|
|||
* 2. else show org name, hide federal agency, hide federal type if applicable
|
||||
*/
|
||||
function handleOrganizationTypeChange() {
|
||||
if (organizationTypeDropdown && organizationNameField) {
|
||||
let selectedValue = organizationTypeDropdown.value;
|
||||
if (selectedValue === "federal") {
|
||||
if (organizationTypeField && organizationNameField) {
|
||||
let selectedValue = organizationTypeDropdown ? organizationTypeDropdown.value : organizationTypeReadonly.innerText;
|
||||
if (selectedValue === "federal" || selectedValue === "Federal") {
|
||||
hideElement(organizationNameField);
|
||||
showElement(federalAgencyField);
|
||||
if (federalTypeField) {
|
||||
|
@ -207,8 +209,8 @@ function handlePortfolioFields(){
|
|||
* Handle urbanization
|
||||
*/
|
||||
function handleStateTerritoryChange() {
|
||||
let selectedValue = stateTerritoryDropdown.value;
|
||||
if (selectedValue === "PR") {
|
||||
let selectedValue = stateTerritoryDropdown ? stateTerritoryDropdown.value : stateTerritoryReadonly.innerText;
|
||||
if (selectedValue === "PR" || selectedValue === "Puerto Rico (PR)") {
|
||||
showElement(urbanizationField)
|
||||
} else {
|
||||
hideElement(urbanizationField)
|
||||
|
@ -265,7 +267,7 @@ function handlePortfolioFields(){
|
|||
* Initializes necessary data and display configurations for the portfolio fields.
|
||||
*/
|
||||
function initializePortfolioSettings() {
|
||||
if (urbanizationField && stateTerritoryDropdown) {
|
||||
if (urbanizationField && stateTerritoryField) {
|
||||
handleStateTerritoryChange();
|
||||
}
|
||||
handleOrganizationTypeChange();
|
||||
|
@ -285,10 +287,12 @@ function handlePortfolioFields(){
|
|||
handleStateTerritoryChange();
|
||||
});
|
||||
}
|
||||
if (organizationTypeDropdown) {
|
||||
organizationTypeDropdown.addEventListener("change", function() {
|
||||
handleOrganizationTypeChange();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Run initial setup functions
|
||||
initializePortfolioSettings();
|
||||
|
|
|
@ -1,27 +0,0 @@
|
|||
import { submitForm } from './form-helpers.js';
|
||||
|
||||
export function initDomainDSData() {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
let domain_dsdata_page = document.getElementById("domain-dsdata");
|
||||
if (domain_dsdata_page) {
|
||||
const override_button = document.getElementById("disable-override-click-button");
|
||||
const cancel_button = document.getElementById("btn-cancel-click-button");
|
||||
const cancel_close_button = document.getElementById("btn-cancel-click-close-button");
|
||||
if (override_button) {
|
||||
override_button.addEventListener("click", function () {
|
||||
submitForm("disable-override-click-form");
|
||||
});
|
||||
}
|
||||
if (cancel_button) {
|
||||
cancel_button.addEventListener("click", function () {
|
||||
submitForm("btn-cancel-click-form");
|
||||
});
|
||||
}
|
||||
if (cancel_close_button) {
|
||||
cancel_close_button.addEventListener("click", function () {
|
||||
submitForm("btn-cancel-click-form");
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
521
src/registrar/assets/src/js/getgov/form-dsdata.js
Normal file
521
src/registrar/assets/src/js/getgov/form-dsdata.js
Normal file
|
@ -0,0 +1,521 @@
|
|||
import { showElement, hideElement, scrollToElement } from './helpers';
|
||||
import { removeErrorsFromElement, removeFormErrors } from './form-helpers';
|
||||
|
||||
export class DSDataForm {
|
||||
constructor() {
|
||||
this.addDSDataButton = document.getElementById('dsdata-add-button');
|
||||
this.addDSDataForm = document.querySelector('.add-dsdata-form');
|
||||
this.formChanged = false;
|
||||
this.callback = null;
|
||||
|
||||
// Bind event handlers to maintain 'this' context
|
||||
this.handleAddFormClick = this.handleAddFormClick.bind(this);
|
||||
this.handleEditClick = this.handleEditClick.bind(this);
|
||||
this.handleDeleteClick = this.handleDeleteClick.bind(this);
|
||||
this.handleDeleteKebabClick = this.handleDeleteKebabClick.bind(this);
|
||||
this.handleCancelClick = this.handleCancelClick.bind(this);
|
||||
this.handleCancelAddFormClick = this.handleCancelAddFormClick.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the DSDataForm by setting up display and event listeners.
|
||||
*/
|
||||
init() {
|
||||
this.initializeDSDataFormDisplay();
|
||||
this.initializeEventListeners();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Determines the initial display state of the DS data form,
|
||||
* handling validation errors and setting visibility of elements accordingly.
|
||||
*/
|
||||
initializeDSDataFormDisplay() {
|
||||
|
||||
// This check indicates that there is an Add DS Data form
|
||||
// and that form has errors in it. In this case, show the form, and indicate that the form has
|
||||
// changed.
|
||||
if (this.addDSDataForm && this.addDSDataForm.querySelector('.usa-input--error')) {
|
||||
showElement(this.addDSDataForm);
|
||||
this.formChanged = true;
|
||||
}
|
||||
|
||||
// handle display of table view errors
|
||||
// if error exists in an edit-row, make that row show, and readonly row hide
|
||||
const formTable = document.getElementById('dsdata-table')
|
||||
if (formTable) {
|
||||
const editRows = formTable.querySelectorAll('.edit-row');
|
||||
editRows.forEach(editRow => {
|
||||
if (editRow.querySelector('.usa-input--error')) {
|
||||
const readOnlyRow = editRow.previousElementSibling;
|
||||
this.formChanged = true;
|
||||
showElement(editRow);
|
||||
hideElement(readOnlyRow);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches event listeners to relevant UI elements for interaction handling.
|
||||
*/
|
||||
initializeEventListeners() {
|
||||
this.addDSDataButton.addEventListener('click', this.handleAddFormClick);
|
||||
|
||||
const editButtons = document.querySelectorAll('.dsdata-edit');
|
||||
editButtons.forEach(editButton => {
|
||||
editButton.addEventListener('click', this.handleEditClick);
|
||||
});
|
||||
|
||||
const cancelButtons = document.querySelectorAll('.dsdata-cancel');
|
||||
cancelButtons.forEach(cancelButton => {
|
||||
cancelButton.addEventListener('click', this.handleCancelClick);
|
||||
});
|
||||
|
||||
const cancelAddFormButtons = document.querySelectorAll('.dsdata-cancel-add-form');
|
||||
cancelAddFormButtons.forEach(cancelAddFormButton => {
|
||||
cancelAddFormButton.addEventListener('click', this.handleCancelAddFormClick);
|
||||
});
|
||||
|
||||
const deleteButtons = document.querySelectorAll('.dsdata-delete');
|
||||
deleteButtons.forEach(deleteButton => {
|
||||
deleteButton.addEventListener('click', this.handleDeleteClick);
|
||||
});
|
||||
|
||||
const deleteKebabButtons = document.querySelectorAll('.dsdata-delete-kebab');
|
||||
deleteKebabButtons.forEach(deleteKebabButton => {
|
||||
deleteKebabButton.addEventListener('click', this.handleDeleteKebabClick);
|
||||
});
|
||||
|
||||
const inputs = document.querySelectorAll("input[type='text'], textarea");
|
||||
inputs.forEach(input => {
|
||||
input.addEventListener("input", () => {
|
||||
this.formChanged = true;
|
||||
});
|
||||
});
|
||||
|
||||
const selects = document.querySelectorAll("select");
|
||||
selects.forEach(select => {
|
||||
select.addEventListener("change", () => {
|
||||
this.formChanged = true;
|
||||
});
|
||||
});
|
||||
|
||||
// Set event listeners on the submit buttons for the modals. Event listeners
|
||||
// should execute the callback function, which has its logic updated prior
|
||||
// to modal display
|
||||
const unsaved_changes_modal = document.getElementById('unsaved-changes-modal');
|
||||
if (unsaved_changes_modal) {
|
||||
const submitButton = document.getElementById('unsaved-changes-click-button');
|
||||
const closeButton = unsaved_changes_modal.querySelector('.usa-modal__close');
|
||||
submitButton.addEventListener('click', () => {
|
||||
closeButton.click();
|
||||
this.executeCallback();
|
||||
});
|
||||
}
|
||||
const cancel_changes_modal = document.getElementById('cancel-changes-modal');
|
||||
if (cancel_changes_modal) {
|
||||
const submitButton = document.getElementById('cancel-changes-click-button');
|
||||
const closeButton = cancel_changes_modal.querySelector('.usa-modal__close');
|
||||
submitButton.addEventListener('click', () => {
|
||||
closeButton.click();
|
||||
this.executeCallback();
|
||||
});
|
||||
}
|
||||
const delete_modal = document.getElementById('delete-modal');
|
||||
if (delete_modal) {
|
||||
const submitButton = document.getElementById('delete-click-button');
|
||||
const closeButton = delete_modal.querySelector('.usa-modal__close');
|
||||
submitButton.addEventListener('click', () => {
|
||||
closeButton.click();
|
||||
this.executeCallback();
|
||||
});
|
||||
}
|
||||
const disable_dnssec_modal = document.getElementById('disable-dnssec-modal');
|
||||
if (disable_dnssec_modal) {
|
||||
const submitButton = document.getElementById('disable-dnssec-click-button');
|
||||
const closeButton = disable_dnssec_modal.querySelector('.usa-modal__close');
|
||||
submitButton.addEventListener('click', () => {
|
||||
closeButton.click();
|
||||
this.executeCallback();
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a stored callback function if defined, otherwise logs a warning.
|
||||
*/
|
||||
executeCallback() {
|
||||
if (this.callback) {
|
||||
this.callback();
|
||||
this.callback = null;
|
||||
} else {
|
||||
console.warn("No callback function set.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles clicking the 'Add DS data' button, showing the form if needed.
|
||||
* @param {Event} event - Click event
|
||||
*/
|
||||
handleAddFormClick(event) {
|
||||
this.callback = () => {
|
||||
// Check if any other edit row is currently visible and hide it
|
||||
document.querySelectorAll('tr.edit-row:not(.display-none)').forEach(openEditRow => {
|
||||
this.resetEditRowAndFormAndCollapseEditRow(openEditRow);
|
||||
});
|
||||
if (this.addDSDataForm) {
|
||||
// Check if this.addDSDataForm is visible (i.e., does not have 'display-none')
|
||||
if (!this.addDSDataForm.classList.contains('display-none')) {
|
||||
this.resetAddDSDataForm();
|
||||
}
|
||||
// show add ds data form
|
||||
showElement(this.addDSDataForm);
|
||||
// focus on key tag in the form
|
||||
let keyTagInput = this.addDSDataForm.querySelector('input[name$="-key_tag"]');
|
||||
if (keyTagInput) {
|
||||
keyTagInput.focus();
|
||||
}
|
||||
} else {
|
||||
this.addAlert("error", "You’ve reached the maximum amount of DS Data records (8). To add another record, you’ll need to delete one of your saved records.");
|
||||
}
|
||||
};
|
||||
if (this.formChanged) {
|
||||
//------- Show the unsaved changes confirmation modal
|
||||
let modalTrigger = document.querySelector("#unsaved_changes_trigger");
|
||||
if (modalTrigger) {
|
||||
modalTrigger.click();
|
||||
}
|
||||
} else {
|
||||
this.executeCallback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles clicking an 'Edit' button on a readonly row, which hides the readonly row
|
||||
* and displays the edit row, after performing some checks and possibly displaying modal.
|
||||
* @param {Event} event - Click event
|
||||
*/
|
||||
handleEditClick(event) {
|
||||
let editButton = event.target;
|
||||
let readOnlyRow = editButton.closest('tr'); // Find the closest row
|
||||
let editRow = readOnlyRow.nextElementSibling; // Get the next row
|
||||
if (!editRow || !readOnlyRow) {
|
||||
console.warn("Expected DOM element but did not find it");
|
||||
return;
|
||||
}
|
||||
this.callback = () => {
|
||||
// Check if any other edit row is currently visible and hide it
|
||||
document.querySelectorAll('tr.edit-row:not(.display-none)').forEach(openEditRow => {
|
||||
this.resetEditRowAndFormAndCollapseEditRow(openEditRow);
|
||||
});
|
||||
// Check if this.addDSDataForm is visible (i.e., does not have 'display-none')
|
||||
if (this.addDSDataForm && !this.addDSDataForm.classList.contains('display-none')) {
|
||||
this.resetAddDSDataForm();
|
||||
}
|
||||
// hide and show rows as appropriate
|
||||
hideElement(readOnlyRow);
|
||||
showElement(editRow);
|
||||
};
|
||||
if (this.formChanged) {
|
||||
//------- Show the unsaved changes confirmation modal
|
||||
let modalTrigger = document.querySelector("#unsaved_changes_trigger");
|
||||
if (modalTrigger) {
|
||||
modalTrigger.click();
|
||||
}
|
||||
} else {
|
||||
this.executeCallback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles clicking a 'Delete' button on an edit row, which hattempts to delete the DS record
|
||||
* after displaying modal.
|
||||
* @param {Event} event - Click event
|
||||
*/
|
||||
handleDeleteClick(event) {
|
||||
let deleteButton = event.target;
|
||||
let editRow = deleteButton.closest('tr');
|
||||
if (!editRow) {
|
||||
console.warn("Expected DOM element but did not find it");
|
||||
return;
|
||||
}
|
||||
this.deleteRow(editRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles clicking a 'Delete' button on a readonly row in a kebab, which attempts to delete the DS record
|
||||
* after displaying modal.
|
||||
* @param {Event} event - Click event
|
||||
*/
|
||||
handleDeleteKebabClick(event) {
|
||||
let deleteKebabButton = event.target;
|
||||
let accordionDiv = deleteKebabButton.closest('div');
|
||||
// hide the accordion
|
||||
accordionDiv.hidden = true;
|
||||
let readOnlyRow = deleteKebabButton.closest('tr'); // Find the closest row
|
||||
let editRow = readOnlyRow.nextElementSibling; // Get the next row
|
||||
if (!editRow) {
|
||||
console.warn("Expected DOM element but did not find it");
|
||||
return;
|
||||
}
|
||||
this.deleteRow(editRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a DS record row. If there is only one DS record, prompt the user
|
||||
* that they will be disabling DNSSEC. Otherwise, prompt with delete confiration.
|
||||
* If deletion proceeds, the input fields are cleared, and the form is submitted.
|
||||
* @param {HTMLElement} editRow - The row corresponding to the DS record being deleted.
|
||||
*/
|
||||
deleteRow(editRow) {
|
||||
// update the callback method
|
||||
this.callback = () => {
|
||||
hideElement(editRow);
|
||||
let deleteInput = editRow.querySelector("input[name$='-DELETE']");
|
||||
if (deleteInput) {
|
||||
deleteInput.checked = true;
|
||||
}
|
||||
document.querySelector("form").submit();
|
||||
};
|
||||
// Check if at least 2 DS data records exist before the delete row action is taken
|
||||
const thirdDSData = document.getElementById('id_form-2-key_tag')
|
||||
if (thirdDSData) {
|
||||
let modalTrigger = document.querySelector('#delete_trigger');
|
||||
if (modalTrigger) {
|
||||
modalTrigger.click();
|
||||
}
|
||||
} else {
|
||||
let modalTrigger = document.querySelector('#disable_dnssec_trigger');
|
||||
if (modalTrigger) {
|
||||
modalTrigger.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the click event on the "Cancel" button in the add DS data form.
|
||||
* Resets the form fields and hides the add form section.
|
||||
* @param {Event} event - Click event
|
||||
*/
|
||||
handleCancelAddFormClick(event) {
|
||||
this.callback = () => {
|
||||
this.resetAddDSDataForm();
|
||||
}
|
||||
if (this.formChanged) {
|
||||
// Show the cancel changes confirmation modal
|
||||
let modalTrigger = document.querySelector("#cancel_changes_trigger");
|
||||
if (modalTrigger) {
|
||||
modalTrigger.click();
|
||||
}
|
||||
} else {
|
||||
this.executeCallback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the click event for the cancel button within the table form.
|
||||
*
|
||||
* This method identifies the edit row containing the cancel button and resets
|
||||
* it to its initial state, restoring the corresponding read-only row.
|
||||
*
|
||||
* @param {Event} event - the click event triggered by the cancel button
|
||||
*/
|
||||
handleCancelClick(event) {
|
||||
// get the cancel button that was clicked
|
||||
let cancelButton = event.target;
|
||||
// find the closest table row that contains the cancel button
|
||||
let editRow = cancelButton.closest('tr');
|
||||
this.callback = () => {
|
||||
if (editRow) {
|
||||
this.resetEditRowAndFormAndCollapseEditRow(editRow);
|
||||
} else {
|
||||
console.warn("Expected DOM element but did not find it");
|
||||
}
|
||||
}
|
||||
if (this.formChanged) {
|
||||
// Show the cancel changes confirmation modal
|
||||
let modalTrigger = document.querySelector("#cancel_changes_trigger");
|
||||
if (modalTrigger) {
|
||||
modalTrigger.click();
|
||||
}
|
||||
} else {
|
||||
this.executeCallback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the edit row, restores its original values, removes validation errors,
|
||||
* and collapses the edit row while making the readonly row visible again.
|
||||
* @param {HTMLElement} editRow - The row that is being reset and collapsed.
|
||||
*/
|
||||
resetEditRowAndFormAndCollapseEditRow(editRow) {
|
||||
let readOnlyRow = editRow.previousElementSibling; // Get the next row
|
||||
if (!editRow || !readOnlyRow) {
|
||||
console.warn("Expected DOM element but did not find it");
|
||||
return;
|
||||
}
|
||||
// reset the values set in editRow
|
||||
this.resetInputValuesInElement(editRow);
|
||||
// copy values from editRow to readOnlyRow
|
||||
this.copyEditRowToReadonlyRow(editRow, readOnlyRow);
|
||||
// remove errors from the editRow
|
||||
removeErrorsFromElement(editRow);
|
||||
// remove errors from the entire form
|
||||
removeFormErrors();
|
||||
// reset formChanged
|
||||
this.resetFormChanged();
|
||||
// hide and show rows as appropriate
|
||||
hideElement(editRow);
|
||||
showElement(readOnlyRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the 'Add DS data' form by clearing its input fields, removing errors,
|
||||
* and hiding the form to return it to its initial state.
|
||||
*/
|
||||
resetAddDSDataForm() {
|
||||
if (this.addDSDataForm) {
|
||||
// reset the values set in addDSDataForm
|
||||
this.resetInputValuesInElement(this.addDSDataForm);
|
||||
// remove errors from the addDSDataForm
|
||||
removeErrorsFromElement(this.addDSDataForm);
|
||||
// remove errors from the entire form
|
||||
removeFormErrors();
|
||||
// reset formChanged
|
||||
this.resetFormChanged();
|
||||
// hide the addDSDataForm
|
||||
hideElement(this.addDSDataForm);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets all text input fields within the specified DOM element to their initial values.
|
||||
* Triggers an 'input' event to ensure any event listeners update accordingly.
|
||||
* @param {HTMLElement} domElement - The parent element containing text input fields to be reset.
|
||||
*/
|
||||
resetInputValuesInElement(domElement) {
|
||||
const inputEvent = new Event('input');
|
||||
const changeEvent = new Event('change');
|
||||
// Reset text inputs
|
||||
const inputs = document.querySelectorAll("input[type='text'], textarea");
|
||||
inputs.forEach(input => {
|
||||
// Reset input value to its initial stored value
|
||||
input.value = input.dataset.initialValue;
|
||||
// Dispatch input event to update any event-driven changes
|
||||
input.dispatchEvent(inputEvent);
|
||||
});
|
||||
// Reset select elements
|
||||
let selects = domElement.querySelectorAll("select");
|
||||
selects.forEach(select => {
|
||||
// Reset select value to its initial stored value
|
||||
select.value = select.dataset.initialValue;
|
||||
// Dispatch change event to update any event-driven changes
|
||||
select.dispatchEvent(changeEvent);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies values from the editable row's text inputs into the corresponding
|
||||
* readonly row cells, formatting them appropriately.
|
||||
* @param {HTMLElement} editRow - The row containing editable input fields.
|
||||
* @param {HTMLElement} readOnlyRow - The row where values will be displayed in a non-editable format.
|
||||
*/
|
||||
copyEditRowToReadonlyRow(editRow, readOnlyRow) {
|
||||
let keyTagInput = editRow.querySelector("input[type='text']");
|
||||
let selects = editRow.querySelectorAll("select");
|
||||
let digestInput = editRow.querySelector("textarea");
|
||||
let tds = readOnlyRow.querySelectorAll("td");
|
||||
|
||||
// Copy the key tag input value
|
||||
if (keyTagInput) {
|
||||
tds[0].innerText = keyTagInput.value || "";
|
||||
}
|
||||
|
||||
// Copy select values (showing the selected label instead of value)
|
||||
if (selects[0]) {
|
||||
let selectedOption = selects[0].options[selects[0].selectedIndex];
|
||||
if (tds[1]) {
|
||||
tds[1].innerHTML = `<span class="ellipsis ellipsis--15">${selectedOption ? selectedOption.text : ""}</span>`;
|
||||
}
|
||||
}
|
||||
if (selects[1]) {
|
||||
let selectedOption = selects[1].options[selects[1].selectedIndex];
|
||||
if (tds[2]) {
|
||||
tds[2].innerText = selectedOption ? selectedOption.text : "";
|
||||
}
|
||||
}
|
||||
|
||||
// Copy the digest input value
|
||||
if (digestInput) {
|
||||
tds[3].innerHTML = `<span class="ellipsis ellipsis--23">${digestInput.value || ""}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the form change state.
|
||||
* This method marks the form as unchanged by setting `formChanged` to false.
|
||||
* It is useful for tracking whether a user has modified any form fields.
|
||||
*/
|
||||
resetFormChanged() {
|
||||
this.formChanged = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all existing alert messages from the main content area.
|
||||
* This ensures that only the latest alert is displayed to the user.
|
||||
*/
|
||||
resetAlerts() {
|
||||
const mainContent = document.getElementById("main-content");
|
||||
if (mainContent) {
|
||||
// Remove all alert elements within the main content area
|
||||
mainContent.querySelectorAll(".usa-alert:not(.usa-alert--do-not-reset)").forEach(alert => alert.remove());
|
||||
} else {
|
||||
console.warn("Expecting main-content DOM element");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays an alert message at the top of the main content area.
|
||||
* It first removes any existing alerts before adding a new one to ensure only the latest alert is visible.
|
||||
* @param {string} level - The alert level (e.g., 'error', 'success', 'warning', 'info').
|
||||
* @param {string} message - The message to display inside the alert.
|
||||
*/
|
||||
addAlert(level, message) {
|
||||
this.resetAlerts(); // Remove any existing alerts before adding a new one
|
||||
|
||||
const mainContent = document.getElementById("main-content");
|
||||
if (!mainContent) return;
|
||||
|
||||
// Create a new alert div with appropriate classes based on alert level
|
||||
const alertDiv = document.createElement("div");
|
||||
alertDiv.className = `usa-alert usa-alert--${level} usa-alert--slim margin-bottom-2`;
|
||||
alertDiv.setAttribute("role", "alert"); // Add the role attribute
|
||||
|
||||
// Create the alert body to hold the message text
|
||||
const alertBody = document.createElement("div");
|
||||
alertBody.className = "usa-alert__body";
|
||||
alertBody.textContent = message;
|
||||
|
||||
// Append the alert body to the alert div and insert it at the top of the main content area
|
||||
alertDiv.appendChild(alertBody);
|
||||
mainContent.insertBefore(alertDiv, mainContent.firstChild);
|
||||
|
||||
// Scroll the page to make the alert visible to the user
|
||||
scrollToElement("class", "usa-alert__body");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the DSDataForm when the DOM is fully loaded.
|
||||
*/
|
||||
export function initFormDSData() {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (document.getElementById('dsdata-add-button')) {
|
||||
const dsDataForm = new DSDataForm();
|
||||
dsDataForm.init();
|
||||
}
|
||||
});
|
||||
}
|
|
@ -38,6 +38,16 @@ export function removeErrorsFromElement(domElement) {
|
|||
domElement.querySelectorAll("input.usa-input--error").forEach(input => {
|
||||
input.classList.remove("usa-input--error");
|
||||
});
|
||||
|
||||
// Remove the 'usa-input--error' class from all select elements
|
||||
domElement.querySelectorAll("select.usa-input--error").forEach(select => {
|
||||
select.classList.remove("usa-input--error");
|
||||
});
|
||||
|
||||
// Remove the 'usa-input--error' class from all textarea elements
|
||||
domElement.querySelectorAll("textarea.usa-input--error").forEach(textarea => {
|
||||
textarea.classList.remove("usa-input--error");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -176,6 +176,15 @@ export class NameserverForm {
|
|||
this.executeCallback();
|
||||
});
|
||||
}
|
||||
const cancel_changes_modal = document.getElementById('cancel-changes-modal');
|
||||
if (cancel_changes_modal) {
|
||||
const submitButton = document.getElementById('cancel-changes-click-button');
|
||||
const closeButton = cancel_changes_modal.querySelector('.usa-modal__close');
|
||||
submitButton.addEventListener('click', () => {
|
||||
closeButton.click();
|
||||
this.executeCallback();
|
||||
});
|
||||
}
|
||||
const delete_modal = document.getElementById('delete-modal');
|
||||
if (delete_modal) {
|
||||
const submitButton = document.getElementById('delete-click-button');
|
||||
|
@ -338,8 +347,19 @@ export class NameserverForm {
|
|||
* @param {Event} event - Click event
|
||||
*/
|
||||
handleCancelAddFormClick(event) {
|
||||
this.callback = () => {
|
||||
this.resetAddNameserversForm();
|
||||
}
|
||||
if (this.formChanged) {
|
||||
// Show the cancel changes confirmation modal
|
||||
let modalTrigger = document.querySelector("#cancel_changes_trigger");
|
||||
if (modalTrigger) {
|
||||
modalTrigger.click();
|
||||
}
|
||||
} else {
|
||||
this.executeCallback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the click event for the cancel button within the table form.
|
||||
|
@ -354,12 +374,23 @@ export class NameserverForm {
|
|||
let cancelButton = event.target;
|
||||
// find the closest table row that contains the cancel button
|
||||
let editRow = cancelButton.closest('tr');
|
||||
this.callback = () => {
|
||||
if (editRow) {
|
||||
this.resetEditRowAndFormAndCollapseEditRow(editRow);
|
||||
} else {
|
||||
console.warn("Expected DOM element but did not find it");
|
||||
}
|
||||
}
|
||||
if (this.formChanged) {
|
||||
// Show the cancel changes confirmation modal
|
||||
let modalTrigger = document.querySelector("#cancel_changes_trigger");
|
||||
if (modalTrigger) {
|
||||
modalTrigger.click();
|
||||
}
|
||||
} else {
|
||||
this.executeCallback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the edit row, restores its original values, removes validation errors,
|
||||
|
|
|
@ -84,7 +84,7 @@ function markForm(e, formLabel){
|
|||
}
|
||||
|
||||
/**
|
||||
* Prepare the namerservers, DS data and Other Contacts formsets' delete button
|
||||
* Prepare the Other Contacts formsets' delete button
|
||||
* for the last added form. We call this from the Add function
|
||||
*
|
||||
*/
|
||||
|
@ -108,7 +108,7 @@ function prepareNewDeleteButton(btn, formLabel) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Prepare the namerservers, DS data and Other Contacts formsets' delete buttons
|
||||
* Prepare the Other Contacts formsets' delete buttons
|
||||
* We will call this on the forms init
|
||||
*
|
||||
*/
|
||||
|
@ -172,16 +172,11 @@ export function initFormsetsForms() {
|
|||
let cloneIndex = 0;
|
||||
let formLabel = '';
|
||||
let isOtherContactsForm = document.querySelector(".other-contacts-form");
|
||||
let isDsDataForm = document.querySelector(".ds-data-form");
|
||||
let isDotgovDomain = document.querySelector(".dotgov-domain-form");
|
||||
if( !(isOtherContactsForm || isDotgovDomain || isDsDataForm) ){
|
||||
if( !(isOtherContactsForm || isDotgovDomain) ){
|
||||
return
|
||||
}
|
||||
// DNSSEC: DS Data
|
||||
if (isDsDataForm) {
|
||||
formLabel = "DS data record";
|
||||
// The Other Contacts form
|
||||
} else if (isOtherContactsForm) {
|
||||
if (isOtherContactsForm) {
|
||||
formLabel = "Organization contact";
|
||||
container = document.querySelector("#other-employees");
|
||||
formIdentifier = "other_contacts"
|
||||
|
@ -287,26 +282,3 @@ export function initFormsetsForms() {
|
|||
prepareNewDeleteButton(newDeleteButton, formLabel);
|
||||
}
|
||||
}
|
||||
|
||||
export function triggerModalOnDsDataForm() {
|
||||
let saveButon = document.querySelector("#save-ds-data");
|
||||
|
||||
// The view context will cause a hitherto hidden modal trigger to
|
||||
// show up. On save, we'll test for that modal trigger appearing. We'll
|
||||
// run that test once every 100 ms for 5 secs, which should balance performance
|
||||
// while accounting for network or lag issues.
|
||||
if (saveButon) {
|
||||
let i = 0;
|
||||
var tryToTriggerModal = setInterval(function() {
|
||||
i++;
|
||||
if (i > 100) {
|
||||
clearInterval(tryToTriggerModal);
|
||||
}
|
||||
let modalTrigger = document.querySelector("#ds-toggle-dnssec-alert");
|
||||
if (modalTrigger) {
|
||||
modalTrigger.click()
|
||||
clearInterval(tryToTriggerModal);
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import { hookupYesNoListener, hookupCallbacksToRadioToggler } from './radios.js';
|
||||
import { initDomainValidators } from './domain-validators.js';
|
||||
import { initFormsetsForms, triggerModalOnDsDataForm } from './formset-forms.js';
|
||||
import { initFormNameservers } from './form-nameservers'
|
||||
import { initFormsetsForms } from './formset-forms.js';
|
||||
import { initFormNameservers } from './form-nameservers';
|
||||
import { initFormDSData } from './form-dsdata.js';
|
||||
import { initializeUrbanizationToggle } from './urbanization.js';
|
||||
import { userProfileListener, finishUserSetupListener } from './user-profile.js';
|
||||
import { handleRequestingEntityFieldset } from './requesting-entity.js';
|
||||
|
@ -13,7 +14,6 @@ import { initEditMemberDomainsTable } from './table-edit-member-domains.js';
|
|||
import { initPortfolioNewMemberPageToggle, initAddNewMemberPageListeners, initPortfolioMemberPageRadio } from './portfolio-member-page.js';
|
||||
import { initDomainRequestForm } from './domain-request-form.js';
|
||||
import { initDomainManagersPage } from './domain-managers.js';
|
||||
import { initDomainDSData } from './domain-dsdata.js';
|
||||
import { initDomainDNSSEC } from './domain-dnssec.js';
|
||||
import { initFormErrorHandling } from './form-errors.js';
|
||||
import { domain_purpose_choice_callbacks } from './domain-purpose-form.js';
|
||||
|
@ -22,12 +22,14 @@ import { initButtonLinks } from '../getgov-admin/button-utils.js';
|
|||
initDomainValidators();
|
||||
|
||||
initFormsetsForms();
|
||||
triggerModalOnDsDataForm();
|
||||
initFormNameservers();
|
||||
initFormDSData();
|
||||
|
||||
hookupYesNoListener("other_contacts-has_other_contacts",'other-employees', 'no-other-employees');
|
||||
hookupYesNoListener("additional_details-has_anything_else_text",'anything-else', null);
|
||||
hookupYesNoListener("additional_details-has_cisa_representative",'cisa-representative', null);
|
||||
hookupYesNoListener("portfolio_additional_details-working_with_eop", "eop-contact-container", null);
|
||||
hookupYesNoListener("portfolio_additional_details-has_anything_else_text", 'anything-else-details-container', null);
|
||||
hookupYesNoListener("dotgov_domain-feb_naming_requirements", null, "domain-naming-requirements-details-container");
|
||||
|
||||
hookupCallbacksToRadioToggler("purpose-feb_purpose_choice", domain_purpose_choice_callbacks);
|
||||
|
@ -35,7 +37,6 @@ hookupCallbacksToRadioToggler("purpose-feb_purpose_choice", domain_purpose_choic
|
|||
hookupYesNoListener("purpose-has_timeframe", "purpose-timeframe-details-container", null);
|
||||
hookupYesNoListener("purpose-is_interagency_initiative", "purpose-interagency-initaitive-details-container", null);
|
||||
|
||||
|
||||
initializeUrbanizationToggle();
|
||||
|
||||
userProfileListener();
|
||||
|
@ -51,7 +52,6 @@ initEditMemberDomainsTable();
|
|||
|
||||
initDomainRequestForm();
|
||||
initDomainManagersPage();
|
||||
initDomainDSData();
|
||||
initDomainDNSSEC();
|
||||
|
||||
initFormErrorHandling();
|
||||
|
|
|
@ -103,7 +103,7 @@ export function generateKebabHTML(action, unique_id, modal_button_text, screen_r
|
|||
<div class="usa-accordion__heading">
|
||||
<button
|
||||
type="button"
|
||||
class="usa-button usa-button--unstyled usa-button--with-icon usa-accordion__button usa-button--more-actions"
|
||||
class="usa-button usa-button--unstyled usa-button--with-icon usa-accordion__button usa-button--more-actions margin-top-2px"
|
||||
aria-expanded="false"
|
||||
aria-controls="more-actions-${unique_id}"
|
||||
aria-label="${screen_reader_text}"
|
||||
|
|
|
@ -74,7 +74,7 @@ export class DomainRequestsTable extends BaseTable {
|
|||
|
||||
if (this.portfolioValue) {
|
||||
markupCreatorRow = `
|
||||
<td>
|
||||
<td data-label="Created by">
|
||||
<span class="text-wrap break-word">${request.creator ? request.creator : ''}</span>
|
||||
</td>
|
||||
`
|
||||
|
@ -117,7 +117,7 @@ export class DomainRequestsTable extends BaseTable {
|
|||
<td data-label="Status">
|
||||
${request.status}
|
||||
</td>
|
||||
<td class="width--action-column">
|
||||
<td data-label="Action" class="width--action-column">
|
||||
<div class="tablet:display-flex tablet:flex-row">
|
||||
<a href="${actionUrl}" ${customTableOptions.hasAdditionalActions ? "class='margin-right-2'" : ''}>
|
||||
<svg class="usa-icon top-1px" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
|
|
|
@ -27,7 +27,7 @@ export class DomainsTable extends BaseTable {
|
|||
|
||||
if (this.portfolioValue) {
|
||||
markupForSuborganizationRow = `
|
||||
<td>
|
||||
<td data-label="Suborganization">
|
||||
<span class="text-wrap" aria-label="${domain.suborganization ? suborganization : 'No suborganization'}">${suborganization}</span>
|
||||
</td>
|
||||
`
|
||||
|
@ -56,7 +56,7 @@ export class DomainsTable extends BaseTable {
|
|||
</svg>
|
||||
</td>
|
||||
${markupForSuborganizationRow}
|
||||
<td class="width--action-column">
|
||||
<td data-label="Action" class="width--action-column">
|
||||
<div class="tablet:display-flex tablet:flex-row flex-align-center margin-right-2">
|
||||
<a href="${actionUrl}">
|
||||
<svg class="usa-icon top-1px" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
|
|
|
@ -116,7 +116,7 @@ export class MembersTable extends BaseTable {
|
|||
<td class="padding-bottom-0" headers="header-last-active row-header-${unique_id}" data-sort-value="${last_active.sort_value}" data-label="Last active">
|
||||
${last_active.display_value}
|
||||
</td>
|
||||
<td class="padding-bottom-0" headers="header-action row-header-${unique_id}" class="width--action-column">
|
||||
<td data-label="Action" class="padding-bottom-0" headers="header-action row-header-${unique_id}" class="width--action-column">
|
||||
<div class="tablet:display-flex tablet:flex-row flex-align-center">
|
||||
<a href="${member.action_url}" ${customTableOptions.hasAdditionalActions ? "class='margin-right-2'" : ''}>
|
||||
<svg class="usa-icon top-1px" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
|
|
|
@ -39,6 +39,9 @@
|
|||
.margin-top-0 {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
.margin-top-2px {
|
||||
margin-top: 2px !important;
|
||||
}
|
||||
}
|
||||
|
||||
// This will work in responsive tables if we overwrite the overflow value on the table container
|
||||
|
@ -67,10 +70,11 @@
|
|||
// Currently, that's not an issue since that Members table is not wrapped in the
|
||||
// reponsive wrapper.
|
||||
@include at-media-max("desktop") {
|
||||
tr:last-of-type .usa-accordion--more-actions .usa-accordion__content {
|
||||
tr:last-of-type .usa-accordion--more-actions .usa-accordion__content,
|
||||
tr.view-only-row:nth-last-of-type(2) .usa-accordion--more-actions .usa-accordion__content {
|
||||
top: auto;
|
||||
bottom: -10px;
|
||||
right: 30px;
|
||||
right: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -79,6 +79,10 @@ body {
|
|||
}
|
||||
}
|
||||
|
||||
.section-outlined--extra-padding {
|
||||
padding: units(2) units(3) units(3);
|
||||
}
|
||||
|
||||
.section-outlined--border-base-light {
|
||||
border: 1px solid color('base-light');
|
||||
}
|
||||
|
@ -217,6 +221,10 @@ abbr[title] {
|
|||
max-width: 23ch;
|
||||
}
|
||||
|
||||
.ellipsis--15 {
|
||||
max-width: 15ch;
|
||||
}
|
||||
|
||||
.vertical-align-middle {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
|
|
@ -15,6 +15,11 @@
|
|||
padding-left: $widescreen-x-padding !important;
|
||||
padding-right: $widescreen-x-padding !important;
|
||||
}
|
||||
|
||||
// Accomodate sideanv + table layouts
|
||||
.grid-col--sidenav {
|
||||
max-width: 230px;
|
||||
}
|
||||
}
|
||||
|
||||
// matches max-width to equal the max-width of .grid-container
|
||||
|
@ -22,5 +27,5 @@
|
|||
// regular grid-container within a widescreen (see instances
|
||||
// where is_widescreen_centered is used in the html).
|
||||
.max-width--grid-container {
|
||||
max-width: 960px;
|
||||
max-width: 1024px;
|
||||
}
|
|
@ -79,3 +79,14 @@ legend.float-left-tablet + button.float-right-tablet {
|
|||
.bg-gray-1 .usa-radio {
|
||||
background: color('gray-1');
|
||||
}
|
||||
|
||||
.usa-textarea--digest {
|
||||
max-height: 4rem;
|
||||
min-width: 13rem;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.usa-form .usa-button.margin-top-2 {
|
||||
margin-top: units(2) !important;
|
||||
}
|
||||
|
|
|
@ -142,6 +142,14 @@ th {
|
|||
}
|
||||
}
|
||||
|
||||
.dotgov-table--cell-padding-2-2-2-0 {
|
||||
@include at-media(mobile-lg) {
|
||||
td, th {
|
||||
padding: units(2) units(2) units(2) 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.usa-table--striped tbody tr:nth-child(odd) th,
|
||||
.usa-table--striped tbody tr:nth-child(odd) td {
|
||||
background-color: color('primary-lightest');
|
||||
|
@ -165,4 +173,8 @@ th {
|
|||
.usa-table-container--scrollable.usa-table-container--override-overflow {
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
.usa-table-container--override-scrollable td {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -203,6 +203,8 @@ MIDDLEWARE = [
|
|||
"registrar.registrar_middleware.CheckPortfolioMiddleware",
|
||||
# Restrict access using Opt-Out approach
|
||||
"registrar.registrar_middleware.RestrictAccessMiddleware",
|
||||
# Our own router logs that included user info to speed up log tracing time on stable
|
||||
"registrar.registrar_middleware.RequestLoggingMiddleware",
|
||||
]
|
||||
|
||||
# application object used by Django's built-in servers (e.g. `runserver`)
|
||||
|
|
|
@ -12,6 +12,9 @@ logger = logging.getLogger(__name__)
|
|||
# Constants for clarity
|
||||
ALL = "all"
|
||||
IS_STAFF = "is_staff"
|
||||
IS_CISA_ANALYST = "is_cisa_analyst"
|
||||
IS_OMB_ANALYST = "is_omb_analyst"
|
||||
IS_FULL_ACCESS = "is_full_access"
|
||||
IS_DOMAIN_MANAGER = "is_domain_manager"
|
||||
IS_DOMAIN_REQUEST_CREATOR = "is_domain_request_creator"
|
||||
IS_STAFF_MANAGING_DOMAIN = "is_staff_managing_domain"
|
||||
|
@ -108,6 +111,9 @@ def _user_has_permission(user, request, rules, **kwargs):
|
|||
# Define permission checks
|
||||
permission_checks = [
|
||||
(IS_STAFF, lambda: user.is_staff),
|
||||
(IS_CISA_ANALYST, lambda: user.has_perm("registrar.analyst_access_permission")),
|
||||
(IS_OMB_ANALYST, lambda: user.groups.filter(name="omb_analysts_group").exists()),
|
||||
(IS_FULL_ACCESS, lambda: user.has_perm("registrar.full_access_permission")),
|
||||
(
|
||||
IS_DOMAIN_MANAGER,
|
||||
lambda: (not user.is_org_user(request) and _is_domain_manager(user, **kwargs))
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import logging
|
||||
from django import forms
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator, RegexValidator, MaxLengthValidator
|
||||
from django.core.validators import RegexValidator, MaxLengthValidator
|
||||
from django.forms import formset_factory
|
||||
from registrar.forms.utility.combobox import ComboboxWidget
|
||||
from registrar.models import DomainRequest, FederalAgency
|
||||
|
@ -630,14 +630,10 @@ class DomainDsdataForm(forms.Form):
|
|||
if not re.match(r"^[0-9a-fA-F]+$", value):
|
||||
raise forms.ValidationError(str(DsDataError(code=DsDataErrorCodes.INVALID_DIGEST_CHARS)))
|
||||
|
||||
key_tag = forms.IntegerField(
|
||||
key_tag = forms.CharField(
|
||||
required=True,
|
||||
label="Key tag",
|
||||
validators=[
|
||||
MinValueValidator(0, message=str(DsDataError(code=DsDataErrorCodes.INVALID_KEYTAG_SIZE))),
|
||||
MaxValueValidator(65535, message=str(DsDataError(code=DsDataErrorCodes.INVALID_KEYTAG_SIZE))),
|
||||
],
|
||||
error_messages={"required": ("Key tag is required.")},
|
||||
error_messages={"required": "Key tag is required."},
|
||||
)
|
||||
|
||||
algorithm = forms.TypedChoiceField(
|
||||
|
@ -663,6 +659,13 @@ class DomainDsdataForm(forms.Form):
|
|||
error_messages={
|
||||
"required": "Digest is required.",
|
||||
},
|
||||
widget=forms.Textarea(
|
||||
attrs={
|
||||
"maxlength": "64",
|
||||
"class": "text-wrap usa-textarea--digest",
|
||||
"hide_character_count": "True",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
|
@ -672,6 +675,22 @@ class DomainDsdataForm(forms.Form):
|
|||
cleaned_data = super().clean()
|
||||
digest_type = cleaned_data.get("digest_type", 0)
|
||||
digest = cleaned_data.get("digest", "")
|
||||
|
||||
# Convert key_tag to an integer safely
|
||||
key_tag = cleaned_data.get("key_tag", 0)
|
||||
try:
|
||||
key_tag = int(key_tag)
|
||||
if key_tag < 0 or key_tag > 65535:
|
||||
self.add_error(
|
||||
"key_tag",
|
||||
DsDataError(code=DsDataErrorCodes.INVALID_KEYTAG_SIZE),
|
||||
)
|
||||
except ValueError:
|
||||
self.add_error(
|
||||
"key_tag",
|
||||
DsDataError(code=DsDataErrorCodes.INVALID_KEYTAG_CHARS),
|
||||
)
|
||||
|
||||
# validate length of digest depending on digest_type
|
||||
if digest_type == 1 and len(digest) != 40:
|
||||
self.add_error(
|
||||
|
@ -686,9 +705,45 @@ class DomainDsdataForm(forms.Form):
|
|||
return cleaned_data
|
||||
|
||||
|
||||
class BaseDsdataFormset(forms.BaseFormSet):
|
||||
def clean(self):
|
||||
"""Check for duplicate entries in the formset."""
|
||||
if any(self.errors):
|
||||
return # Skip duplicate checking if other errors exist
|
||||
|
||||
duplicate_errors = self._check_for_duplicates()
|
||||
if duplicate_errors:
|
||||
raise forms.ValidationError("Duplicate DS records found. Each DS record must be unique.")
|
||||
|
||||
def _check_for_duplicates(self):
|
||||
"""Check for duplicate entries in the DS data forms"""
|
||||
|
||||
seen_ds_records = set()
|
||||
duplicate_found = False
|
||||
|
||||
for form in self.forms:
|
||||
if form.cleaned_data.get("key_tag") and not form.cleaned_data.get("DELETE", False):
|
||||
ds_tuple = (
|
||||
form.cleaned_data["key_tag"],
|
||||
form.cleaned_data["algorithm"],
|
||||
form.cleaned_data["digest_type"],
|
||||
form.cleaned_data["digest"].upper(),
|
||||
)
|
||||
|
||||
if ds_tuple in seen_ds_records:
|
||||
form.add_error("key_tag", "You already entered this DS record. DS records must be unique.")
|
||||
duplicate_found = True # Track that we found at least one duplicate
|
||||
|
||||
seen_ds_records.add(ds_tuple)
|
||||
|
||||
return duplicate_found # Returns True if any duplicates were found
|
||||
|
||||
|
||||
DomainDsdataFormset = formset_factory(
|
||||
DomainDsdataForm,
|
||||
extra=0,
|
||||
formset=BaseDsdataFormset,
|
||||
extra=1,
|
||||
max_num=8,
|
||||
can_delete=True,
|
||||
)
|
||||
|
||||
|
|
|
@ -86,7 +86,6 @@ class RequestingEntityForm(RegistrarForm):
|
|||
return {}
|
||||
# get the domain request as a dict, per usual method
|
||||
domain_request_dict = {name: getattr(obj, name) for name in cls.declared_fields.keys()} # type: ignore
|
||||
|
||||
# set sub_organization to 'other' if is_requesting_new_suborganization is True
|
||||
if isinstance(obj, DomainRequest) and obj.is_requesting_new_suborganization():
|
||||
domain_request_dict["sub_organization"] = "other"
|
||||
|
@ -616,7 +615,8 @@ class PurposeDetailsForm(BaseDeletableRegistrarForm):
|
|||
label="Purpose",
|
||||
widget=forms.Textarea(
|
||||
attrs={
|
||||
"aria-label": "What is the purpose of your requested domain? Describe how you’ll use your .gov domain. \
|
||||
"aria-label": "What is the purpose of your requested domain? \
|
||||
Describe how you’ll use your .gov domain. \
|
||||
Will it be used for a website, email, or something else?"
|
||||
}
|
||||
),
|
||||
|
@ -922,6 +922,7 @@ class AnythingElseYesNoForm(BaseYesNoForm):
|
|||
|
||||
|
||||
class RequirementsForm(RegistrarForm):
|
||||
|
||||
is_policy_acknowledged = forms.BooleanField(
|
||||
label="I read and agree to the requirements for operating a .gov domain.",
|
||||
error_messages={
|
||||
|
|
|
@ -121,3 +121,83 @@ class FEBInteragencyInitiativeDetailsForm(BaseDeletableRegistrarForm):
|
|||
],
|
||||
error_messages={"required": "Name the agencies that will be involved in this initiative."},
|
||||
)
|
||||
|
||||
|
||||
class WorkingWithEOPYesNoForm(BaseDeletableRegistrarForm, BaseYesNoForm):
|
||||
"""
|
||||
Form for determining if the Federal Executive Branch (FEB) agency is working with the
|
||||
Executive Office of the President (EOP) on the domain request.
|
||||
"""
|
||||
|
||||
field_name = "working_with_eop"
|
||||
|
||||
@property
|
||||
def form_is_checked(self):
|
||||
"""
|
||||
Determines the initial checked state of the form based on the domain_request's attributes.
|
||||
"""
|
||||
return self.domain_request.working_with_eop
|
||||
|
||||
|
||||
class EOPContactForm(BaseDeletableRegistrarForm):
|
||||
"""
|
||||
Form for contact information of the representative of the
|
||||
Executive Office of the President (EOP) that the Federal
|
||||
Executive Branch (FEB) agency is working with.
|
||||
"""
|
||||
|
||||
first_name = forms.CharField(
|
||||
label="First name / given name",
|
||||
error_messages={"required": "Enter the first name / given name of this contact."},
|
||||
required=True,
|
||||
)
|
||||
last_name = forms.CharField(
|
||||
label="Last name / family name",
|
||||
error_messages={"required": "Enter the last name / family name of this contact."},
|
||||
required=True,
|
||||
)
|
||||
email = forms.EmailField(
|
||||
label="Email",
|
||||
max_length=None,
|
||||
error_messages={
|
||||
"required": ("Enter an email address in the required format, like name@example.com."),
|
||||
"invalid": ("Enter an email address in the required format, like name@example.com."),
|
||||
},
|
||||
validators=[
|
||||
MaxLengthValidator(
|
||||
320,
|
||||
message="Response must be less than 320 characters.",
|
||||
)
|
||||
],
|
||||
required=True,
|
||||
help_text="Enter an email address in the required format, like name@example.com.",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_database(cls, obj):
|
||||
return {
|
||||
"first_name": obj.eop_stakeholder_first_name,
|
||||
"last_name": obj.eop_stakeholder_last_name,
|
||||
"email": obj.eop_stakeholder_email,
|
||||
}
|
||||
|
||||
def to_database(self, obj):
|
||||
# This function overrides the behavior of the BaseDeletableRegistrarForm.
|
||||
# in order to preserve deletable functionality, we need to call the
|
||||
# superclass's to_database method if the form is marked for deletion.
|
||||
if self.form_data_marked_for_deletion:
|
||||
super().to_database(obj)
|
||||
return
|
||||
if not self.is_valid():
|
||||
return
|
||||
obj.eop_stakeholder_first_name = self.cleaned_data["first_name"]
|
||||
obj.eop_stakeholder_last_name = self.cleaned_data["last_name"]
|
||||
obj.eop_stakeholder_email = self.cleaned_data["email"]
|
||||
obj.save()
|
||||
|
||||
|
||||
class FEBAnythingElseYesNoForm(BaseYesNoForm, BaseDeletableRegistrarForm):
|
||||
"""Yes/no toggle for the anything else question on additional details"""
|
||||
|
||||
form_is_checked = property(lambda self: self.domain_request.has_anything_else_text) # type: ignore
|
||||
field_name = "has_anything_else_text"
|
||||
|
|
|
@ -22,6 +22,7 @@ from registrar.models.utility.portfolio_helper import (
|
|||
get_domains_display,
|
||||
get_members_description_display,
|
||||
get_members_display,
|
||||
get_portfolio_invitation_associations,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -459,7 +460,14 @@ class PortfolioNewMemberForm(BasePortfolioMemberForm):
|
|||
if hasattr(e, "code"):
|
||||
field = "email" if "email" in self.fields else None
|
||||
if e.code == "has_existing_permissions":
|
||||
self.add_error(field, f"{self.instance.email} is already a member of another .gov organization.")
|
||||
existing_permissions, existing_invitations = get_portfolio_invitation_associations(self.instance)
|
||||
|
||||
same_portfolio_for_permissions = existing_permissions.exclude(portfolio=self.instance.portfolio)
|
||||
same_portfolio_for_invitations = existing_invitations.exclude(portfolio=self.instance.portfolio)
|
||||
if same_portfolio_for_permissions.exists() or same_portfolio_for_invitations.exists():
|
||||
self.add_error(
|
||||
field, f"{self.instance.email} is already a member of another .gov organization."
|
||||
)
|
||||
override_error = True
|
||||
elif e.code == "has_existing_invitations":
|
||||
self.add_error(
|
||||
|
|
|
@ -60,6 +60,7 @@ class UserProfileForm(forms.ModelForm):
|
|||
self.fields["email"].error_messages = {
|
||||
"required": "Enter an email address in the required format, like name@example.com."
|
||||
}
|
||||
self.fields["email"].widget.attrs["hide_character_count"] = "True"
|
||||
self.fields["phone"].error_messages["required"] = "Enter your phone number."
|
||||
|
||||
if self.instance and self.instance.phone:
|
||||
|
|
38
src/registrar/migrations/0143_create_groups_v18.py
Normal file
38
src/registrar/migrations/0143_create_groups_v18.py
Normal 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,
|
||||
),
|
||||
]
|
|
@ -0,0 +1,33 @@
|
|||
# Generated by Django 4.2.17 on 2025-03-17 20:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("registrar", "0143_create_groups_v18"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="domainrequest",
|
||||
name="eop_stakeholder_email",
|
||||
field=models.EmailField(blank=True, max_length=254, null=True, verbose_name="EOP Stakeholder Email"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="domainrequest",
|
||||
name="eop_stakeholder_first_name",
|
||||
field=models.CharField(blank=True, null=True, verbose_name="EOP Stakeholder First Name"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="domainrequest",
|
||||
name="eop_stakeholder_last_name",
|
||||
field=models.CharField(blank=True, null=True, verbose_name="EOP Stakeholder Last Name"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="domainrequest",
|
||||
name="working_with_eop",
|
||||
field=models.BooleanField(blank=True, null=True),
|
||||
),
|
||||
]
|
|
@ -662,7 +662,6 @@ class Domain(TimeStampedModel, DomainHelper):
|
|||
oldDnssecdata = self.dnssecdata
|
||||
addDnssecdata: dict = {}
|
||||
remDnssecdata: dict = {}
|
||||
|
||||
if _dnssecdata and _dnssecdata.dsData is not None:
|
||||
# initialize addDnssecdata and remDnssecdata for dsData
|
||||
addDnssecdata["dsData"] = _dnssecdata.dsData
|
||||
|
@ -713,15 +712,15 @@ class Domain(TimeStampedModel, DomainHelper):
|
|||
added_record = "dsData" in _addDnssecdata and _addDnssecdata["dsData"] is not None
|
||||
deleted_record = "dsData" in _remDnssecdata and _remDnssecdata["dsData"] is not None
|
||||
|
||||
if added_record:
|
||||
registry.send(addRequest, cleaned=True)
|
||||
dsdata_change_log = f"{user_email} added a DS data record"
|
||||
if deleted_record:
|
||||
registry.send(remRequest, cleaned=True)
|
||||
dsdata_change_log = f"{user_email} deleted a DS data record"
|
||||
if added_record:
|
||||
registry.send(addRequest, cleaned=True)
|
||||
if dsdata_change_log != "": # if they add and remove a record at same time
|
||||
dsdata_change_log = f"{user_email} added and deleted a DS data record"
|
||||
else:
|
||||
dsdata_change_log = f"{user_email} deleted a DS data record"
|
||||
dsdata_change_log = f"{user_email} added a DS data record"
|
||||
if dsdata_change_log != "":
|
||||
self.dsdata_last_change = dsdata_change_log
|
||||
self.save() # audit log will now record this as a change
|
||||
|
@ -896,6 +895,7 @@ class Domain(TimeStampedModel, DomainHelper):
|
|||
which inturn call this function)
|
||||
Will throw error if contact type is not the same as expectType
|
||||
Raises ValueError if expected type doesn't match the contact type"""
|
||||
|
||||
if expectedType != contact.contact_type:
|
||||
raise ValueError("Cannot set a contact with a different contact type, expected type was %s" % expectedType)
|
||||
|
||||
|
@ -908,7 +908,6 @@ class Domain(TimeStampedModel, DomainHelper):
|
|||
duplicate_contacts = PublicContact.objects.exclude(registry_id=contact.registry_id).filter(
|
||||
domain=self, contact_type=contact.contact_type
|
||||
)
|
||||
|
||||
# if no record exists with this contact type
|
||||
# make contact in registry, duplicate and errors handled there
|
||||
errorCode = self._make_contact_in_registry(contact)
|
||||
|
@ -987,6 +986,24 @@ class Domain(TimeStampedModel, DomainHelper):
|
|||
logger.info("making technical contact")
|
||||
self._set_singleton_contact(contact, expectedType=contact.ContactTypeChoices.TECHNICAL)
|
||||
|
||||
def print_contact_info_epp(self, contact: PublicContact):
|
||||
"""Prints registry data for this PublicContact for easier debugging"""
|
||||
results = self._request_contact_info(contact, get_result_as_dict=True)
|
||||
logger.info("---------------------")
|
||||
logger.info(f"EPP info for {contact.contact_type}:")
|
||||
logger.info("---------------------")
|
||||
for key, value in results.items():
|
||||
logger.info(f"{key}: {value}")
|
||||
|
||||
def print_all_domain_contact_info_epp(self):
|
||||
"""Prints registry data for this domains security, registrant, technical, and administrative contacts."""
|
||||
logger.info(f"Contact info for {self}:")
|
||||
logger.info("=====================")
|
||||
contacts = [self.security_contact, self.registrant_contact, self.technical_contact, self.administrative_contact]
|
||||
for contact in contacts:
|
||||
if contact:
|
||||
self.print_contact_info_epp(contact)
|
||||
|
||||
def is_active(self) -> bool:
|
||||
"""Currently just returns if the state is created,
|
||||
because then it should be live, theoretically.
|
||||
|
@ -1367,10 +1384,14 @@ class Domain(TimeStampedModel, DomainHelper):
|
|||
)
|
||||
return street_dict
|
||||
|
||||
def _request_contact_info(self, contact: PublicContact):
|
||||
def _request_contact_info(self, contact: PublicContact, get_result_as_dict=False):
|
||||
"""Grabs the resultant contact information in epp for this public contact
|
||||
by using the InfoContact command.
|
||||
Returns a commands.InfoContactResultData object, or a dict if get_result_as_dict is True."""
|
||||
try:
|
||||
req = commands.InfoContact(id=contact.registry_id)
|
||||
return registry.send(req, cleaned=True).res_data[0]
|
||||
result = registry.send(req, cleaned=True).res_data[0]
|
||||
return result if not get_result_as_dict else vars(result)
|
||||
except RegistryError as error:
|
||||
logger.error(
|
||||
"Registry threw error for contact id %s contact type is %s, error code is\n %s full error is %s", # noqa
|
||||
|
@ -1690,22 +1711,26 @@ class Domain(TimeStampedModel, DomainHelper):
|
|||
return help_text
|
||||
|
||||
def _disclose_fields(self, contact: PublicContact):
|
||||
"""creates a disclose object that can be added to a contact Create using
|
||||
"""creates a disclose object that can be added to a contact Create or Update using
|
||||
.disclose= <this function> on the command before sending.
|
||||
if item is security email then make sure email is visible"""
|
||||
is_security = contact.contact_type == contact.ContactTypeChoices.SECURITY
|
||||
# You can find each enum here:
|
||||
# https://github.com/cisagov/epplib/blob/master/epplib/models/common.py#L32
|
||||
DF = epp.DiscloseField
|
||||
fields = {DF.EMAIL}
|
||||
all_disclose_fields = {field for field in DF}
|
||||
disclose_args = {"fields": all_disclose_fields, "flag": False, "types": {DF.ADDR: "loc", DF.NAME: "loc"}}
|
||||
|
||||
hidden_security_emails = [DefaultEmail.PUBLIC_CONTACT_DEFAULT.value, DefaultEmail.LEGACY_DEFAULT.value]
|
||||
disclose = is_security and contact.email not in hidden_security_emails
|
||||
# Delete after testing on other devices
|
||||
logger.info("Updated domain contact %s to disclose: %s", contact.email, disclose)
|
||||
# Will only disclose DF.EMAIL if its not the default
|
||||
return epp.Disclose(
|
||||
flag=disclose,
|
||||
fields=fields,
|
||||
)
|
||||
fields_to_remove = {DF.NOTIFY_EMAIL, DF.VAT, DF.IDENT}
|
||||
if contact.contact_type == contact.ContactTypeChoices.SECURITY:
|
||||
if contact.email not in DefaultEmail.get_all_emails():
|
||||
fields_to_remove.add(DF.EMAIL)
|
||||
elif contact.contact_type == contact.ContactTypeChoices.ADMINISTRATIVE:
|
||||
fields_to_remove.update({DF.NAME, DF.EMAIL, DF.VOICE, DF.ADDR})
|
||||
|
||||
disclose_args["fields"].difference_update(fields_to_remove) # type: ignore
|
||||
|
||||
logger.debug("Updated domain contact %s to disclose: %s", contact.email, disclose_args.get("flag"))
|
||||
return epp.Disclose(**disclose_args) # type: ignore
|
||||
|
||||
def _make_epp_contact_postal_info(self, contact: PublicContact): # type: ignore
|
||||
return epp.PostalInfo( # type: ignore
|
||||
|
|
|
@ -449,7 +449,9 @@ class DomainInformation(TimeStampedModel):
|
|||
def converted_federal_type(self):
|
||||
if self.portfolio:
|
||||
return self.portfolio.federal_type
|
||||
return self.federal_type
|
||||
elif self.federal_agency:
|
||||
return self.federal_agency.federal_type
|
||||
return None
|
||||
|
||||
@property
|
||||
def converted_senior_official(self):
|
||||
|
|
|
@ -523,6 +523,29 @@ class DomainRequest(TimeStampedModel):
|
|||
choices=FEBPurposeChoices.choices,
|
||||
)
|
||||
|
||||
working_with_eop = models.BooleanField(
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
eop_stakeholder_first_name = models.CharField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="EOP Stakeholder First Name",
|
||||
)
|
||||
|
||||
eop_stakeholder_last_name = models.CharField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="EOP Stakeholder Last Name",
|
||||
)
|
||||
|
||||
eop_stakeholder_email = models.EmailField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="EOP Stakeholder Email",
|
||||
)
|
||||
|
||||
# This field is alternately used for generic domain purpose explanations
|
||||
# and for explanations of the specific purpose chosen with feb_purpose_choice
|
||||
# by a Federal Executive Branch agency.
|
||||
|
@ -1505,7 +1528,9 @@ class DomainRequest(TimeStampedModel):
|
|||
def converted_federal_type(self):
|
||||
if self.portfolio:
|
||||
return self.portfolio.federal_type
|
||||
return self.federal_type
|
||||
elif self.federal_agency:
|
||||
return self.federal_agency.federal_type
|
||||
return None
|
||||
|
||||
@property
|
||||
def converted_address_line1(self):
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import logging
|
||||
from datetime import datetime
|
||||
from random import choices
|
||||
from string import ascii_uppercase, ascii_lowercase, digits
|
||||
|
@ -9,6 +10,9 @@ from registrar.utility.enums import DefaultEmail
|
|||
from .utility.time_stamped_model import TimeStampedModel
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_id():
|
||||
"""Generate a 16 character registry ID with a low probability of collision."""
|
||||
day = datetime.today().strftime("%A")[:2]
|
||||
|
@ -92,15 +96,14 @@ class PublicContact(TimeStampedModel):
|
|||
return cls(
|
||||
contact_type=PublicContact.ContactTypeChoices.REGISTRANT,
|
||||
registry_id=get_id(),
|
||||
name="CSD/CB – Attn: Cameron Dixon",
|
||||
name="CSD/CB – Attn: .gov TLD",
|
||||
org="Cybersecurity and Infrastructure Security Agency",
|
||||
street1="CISA – NGR STOP 0645",
|
||||
street2="1110 N. Glebe Rd.",
|
||||
street1="1110 N. Glebe Rd",
|
||||
city="Arlington",
|
||||
sp="VA",
|
||||
pc="20598-0645",
|
||||
pc="22201",
|
||||
cc="US",
|
||||
email=DefaultEmail.PUBLIC_CONTACT_DEFAULT.value,
|
||||
email=DefaultEmail.PUBLIC_CONTACT_DEFAULT,
|
||||
voice="+1.8882820870",
|
||||
pw="thisisnotapassword",
|
||||
)
|
||||
|
@ -110,14 +113,14 @@ class PublicContact(TimeStampedModel):
|
|||
return cls(
|
||||
contact_type=PublicContact.ContactTypeChoices.ADMINISTRATIVE,
|
||||
registry_id=get_id(),
|
||||
name="Program Manager",
|
||||
name="CSD/CB – Attn: .gov TLD",
|
||||
org="Cybersecurity and Infrastructure Security Agency",
|
||||
street1="4200 Wilson Blvd.",
|
||||
street1="1110 N. Glebe Rd",
|
||||
city="Arlington",
|
||||
sp="VA",
|
||||
pc="22201",
|
||||
cc="US",
|
||||
email=DefaultEmail.PUBLIC_CONTACT_DEFAULT.value,
|
||||
email=DefaultEmail.PUBLIC_CONTACT_DEFAULT,
|
||||
voice="+1.8882820870",
|
||||
pw="thisisnotapassword",
|
||||
)
|
||||
|
@ -127,14 +130,14 @@ class PublicContact(TimeStampedModel):
|
|||
return cls(
|
||||
contact_type=PublicContact.ContactTypeChoices.TECHNICAL,
|
||||
registry_id=get_id(),
|
||||
name="Registry Customer Service",
|
||||
name="CSD/CB – Attn: .gov TLD",
|
||||
org="Cybersecurity and Infrastructure Security Agency",
|
||||
street1="4200 Wilson Blvd.",
|
||||
street1="1110 N. Glebe Rd",
|
||||
city="Arlington",
|
||||
sp="VA",
|
||||
pc="22201",
|
||||
cc="US",
|
||||
email=DefaultEmail.PUBLIC_CONTACT_DEFAULT.value,
|
||||
email=DefaultEmail.PUBLIC_CONTACT_DEFAULT,
|
||||
voice="+1.8882820870",
|
||||
pw="thisisnotapassword",
|
||||
)
|
||||
|
@ -144,14 +147,14 @@ class PublicContact(TimeStampedModel):
|
|||
return cls(
|
||||
contact_type=PublicContact.ContactTypeChoices.SECURITY,
|
||||
registry_id=get_id(),
|
||||
name="Registry Customer Service",
|
||||
name="CSD/CB – Attn: .gov TLD",
|
||||
org="Cybersecurity and Infrastructure Security Agency",
|
||||
street1="4200 Wilson Blvd.",
|
||||
street1="1110 N. Glebe Rd",
|
||||
city="Arlington",
|
||||
sp="VA",
|
||||
pc="22201",
|
||||
cc="US",
|
||||
email=DefaultEmail.PUBLIC_CONTACT_DEFAULT.value,
|
||||
email=DefaultEmail.PUBLIC_CONTACT_DEFAULT,
|
||||
voice="+1.8882820870",
|
||||
pw="thisisnotapassword",
|
||||
)
|
||||
|
|
|
@ -141,6 +141,99 @@ class UserGroup(Group):
|
|||
except Exception as e:
|
||||
logger.error(f"Error creating analyst permissions group: {e}")
|
||||
|
||||
def create_omb_analyst_group(apps, schema_editor):
|
||||
"""This method gets run from a data migration."""
|
||||
|
||||
# Hard to pass self to these methods as the calls from migrations
|
||||
# are only expecting apps and schema_editor, so we'll just define
|
||||
# apps, schema_editor in the local scope instead
|
||||
OMB_ANALYST_GROUP_PERMISSIONS = [
|
||||
{
|
||||
"app_label": "registrar",
|
||||
"model": "domainrequest",
|
||||
"permissions": ["change_domainrequest"],
|
||||
},
|
||||
{
|
||||
"app_label": "registrar",
|
||||
"model": "domain",
|
||||
"permissions": ["view_domain"],
|
||||
},
|
||||
{
|
||||
"app_label": "registrar",
|
||||
"model": "domaininvitation",
|
||||
"permissions": ["view_domaininvitation"],
|
||||
},
|
||||
{
|
||||
"app_label": "registrar",
|
||||
"model": "federalagency",
|
||||
"permissions": ["view_federalagency"],
|
||||
},
|
||||
{
|
||||
"app_label": "registrar",
|
||||
"model": "portfolio",
|
||||
"permissions": ["view_portfolio"],
|
||||
},
|
||||
{
|
||||
"app_label": "registrar",
|
||||
"model": "suborganization",
|
||||
"permissions": ["view_suborganization"],
|
||||
},
|
||||
{
|
||||
"app_label": "registrar",
|
||||
"model": "seniorofficial",
|
||||
"permissions": ["view_seniorofficial"],
|
||||
},
|
||||
]
|
||||
|
||||
# Avoid error: You can't execute queries until the end
|
||||
# of the 'atomic' block.
|
||||
# From django docs:
|
||||
# https://docs.djangoproject.com/en/4.2/topics/migrations/#data-migrations
|
||||
# We can’t import the Person model directly as it may be a newer
|
||||
# version than this migration expects. We use the historical version.
|
||||
ContentType = apps.get_model("contenttypes", "ContentType")
|
||||
Permission = apps.get_model("auth", "Permission")
|
||||
UserGroup = apps.get_model("registrar", "UserGroup")
|
||||
|
||||
logger.info("Going to create the OMB Analyst Group")
|
||||
try:
|
||||
omb_analysts_group, _ = UserGroup.objects.get_or_create(
|
||||
name="omb_analysts_group",
|
||||
)
|
||||
|
||||
omb_analysts_group.permissions.clear()
|
||||
|
||||
for permission in OMB_ANALYST_GROUP_PERMISSIONS:
|
||||
app_label = permission["app_label"]
|
||||
model_name = permission["model"]
|
||||
permissions = permission["permissions"]
|
||||
|
||||
# Retrieve the content type for the app and model
|
||||
content_type = ContentType.objects.get(app_label=app_label, model=model_name)
|
||||
|
||||
# Retrieve the permissions based on their codenames
|
||||
permissions = Permission.objects.filter(content_type=content_type, codename__in=permissions)
|
||||
|
||||
# Assign the permissions to the group
|
||||
omb_analysts_group.permissions.add(*permissions)
|
||||
|
||||
# Convert the permissions QuerySet to a list of codenames
|
||||
permission_list = list(permissions.values_list("codename", flat=True))
|
||||
|
||||
logger.debug(
|
||||
app_label
|
||||
+ " | "
|
||||
+ model_name
|
||||
+ " | "
|
||||
+ ", ".join(permission_list)
|
||||
+ " added to group "
|
||||
+ omb_analysts_group.name
|
||||
)
|
||||
|
||||
logger.debug("OMB Analyst permissions added to group " + omb_analysts_group.name)
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating analyst permissions group: {e}")
|
||||
|
||||
def create_full_access_group(apps, schema_editor):
|
||||
"""This method gets run from a data migration."""
|
||||
|
||||
|
|
|
@ -257,9 +257,6 @@ def validate_user_portfolio_permission(user_portfolio_permission):
|
|||
Raises:
|
||||
ValidationError: If any of the validation rules are violated.
|
||||
"""
|
||||
PortfolioInvitation = apps.get_model("registrar.PortfolioInvitation")
|
||||
UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
|
||||
|
||||
has_portfolio = bool(user_portfolio_permission.portfolio_id)
|
||||
portfolio_permissions = set(user_portfolio_permission._get_portfolio_permissions())
|
||||
|
||||
|
@ -286,8 +283,8 @@ def validate_user_portfolio_permission(user_portfolio_permission):
|
|||
|
||||
# == Validate the multiple_porfolios flag. == #
|
||||
if not flag_is_active_for_user(user_portfolio_permission.user, "multiple_portfolios"):
|
||||
existing_permissions = UserPortfolioPermission.objects.exclude(id=user_portfolio_permission.id).filter(
|
||||
user=user_portfolio_permission.user
|
||||
existing_permissions, existing_invitations = get_user_portfolio_permission_associations(
|
||||
user_portfolio_permission
|
||||
)
|
||||
if existing_permissions.exists():
|
||||
raise ValidationError(
|
||||
|
@ -296,10 +293,6 @@ def validate_user_portfolio_permission(user_portfolio_permission):
|
|||
code="has_existing_permissions",
|
||||
)
|
||||
|
||||
existing_invitations = PortfolioInvitation.objects.filter(email=user_portfolio_permission.user.email).exclude(
|
||||
Q(portfolio=user_portfolio_permission.portfolio)
|
||||
| Q(status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED)
|
||||
)
|
||||
if existing_invitations.exists():
|
||||
raise ValidationError(
|
||||
"This user is already assigned to a portfolio invitation. "
|
||||
|
@ -308,6 +301,32 @@ def validate_user_portfolio_permission(user_portfolio_permission):
|
|||
)
|
||||
|
||||
|
||||
def get_user_portfolio_permission_associations(user_portfolio_permission):
|
||||
"""
|
||||
Retrieves the associations for a user portfolio invitation.
|
||||
|
||||
Returns:
|
||||
A tuple:
|
||||
(existing_permissions, existing_invitations)
|
||||
where:
|
||||
- existing_permissions: UserPortfolioPermission objects excluding the current permission.
|
||||
- existing_invitations: PortfolioInvitation objects for the user email excluding
|
||||
the current invitation and those with status RETRIEVED.
|
||||
"""
|
||||
PortfolioInvitation = apps.get_model("registrar.PortfolioInvitation")
|
||||
UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
|
||||
existing_permissions = UserPortfolioPermission.objects.exclude(id=user_portfolio_permission.id).filter(
|
||||
user=user_portfolio_permission.user
|
||||
)
|
||||
existing_invitations = PortfolioInvitation.objects.filter(
|
||||
email__iexact=user_portfolio_permission.user.email
|
||||
).exclude(
|
||||
Q(portfolio=user_portfolio_permission.portfolio)
|
||||
| Q(status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED)
|
||||
)
|
||||
return (existing_permissions, existing_invitations)
|
||||
|
||||
|
||||
def validate_portfolio_invitation(portfolio_invitation):
|
||||
"""
|
||||
Validates a PortfolioInvitation instance. Located in portfolio_helper to avoid circular imports
|
||||
|
@ -324,7 +343,6 @@ def validate_portfolio_invitation(portfolio_invitation):
|
|||
Raises:
|
||||
ValidationError: If any of the validation rules are violated.
|
||||
"""
|
||||
PortfolioInvitation = apps.get_model("registrar.PortfolioInvitation")
|
||||
UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
|
||||
User = get_user_model()
|
||||
|
||||
|
@ -351,17 +369,12 @@ def validate_portfolio_invitation(portfolio_invitation):
|
|||
)
|
||||
|
||||
# == Validate the multiple_porfolios flag. == #
|
||||
user = User.objects.filter(email=portfolio_invitation.email).first()
|
||||
user = User.objects.filter(email__iexact=portfolio_invitation.email).first()
|
||||
|
||||
# If user returns None, then we check for global assignment of multiple_portfolios.
|
||||
# Otherwise we just check on the user.
|
||||
if not flag_is_active_for_user(user, "multiple_portfolios"):
|
||||
existing_permissions = UserPortfolioPermission.objects.filter(user=user)
|
||||
|
||||
existing_invitations = PortfolioInvitation.objects.filter(email=portfolio_invitation.email).exclude(
|
||||
Q(id=portfolio_invitation.id) | Q(status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED)
|
||||
)
|
||||
|
||||
existing_permissions, existing_invitations = get_portfolio_invitation_associations(portfolio_invitation)
|
||||
if existing_permissions.exists():
|
||||
raise ValidationError(
|
||||
"This user is already assigned to a portfolio. "
|
||||
|
@ -377,6 +390,27 @@ def validate_portfolio_invitation(portfolio_invitation):
|
|||
)
|
||||
|
||||
|
||||
def get_portfolio_invitation_associations(portfolio_invitation):
|
||||
"""
|
||||
Retrieves the associations for a portfolio invitation.
|
||||
|
||||
Returns:
|
||||
A tuple:
|
||||
(existing_permissions, existing_invitations)
|
||||
where:
|
||||
- existing_permissions: UserPortfolioPermission objects matching the email.
|
||||
- existing_invitations: PortfolioInvitation objects for the email excluding
|
||||
the current invitation and those with status RETRIEVED.
|
||||
"""
|
||||
PortfolioInvitation = apps.get_model("registrar.PortfolioInvitation")
|
||||
UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
|
||||
existing_permissions = UserPortfolioPermission.objects.filter(user__email__iexact=portfolio_invitation.email)
|
||||
existing_invitations = PortfolioInvitation.objects.filter(email__iexact=portfolio_invitation.email).exclude(
|
||||
Q(id=portfolio_invitation.id) | Q(status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED)
|
||||
)
|
||||
return (existing_permissions, existing_invitations)
|
||||
|
||||
|
||||
def cleanup_after_portfolio_member_deletion(portfolio, email, user=None):
|
||||
"""
|
||||
Cleans up after removing a portfolio member or a portfolio invitation.
|
||||
|
|
|
@ -222,3 +222,31 @@ class RestrictAccessMiddleware:
|
|||
raise PermissionDenied # Deny access if the view lacks explicit permission handling
|
||||
|
||||
return self.get_response(request)
|
||||
|
||||
|
||||
class RequestLoggingMiddleware:
|
||||
"""
|
||||
Middleware to log user email, remote address, and request path.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
response = self.get_response(request)
|
||||
|
||||
# Only log in production (stable)
|
||||
if getattr(settings, "IS_PRODUCTION", False):
|
||||
# Get user email (if authenticated), else "Anonymous"
|
||||
user_email = request.user.email if request.user.is_authenticated else "Anonymous"
|
||||
|
||||
# Get remote IP address
|
||||
remote_ip = request.META.get("REMOTE_ADDR", "Unknown IP")
|
||||
|
||||
# Get request path
|
||||
request_path = request.path
|
||||
|
||||
# Log user information
|
||||
logger.info(f"Router log | User: {user_email} | IP: {remote_ip} | Path: {request_path}")
|
||||
|
||||
return response
|
||||
|
|
|
@ -63,6 +63,7 @@
|
|||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if perms.registrar.analyst_access_permission or perms.full_access_permission %}
|
||||
<div class="module">
|
||||
<table class="width-full">
|
||||
<caption class="text-bold">Analytics</caption>
|
||||
|
@ -78,6 +79,7 @@
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p>{% translate 'You don’t have permission to view or edit anything.' %}</p>
|
||||
{% endif %}
|
||||
|
|
|
@ -11,13 +11,15 @@
|
|||
{% block field_sets %}
|
||||
<div class="display-flex flex-row flex-justify submit-row">
|
||||
<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">
|
||||
{# Dja has margin styles defined on inputs as is. Lets work with it, rather than fight it. #}
|
||||
<span class="mini-spacer"></span>
|
||||
<input type="submit" value="Get registry status" name="_get_status">
|
||||
{% endif %}
|
||||
</div>
|
||||
<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>
|
||||
Extend expiration date
|
||||
</a>
|
||||
|
@ -31,9 +33,11 @@
|
|||
<input type="submit" value="Remove hold" name="_remove_client_hold" class="custom-link-button">
|
||||
{% endif %}
|
||||
{% 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>
|
||||
{% 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>
|
||||
Remove from registry
|
||||
</a>
|
||||
|
|
|
@ -6,7 +6,11 @@
|
|||
|
||||
{% if show_formatted_name %}
|
||||
{% if user.get_formatted_name %}
|
||||
{% 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 %}
|
||||
None
|
||||
{% endif %}
|
||||
|
|
|
@ -69,7 +69,11 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
{% elif field.field.name == "portfolio_senior_official" %}
|
||||
<div class="readonly">
|
||||
{% if original_object.portfolio.senior_official %}
|
||||
{% 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 %}
|
||||
No senior official found.<br>
|
||||
{% endif %}
|
||||
|
@ -78,7 +82,11 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
{% if all_contacts.count > 2 %}
|
||||
<div class="readonly">
|
||||
{% for contact in all_contacts %}
|
||||
{% 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 %}
|
||||
</div>
|
||||
{% else %}
|
||||
|
@ -153,6 +161,10 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
<p>No additional members found.</p>
|
||||
{% endif %}
|
||||
</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 %}
|
||||
<div class="readonly">{{ field.contents }}</div>
|
||||
{% endif %}
|
||||
|
|
|
@ -16,7 +16,11 @@
|
|||
{% for admin in admins %}
|
||||
{% url 'admin:registrar_userportfoliopermission_change' admin.pk as url %}
|
||||
<tr>
|
||||
{% 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>
|
||||
{% if admin.user.email %}
|
||||
|
|
|
@ -30,6 +30,9 @@
|
|||
<a href={{ url }}>No senior official found. Create one now.</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% elif field.field.name == "creator" and adminform.form.show_contact_as_plain_text %}
|
||||
<div class="readonly">{{ field.contents|striptags }}</div>
|
||||
{% else %}
|
||||
<div class="readonly">{{ field.contents }}</div>
|
||||
{% endif %}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<textarea
|
||||
name="{{ widget.name }}"
|
||||
class="usa-textarea usa-character-count__field {{ widget.attrs.class }}"
|
||||
class="usa-textarea{% if classes %} {{ classes }}{% endif %}{% if not widget.attrs.hide_character_count %} usa-character-count__field{% endif %} {{ widget.attrs.class }}"
|
||||
{% include "django/forms/widgets/attrs.html" %}
|
||||
>{% if widget.value %}{{ widget.value }}{% endif %}</textarea>
|
|
@ -9,7 +9,7 @@
|
|||
<div class="grid-container grid-container--widescreen">
|
||||
|
||||
<div class="grid-row grid-gap {% if not is_widescreen_centered %}max-width--grid-container{% endif %}">
|
||||
<div class="tablet:grid-col-3 ">
|
||||
<div class="tablet:grid-col-3 grid-col--sidenav">
|
||||
<p class="font-body-md margin-top-0 margin-bottom-2
|
||||
text-primary-darker text-semibold string-wrap"
|
||||
>
|
||||
|
@ -21,7 +21,7 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="tablet:grid-col-9">
|
||||
<div class="tablet:grid-col">
|
||||
<main id="main-content" class="grid-container">
|
||||
{% if not domain.domain_info %}
|
||||
<div class="usa-alert usa-alert--error margin-bottom-2">
|
||||
|
|
|
@ -34,19 +34,32 @@
|
|||
{% endif %}
|
||||
{% endblock breadcrumb %}
|
||||
|
||||
{% if domain.dnssecdata is None %}
|
||||
<div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-3">
|
||||
<div class="usa-alert__body">
|
||||
You have no DS data added. Enable DNSSEC by adding DS data.
|
||||
<div class="grid-row grid-gap">
|
||||
<div class="tablet:grid-col-6">
|
||||
<h1 class="tablet:margin-bottom-1" id="domain-dsdata">DS data</h1>
|
||||
</div>
|
||||
|
||||
<div class="tablet:grid-col-6 text-right--tablet">
|
||||
<button type="button" class="usa-button margin-bottom-1 tablet:float-right" id="dsdata-add-button">
|
||||
Add DS data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h1 id="domain-dsdata">DS data</h1>
|
||||
<p>In order to enable DNSSEC, you must first configure it with your DNS provider.</p>
|
||||
|
||||
<p>In order to enable DNSSEC, you must first configure it with your DNS hosting service.</p>
|
||||
<p>Click “Add DS data” and enter the values given by your DNS provider for DS (Delegation Signer) data. You can add a maximum of 8 DS records.</p>
|
||||
|
||||
<p>Enter the values given by your DNS provider for DS data.</p>
|
||||
{% comment %}
|
||||
This template supports the rendering of three different DS data forms, conditionally displayed:
|
||||
1 - Add DS Data form (rendered when there are no existing DS data records defined for the domain)
|
||||
2 - DS Data table (rendered when the domain has existing DS data, which can be viewed and edited)
|
||||
3 - Add DS Data form (rendered above the DS Data table to add a single additional DS Data record)
|
||||
{% endcomment %}
|
||||
|
||||
{% if formset.initial and formset.forms.0.initial %}
|
||||
|
||||
{% comment %}This section renders both the DS Data table and the Add DS Data form {% endcomment %}
|
||||
|
||||
{% include "includes/required_fields.html" %}
|
||||
|
||||
|
@ -55,25 +68,31 @@
|
|||
{{ formset.management_form }}
|
||||
|
||||
{% for form in formset %}
|
||||
<fieldset class="repeatable-form">
|
||||
{% if forloop.last and not form.initial %}
|
||||
|
||||
<legend class="sr-only">DS data record {{forloop.counter}}</legend>
|
||||
|
||||
<h2 class="margin-top-0">DS data record {{forloop.counter}}</h2>
|
||||
{% comment %}
|
||||
This section renders the Add DS data form.
|
||||
This section does not render if the last form has initial data (this occurs if 8 DS data records already exist)
|
||||
{% endcomment %}
|
||||
|
||||
<section class="add-dsdata-form display-none section-outlined section-outlined--extra-padding">
|
||||
<h2 class="margin-top-0">Add DS record</h2>
|
||||
<div class="repeatable-form">
|
||||
<div class="grid-row grid-gap-2 flex-end">
|
||||
<div class="tablet:grid-col-4">
|
||||
{% with attr_required=True add_group_class="usa-form-group--unstyled-error" %}
|
||||
{% with sublabel_text="Numbers (0-9) only." %}
|
||||
{% with attr_required=True add_initial_value_attr=True add_group_class="usa-form-group--unstyled-error" %}
|
||||
{% input_with_errors form.key_tag %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<div class="tablet:grid-col-4">
|
||||
{% with attr_required=True add_group_class="usa-form-group--unstyled-error" %}
|
||||
{% with attr_required=True add_initial_value_attr=True add_group_class="usa-form-group--unstyled-error" %}
|
||||
{% input_with_errors form.algorithm %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<div class="tablet:grid-col-4">
|
||||
{% with attr_required=True add_group_class="usa-form-group--unstyled-error" %}
|
||||
{% with attr_required=True add_initial_value_attr=True add_group_class="usa-form-group--unstyled-error" %}
|
||||
{% input_with_errors form.digest_type %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
@ -81,75 +100,310 @@
|
|||
|
||||
<div class="grid-row">
|
||||
<div class="grid-col">
|
||||
{% with attr_required=True add_group_class="usa-form-group--unstyled-error" %}
|
||||
{% with sublabel_text="Numbers (0-9) and letters (a-f) only. SHA-1: 40 chars, SHA-256: 64 chars." %}
|
||||
{% with attr_required=True add_initial_value_attr=True add_group_class="usa-form-group--unstyled-error" %}
|
||||
{% input_with_errors form.digest %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-row margin-top-1">
|
||||
<div class="grid-col">
|
||||
<button type="button" id="button label" class="usa-button usa-button--unstyled usa-button--with-icon float-right-tablet delete-record text-secondary line-height-sans-5">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
|
||||
</svg>Delete
|
||||
<span class="sr-only">DS data record {{forloop.counter}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</fieldset>
|
||||
{% endfor %}
|
||||
|
||||
<button type="button" class="usa-button usa-button--unstyled usa-button--with-icon margin-bottom-2" id="add-form">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
|
||||
</svg>Add new record
|
||||
<div class="margin-top-2">
|
||||
<button
|
||||
type="button"
|
||||
class="usa-button usa-button--outline dsdata-cancel-add-form"
|
||||
name="btn-cancel-click"
|
||||
aria-label="Reset the data in the DS records to the registry state (undo changes)"
|
||||
>Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="save-ds-data"
|
||||
type="submit"
|
||||
class="usa-button"
|
||||
>Save
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<div class="usa-table-container--scrollable usa-table-container--override-overflow usa-table-container--override-scrollable padding-top-5 margin-top-0" tabindex="0">
|
||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked dotgov-table--cell-padding-2-2-2-0" id="dsdata-table">
|
||||
<caption class="sr-only">Your DS data records</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" role="columnheader" class="text-bottom">Key tag</th>
|
||||
<th scope="col" role="columnheader" class="text-bottom">Algorithm</th>
|
||||
<th scope="col" role="columnheader" class="text-bottom">Digest type</th>
|
||||
<th scope="col" role="columnheader" class="text-bottom">Digest</th>
|
||||
<th scope="col" role="columnheader" class="text-bottom width-0 padding-right-0">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for form in formset %}
|
||||
{% if not forloop.last or form.initial %}
|
||||
|
||||
{% comment %}
|
||||
This section renders table rows for each existing DS data records. Two rows are rendered, a readonly row
|
||||
and an edit row. Only one of which is displayed at a time.
|
||||
{% endcomment %}
|
||||
|
||||
<!-- Readonly row -->
|
||||
<tr class="view-only-row">
|
||||
<td data-label="Key tag">{{ form.key_tag.value }}</td>
|
||||
<td data-label="Algorithm">
|
||||
<span class="ellipsis ellipsis--15">
|
||||
{% for value, label in form.algorithm.field.choices %}
|
||||
{% if value|stringformat:"s" == form.algorithm.value|stringformat:"s" %}
|
||||
{{ label }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</span>
|
||||
</td>
|
||||
<td data-label="Digest type">
|
||||
{% for value, label in form.digest_type.field.choices %}
|
||||
{% if value|stringformat:"s" == form.digest_type.value|stringformat:"s" %}
|
||||
{{ label }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td data-label="Digest">
|
||||
<span class="ellipsis ellipsis--23">{{ form.digest.value }}</span>
|
||||
</td>
|
||||
<td class="padding-right-0" data-label="Action">
|
||||
<div class="tablet:display-flex tablet:flex-row">
|
||||
<button type="button" class='usa-button usa-button--unstyled margin-right-2 margin-top-0 dsdata-edit'>
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="/public/img/sprite.svg#edit"></use>
|
||||
</svg>
|
||||
Edit <span class="usa-sr-only">DS record {{forloop.counter}}</span>
|
||||
</button>
|
||||
|
||||
<a
|
||||
role="button"
|
||||
id="button-trigger-delete-dsdata-{{ forloop.counter }}"
|
||||
class="usa-button usa-button--unstyled text-underline late-loading-modal-trigger margin-top-2 line-height-sans-5 text-secondary visible-mobile-flex dsdata-delete-kebab"
|
||||
>
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="/public/img/sprite.svg#delete"></use>
|
||||
</svg>
|
||||
Delete
|
||||
</a>
|
||||
|
||||
<div class="usa-accordion usa-accordion--more-actions margin-right-2 hidden-mobile-flex">
|
||||
<div class="usa-accordion__heading">
|
||||
<button
|
||||
type="button"
|
||||
class="usa-button usa-button--unstyled usa-button--with-icon usa-accordion__button usa-button--more-actions margin-top-2px"
|
||||
aria-expanded="false"
|
||||
aria-controls="more-actions-dsdata-{{ forloop.counter }}"
|
||||
aria-label="More Actions for DS record {{ forloop.counter }}"
|
||||
>
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="/public/img/sprite.svg#more_vert"></use>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="more-actions-dsdata-{{ forloop.counter }}" class="usa-accordion__content usa-prose shadow-1 left-auto right-neg-1" hidden>
|
||||
<h2>More options</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="usa-button usa-button--unstyled text-underline late-loading-modal-trigger line-height-sans-5 text-secondary dsdata-delete-kebab margin-top-2"
|
||||
name="btn-delete-kebab-click"
|
||||
aria-label="Delete DS record {{ forloop.counter }} from the registry"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Edit row -->
|
||||
<tr class="edit-row display-none">
|
||||
<td class="text-bottom">
|
||||
{% with sublabel_text="(0-65535)." %}
|
||||
{% with attr_required=True add_initial_value_attr=True add_group_class="usa-form-group--unstyled-error margin-top-0" use_small_sublabel_text=True inline_error_class="font-body-xs" %}
|
||||
{% input_with_errors form.key_tag %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
</td>
|
||||
<td class="text-bottom">
|
||||
{% with attr_required=True add_initial_value_attr=True add_group_class="usa-form-group--unstyled-error margin-top-0" use_small_sublabel_text=True inline_error_class="font-body-xs" %}
|
||||
{% input_with_errors form.algorithm %}
|
||||
{% endwith %}
|
||||
</td>
|
||||
<td class="text-bottom">
|
||||
{% with attr_required=True add_initial_value_attr=True add_group_class="usa-form-group--unstyled-error margin-top-0" use_small_sublabel_text=True inline_error_class="font-body-xs" %}
|
||||
{% input_with_errors form.digest_type %}
|
||||
{% endwith %}
|
||||
</td>
|
||||
<td class="text-bottom">
|
||||
{% with sublabel_text="Numbers (0-9) and letters (a-f) only. SHA-1: 40 chars, SHA-256: 64 chars." %}
|
||||
{% with attr_required=True add_initial_value_attr=True add_group_class="usa-form-group--unstyled-error margin-top-0" use_small_sublabel_text=True inline_error_class="font-body-xs" %}
|
||||
{% input_with_errors form.digest %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
</td>
|
||||
<td class="padding-right-0 text-bottom" data-label="Action">
|
||||
<button class="usa-button usa-button--unstyled display-block margin-top-1" type="submit">Save</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="usa-button usa-button--outline"
|
||||
type="button"
|
||||
class="usa-button usa-button--unstyled display-block dsdata-cancel"
|
||||
name="btn-cancel-click"
|
||||
aria-label="Reset the data in the DS record form to the registry state (undo changes)"
|
||||
>Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="usa-button usa-button--unstyled display-block text-secondary dsdata-delete"
|
||||
name="btn-delete-click"
|
||||
aria-label="Delete the DS record from the registry"
|
||||
>Delete
|
||||
</button>
|
||||
<div class="display-none">{{ form.DELETE }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% else %}
|
||||
|
||||
{% comment %}
|
||||
This section renders Add DS Data form which renders when there are no existing
|
||||
DS records defined on the domain.
|
||||
{% endcomment %}
|
||||
|
||||
<div class="add-dsdata-form display-none">
|
||||
{% include "includes/required_fields.html" %}
|
||||
<section class="section-outlined section-outlined--extra-padding">
|
||||
<form class="usa-form usa-form--extra-large" method="post" novalidate>
|
||||
<h2>Add DS record</h2>
|
||||
{% csrf_token %}
|
||||
{{ formset.management_form }}
|
||||
{% for form in formset %}
|
||||
<div class="repeatable-form">
|
||||
<div class="grid-row grid-gap-2 flex-end">
|
||||
<div class="tablet:grid-col-4">
|
||||
{% with sublabel_text="Numbers (0-9) only." %}
|
||||
{% with attr_required=True add_initial_value_attr=True add_group_class="usa-form-group--unstyled-error" %}
|
||||
{% input_with_errors form.key_tag %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<div class="tablet:grid-col-4">
|
||||
{% with attr_required=True add_initial_value_attr=True add_group_class="usa-form-group--unstyled-error" %}
|
||||
{% input_with_errors form.algorithm %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<div class="tablet:grid-col-4">
|
||||
{% with attr_required=True add_initial_value_attr=True add_group_class="usa-form-group--unstyled-error" %}
|
||||
{% input_with_errors form.digest_type %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-row">
|
||||
<div class="grid-col">
|
||||
{% with sublabel_text="Numbers (0-9) and letters (a-f) only. SHA-1: 40 chars, SHA-256: 64 chars." %}
|
||||
{% with hide_character_count=True %}
|
||||
{% with attr_required=True add_initial_value_attr=True add_group_class="usa-form-group--unstyled-error" %}
|
||||
{% input_with_errors form.digest %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="margin-top-2">
|
||||
<button
|
||||
type="button"
|
||||
class="usa-button usa-button--outline dsdata-cancel-add-form"
|
||||
name="btn-cancel-click"
|
||||
aria-label="Reset the data in the DS records to the registry state (undo changes)"
|
||||
>Cancel
|
||||
</button>
|
||||
<button
|
||||
id="save-ds-data"
|
||||
type="submit"
|
||||
class="usa-button"
|
||||
>Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if trigger_modal %}
|
||||
<a
|
||||
id="ds-toggle-dnssec-alert"
|
||||
href="#toggle-dnssec-alert"
|
||||
id="unsaved_changes_trigger"
|
||||
href="#unsaved-changes-modal"
|
||||
class="usa-button usa-button--outline margin-top-1 display-none"
|
||||
aria-controls="toggle-dnssec-alert"
|
||||
aria-controls="unsaved-changes-modal"
|
||||
data-open-modal
|
||||
>Trigger unsaved changes modal</a>
|
||||
<div
|
||||
class="usa-modal"
|
||||
id="unsaved-changes-modal"
|
||||
aria-labelledby="Are you sure you want to continue?"
|
||||
aria-describedby="You have unsaved changes that will be lost."
|
||||
>
|
||||
{% include 'includes/modal.html' with modal_heading="Are you sure you want to continue?" modal_description="You have unsaved changes that will be lost." modal_button_id="unsaved-changes-click-button" modal_button_text="Continue without saving" cancel_button_text="Go back" %}
|
||||
</div>
|
||||
|
||||
<a
|
||||
id="cancel_changes_trigger"
|
||||
href="#cancel-changes-modal"
|
||||
class="usa-button usa-button--outline margin-top-1 display-none"
|
||||
aria-controls="cancel-changes-modal"
|
||||
data-open-modal
|
||||
>Trigger cancel changes modal</a>
|
||||
<div
|
||||
class="usa-modal"
|
||||
id="cancel-changes-modal"
|
||||
aria-labelledby="Are you sure you want to cancel your changes?"
|
||||
aria-describedby="This action cannot be undone."
|
||||
>
|
||||
{% include 'includes/modal.html' with modal_heading="Are you sure you want to cancel your changes?" modal_description="This action cannot be undone." modal_button_id="cancel-changes-click-button" modal_button_text="Yes, cancel" cancel_button_text="Go back" %}
|
||||
</div>
|
||||
|
||||
<a
|
||||
id="delete_trigger"
|
||||
href="#delete-modal"
|
||||
class="usa-button usa-button--outline margin-top-1 display-none"
|
||||
aria-controls="delete-modal"
|
||||
data-open-modal
|
||||
>Trigger delete modal</a>
|
||||
<div
|
||||
class="usa-modal"
|
||||
id="delete-modal"
|
||||
aria-labelledby="Are you sure you want to delete this DS data record?"
|
||||
aria-describedby="This action cannot be undone."
|
||||
>
|
||||
{% include 'includes/modal.html' with modal_heading="Are you sure you want to delete this DS data record?" modal_description="This action cannot be undone." modal_button_id="delete-click-button" modal_button_text="Yes, delete" modal_button_class="usa-button--secondary" %}
|
||||
</div>
|
||||
|
||||
<a
|
||||
id="disable_dnssec_trigger"
|
||||
href="#disable-dnssec-modal"
|
||||
class="usa-button usa-button--outline margin-top-1 display-none"
|
||||
aria-controls="disable-dnssec-modal"
|
||||
data-open-modal
|
||||
>Trigger Disable DNSSEC Modal</a
|
||||
>
|
||||
{% endif %}
|
||||
{# Use data-force-action to take esc out of the equation and pass cancel_button_resets_ds_form to effectuate a reset in the view #}
|
||||
<div
|
||||
class="usa-modal"
|
||||
id="toggle-dnssec-alert"
|
||||
id="disable-dnssec-modal"
|
||||
aria-labelledby="Are you sure you want to continue?"
|
||||
aria-describedby="Your DNSSEC records will be deleted from the registry."
|
||||
data-force-action
|
||||
>
|
||||
{% include 'includes/modal.html' with cancel_button_resets_ds_form=True modal_heading="Warning: You are about to remove all DS records on your domain." modal_description="To fully disable DNSSEC: In addition to removing your DS records here, you’ll need to delete the DS records at your DNS host. To avoid causing your domain to appear offline, you should wait to delete your DS records at your DNS host until the Time to Live (TTL) expires. This is often less than 24 hours, but confirm with your provider." modal_button_id="disable-override-click-button" modal_button_text="Remove all DS data" modal_button_class="usa-button--secondary" %}
|
||||
{% include 'includes/modal.html' with modal_heading="Warning: You are about to remove all DS records on your domain." modal_description="To fully disable DNSSEC: In addition to removing your DS records here, you’ll need to delete the DS records at your DNS host. To avoid causing your domain to appear offline, you should wait to delete your DS records at your DNS host until the Time to Live (TTL) expires. This is often less than 24 hours, but confirm with your provider." modal_button_id="disable-dnssec-click-button" modal_button_text="Remove all DS data" modal_button_class="usa-button--secondary" %}
|
||||
</div>
|
||||
<form method="post" id="disable-override-click-form">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="disable-override-click" value="1">
|
||||
</form>
|
||||
<form method="post" id="btn-cancel-click-form">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="btn-cancel-click" value="1">
|
||||
</form>
|
||||
|
||||
{% endblock %} {# domain_content #}
|
||||
|
|
|
@ -82,7 +82,7 @@
|
|||
This section does not render if the last form has initial data (this occurs if 13 nameservers already exist)
|
||||
{% endcomment %}
|
||||
|
||||
<section class="add-nameservers-form display-none section-outlined">
|
||||
<section class="add-nameservers-form display-none section-outlined section-outlined--extra-padding">
|
||||
{{ form.domain }}
|
||||
<h2>Add a name server</h2>
|
||||
<div class="repeatable-form">
|
||||
|
@ -121,7 +121,7 @@
|
|||
|
||||
|
||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked" id="nameserver-table">
|
||||
<caption class="sr-only">Your registered domains</caption>
|
||||
<caption class="sr-only">Your Name server records</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" role="columnheader">Name servers</th>
|
||||
|
@ -141,8 +141,8 @@
|
|||
{{ form.domain }}
|
||||
<!-- Readonly row -->
|
||||
<tr>
|
||||
<td colspan="2" aria-colspan="2">{{ form.server.value }} {% if form.ip.value %}({{ form.ip.value }}){% endif %}</td>
|
||||
<td class="padding-right-0">
|
||||
<td colspan="2" aria-colspan="2" data-label="Name server (IP address)">{{ form.server.value }} {% if form.ip.value %}({{ form.ip.value }}){% endif %}</td>
|
||||
<td class="padding-right-0" data-label="Action">
|
||||
<div class="tablet:display-flex tablet:flex-row">
|
||||
<button type="button" class='usa-button usa-button--unstyled margin-right-2 margin-top-0 nameserver-edit'>
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
|
@ -154,7 +154,7 @@
|
|||
<a
|
||||
role="button"
|
||||
id="button-trigger-delete-{{ form.server.value }}"
|
||||
class="usa-button usa-button--unstyled text-no-underline late-loading-modal-trigger margin-top-2 line-height-sans-5 text-secondary visible-mobile-flex nameserver-delete-kebab"
|
||||
class="usa-button usa-button--unstyled text-underline late-loading-modal-trigger margin-top-2 line-height-sans-5 text-secondary visible-mobile-flex nameserver-delete-kebab"
|
||||
>
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="/public/img/sprite.svg#delete"></use>
|
||||
|
@ -166,12 +166,12 @@
|
|||
<div class="usa-accordion__heading">
|
||||
<button
|
||||
type="button"
|
||||
class="usa-button usa-button--unstyled usa-button--with-icon usa-accordion__button usa-button--more-actions margin-top-0"
|
||||
class="usa-button usa-button--unstyled usa-button--with-icon usa-accordion__button usa-button--more-actions margin-top-2px"
|
||||
aria-expanded="false"
|
||||
aria-controls="more-actions-{{ form.server.value }}"
|
||||
aria-label="More Actions for ({{ form.server.value }})"
|
||||
>
|
||||
<svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="/public/img/sprite.svg#more_vert"></use>
|
||||
</svg>
|
||||
</button>
|
||||
|
@ -180,7 +180,7 @@
|
|||
<h2>More options</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="usa-button usa-button--unstyled text-no-underline late-loading-modal-trigger margin-top-2 line-height-sans-5 text-secondary nameserver-delete-kebab"
|
||||
class="usa-button usa-button--unstyled text-underline late-loading-modal-trigger margin-top-2 line-height-sans-5 text-secondary nameserver-delete-kebab"
|
||||
name="btn-delete-kebab-click"
|
||||
aria-label="Delete the name server from the registry"
|
||||
>
|
||||
|
@ -206,7 +206,7 @@
|
|||
{% input_with_errors form.ip %}
|
||||
{% endwith %}
|
||||
</td>
|
||||
<td class="padding-right-0 text-bottom">
|
||||
<td class="padding-right-0 text-bottom" data-label="Action">
|
||||
<button class="usa-button usa-button--unstyled display-block margin-top-1" type="submit">Save</button>
|
||||
|
||||
<button
|
||||
|
@ -240,9 +240,9 @@
|
|||
This section renders Add New Nameservers form which renders when there are no existing
|
||||
nameservers defined on the domain.
|
||||
{% endcomment %}
|
||||
|
||||
<section class="add-nameservers-form display-none section-outlined">
|
||||
<div class="add-nameservers-form display-none">
|
||||
{% include "includes/required_fields.html" %}
|
||||
<section class="section-outlined section-outlined--extra-padding">
|
||||
<form class="usa-form usa-form--extra-large" method="post" novalidate>
|
||||
<h2>Add name servers</h2>
|
||||
{% csrf_token %}
|
||||
|
@ -291,6 +291,7 @@
|
|||
</form>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
|
||||
|
@ -310,6 +311,22 @@
|
|||
{% include 'includes/modal.html' with modal_heading="Are you sure you want to continue?" modal_description="You have unsaved changes that will be lost." modal_button_id="unsaved-changes-click-button" modal_button_text="Continue without saving" cancel_button_text="Go back" %}
|
||||
</div>
|
||||
|
||||
<a
|
||||
id="cancel_changes_trigger"
|
||||
href="#cancel-changes-modal"
|
||||
class="usa-button usa-button--outline margin-top-1 display-none"
|
||||
aria-controls="cancel-changes-modal"
|
||||
data-open-modal
|
||||
>Trigger cancel changes modal</a>
|
||||
<div
|
||||
class="usa-modal"
|
||||
id="cancel-changes-modal"
|
||||
aria-labelledby="Are you sure you want to cancel your changes?"
|
||||
aria-describedby="This action cannot be undone."
|
||||
>
|
||||
{% include 'includes/modal.html' with modal_heading="Are you sure you want to cancel your changes?" modal_description="This action cannot be undone." modal_button_id="cancel-changes-click-button" modal_button_text="Yes, cancel" cancel_button_text="Go back" %}
|
||||
</div>
|
||||
|
||||
<a
|
||||
id="delete_trigger"
|
||||
href="#delete-modal"
|
||||
|
|
|
@ -5,10 +5,10 @@
|
|||
{% block content %}
|
||||
<div class="grid-container grid-container--widescreen">
|
||||
<div class="grid-row grid-gap {% if not is_widescreen_centered %}max-width--grid-container{% endif %}">
|
||||
<div class="tablet:grid-col-3">
|
||||
<div class="tablet:grid-col-3 grid-col--sidenav">
|
||||
{% include 'domain_request_sidebar.html' %}
|
||||
</div>
|
||||
<div class="tablet:grid-col-9">
|
||||
<div class="tablet:grid-col">
|
||||
<main id="main-content" class="grid-container register-form-step">
|
||||
<input type="hidden" class="display-none" id="wizard-domain-request-id" value="{{domain_request_id}}"/>
|
||||
{% if steps.current == steps.first %}
|
||||
|
|
|
@ -52,7 +52,6 @@
|
|||
<p>.Gov domains are registered for a one-year period. To renew your domain, you'll be asked to verify your organization’s eligibility and your contact information. </p>
|
||||
|
||||
<p>Though a domain may expire, it will not automatically be put on hold or deleted. We’ll make extensive efforts to contact your organization before holding or deleting a domain.</p>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block form_required_fields_help_text %}
|
||||
|
@ -60,12 +59,34 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block form_fields %}
|
||||
{% if requires_feb_questions %}
|
||||
<h2>Required and prohibited activities</h2>
|
||||
<h3>Prohibitions on non-governmental use</h3>
|
||||
|
||||
<p>Agencies may not use a .gov domain name:
|
||||
<ul class="usa-list">
|
||||
<li>On behalf of a non-federal executive branch entity</li>
|
||||
<li>For a non-governmental purpose</li>
|
||||
</ul>
|
||||
</p>
|
||||
|
||||
<h3>Compliance with the 21st Century IDEA Act is required</h3>
|
||||
<p>As required by the DOTGOV Act, agencies must ensure
|
||||
that any website or digital service that uses a .gov
|
||||
domain name is in compliance with the
|
||||
<a href="https://digital.gov/resources/delivering-digital-first-public-experience-act/" target="_blank" rel="noopener noreferrer">21st Century Integrated Digital Experience Act</a>.
|
||||
and
|
||||
<a href="https://bidenwhitehouse.gov/wp-content/uploads/2023/09/M-23-22-Delivering-a-Digital-First-Public-Experience.pdf" target="_blank" rel="noopener noreferrer">Guidance for Agencies</a>.
|
||||
</p>
|
||||
<h2>Acknowledgement of .gov domain requirements</h2>
|
||||
{% input_with_errors forms.0.is_policy_acknowledged %}
|
||||
{% else %}
|
||||
<fieldset class="usa-fieldset">
|
||||
<legend>
|
||||
<h2>Acknowledgement of .gov domain requirements</h2>
|
||||
</legend>
|
||||
|
||||
{% input_with_errors forms.0.is_policy_acknowledged %}
|
||||
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -40,7 +40,7 @@
|
|||
<button
|
||||
type="submit"
|
||||
class="usa-button"
|
||||
>{% if form.security_email.value is None or form.security_email.value == "dotgov@cisa.dhs.gov" or form.security_email.value == "registrar@dotgov.gov"%}Add security email{% else %}Save{% endif %}</button>
|
||||
>{% if form.security_email.value is None or form.security_email.value == "dotgov@cisa.dhs.gov" or form.security_email.value == "registrar@dotgov.gov" or form.security_email.value == "help@get.gov"%}Add security email{% else %}Save{% endif %}</button>
|
||||
</form>
|
||||
|
||||
{% endblock %} {# domain_content #}
|
||||
|
|
|
@ -5,7 +5,7 @@ A domain manager was removed from {{ domain.name }}.
|
|||
|
||||
REMOVED BY: {{ removed_by.email }}
|
||||
REMOVED ON: {{ date }}
|
||||
MANAGER REMOVED: {{ manager_removed.email }}
|
||||
MANAGER REMOVED: {{ manager_removed_email }}
|
||||
|
||||
----------------------------------------------------------------
|
||||
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
|
||||
Hi,{% if requested_user and requested_user.first_name %} {{ requested_user.first_name }}.{% endif %}
|
||||
|
||||
An update was made to your .gov organization.
|
||||
|
||||
ORGANIZATION: {{ portfolio }}
|
||||
UPDATED BY: {{ editor.email }}
|
||||
UPDATED ON: {{ date }}
|
||||
INFORMATION UPDATED: {{ updated_info }}
|
||||
|
||||
You can view this update in the .gov registrar <https://manage.get.gov>.
|
||||
|
||||
----------------------------------------------------------------
|
||||
|
||||
WHY DID YOU RECEIVE THIS EMAIL?
|
||||
You're listed as an admin for {{ portfolio }}, so you'll receive a
|
||||
notification whenever changes are made to that .gov organization.
|
||||
|
||||
If you have questions or concerns, reach out to the person who made the change or reply
|
||||
to this email.
|
||||
|
||||
THANK YOU
|
||||
.Gov helps the public identify official, trusted information. Thank you for using a .gov
|
||||
domain.
|
||||
|
||||
----------------------------------------------------------------
|
||||
|
||||
The .gov team
|
||||
Contact us: <https://get.gov/contact/>
|
||||
Learn about .gov <https://get.gov>
|
||||
|
||||
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency
|
||||
(CISA) <https://cisa.gov/>
|
||||
{% endautoescape %}
|
|
@ -0,0 +1 @@
|
|||
An update was made to your .gov organization
|
|
@ -57,7 +57,7 @@ THANK YOU
|
|||
The .gov team
|
||||
|
||||
.Gov blog <https://get.gov/updates/>
|
||||
Domain management <{{ manage_url }}}>
|
||||
Domain management <{{ manage_url }}>
|
||||
Get.gov <https://get.gov>
|
||||
|
||||
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <https://cisa.gov/>
|
||||
|
|
|
@ -62,7 +62,7 @@
|
|||
|
||||
{% with link_href=login_help_url %}
|
||||
{% with sublabel_text="We recommend using a Login.gov account that's only connected to your work email address. If you need to change your email, you'll need to make a change to your Login.gov account. Get help with updating your email address." %}
|
||||
{% with link_text="Get help with updating your email address" target_blank=True do_not_show_max_chars=True %}
|
||||
{% with link_text="Get help with updating your email address" target_blank=True %}
|
||||
{% input_with_errors form.email %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
|
|
|
@ -7,9 +7,11 @@ error messages, if necessary.
|
|||
|
||||
{% load widget_tweaks %}
|
||||
|
||||
{% if widget.attrs.maxlength %}
|
||||
{% if widget.attrs.maxlength or field.widget_type == 'textarea' %}
|
||||
{% if not widget.attrs.hide_character_count %}
|
||||
<div class="usa-character-count">
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if field.use_fieldset %}
|
||||
<fieldset
|
||||
|
@ -35,7 +37,7 @@ error messages, if necessary.
|
|||
{% endif %}
|
||||
|
||||
{% if sublabel_text %}
|
||||
<p id="{{ widget.attrs.id }}__sublabel" class="text-base margin-top-2px margin-bottom-1">
|
||||
<p id="{{ widget.attrs.id }}__sublabel" class="{% if use_small_sublabel_text %}font-body-xs {% endif %}text-base margin-top-2px margin-bottom-1">
|
||||
{# If the link_text appears more than once, the first instance will be a link and the other instances will be ignored #}
|
||||
{% if link_text and link_text in sublabel_text %}
|
||||
{% with link_index=sublabel_text|find_index:link_text %}
|
||||
|
@ -52,11 +54,11 @@ error messages, if necessary.
|
|||
{% if field.errors %}
|
||||
<div id="{{ widget.attrs.id }}__error-message">
|
||||
{% for error in field.errors %}
|
||||
<div class="usa-error-message display-flex" role="alert">
|
||||
<div class="usa-error-message display-flex{% if inline_error_class %} {{ inline_error_class }}{% endif %}" role="alert">
|
||||
<svg class="usa-icon usa-icon--large" focusable="true" role="img" aria-label="Error">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#error"></use>
|
||||
</svg>
|
||||
<span class="margin-left-05">{{ error }}</span>
|
||||
<span class="margin-left-05 flex-1">{{ error }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
@ -92,7 +94,8 @@ error messages, if necessary.
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if widget.attrs.maxlength and not do_not_show_max_chars %}
|
||||
{% if field.widget_type == 'textarea' or widget.attrs.maxlength %}
|
||||
{% if not widget.attrs.hide_character_count %}
|
||||
<span
|
||||
id="{{ widget.attrs.id }}__message"
|
||||
class="usa-character-count__message"
|
||||
|
@ -100,6 +103,6 @@ error messages, if necessary.
|
|||
>
|
||||
You can enter up to {{ widget.attrs.maxlength }} characters
|
||||
</span>
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
|
|
@ -58,18 +58,7 @@
|
|||
{% endif %}
|
||||
</li>
|
||||
<li class="usa-button-group__item">
|
||||
{% comment %} The cancel button the DS form actually triggers a context change in the view,
|
||||
in addition to being a close modal hook {% endcomment %}
|
||||
{% if cancel_button_resets_ds_form %}
|
||||
<button
|
||||
type="submit"
|
||||
class="usa-button usa-button--unstyled padding-105 text-center"
|
||||
id="btn-cancel-click-button"
|
||||
data-close-modal
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{% elif not cancel_button_only %}
|
||||
{% if not cancel_button_only %}
|
||||
<button
|
||||
type="button"
|
||||
class="usa-button usa-button--unstyled padding-105 text-center"
|
||||
|
@ -82,21 +71,6 @@
|
|||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% comment %} The cancel button the DS form actually triggers a context change in the view,
|
||||
in addition to being a close modal hook {% endcomment %}
|
||||
{% if cancel_button_resets_ds_form %}
|
||||
<button
|
||||
type="submit"
|
||||
class="usa-button usa-modal__close"
|
||||
aria-label="Close this window"
|
||||
id="btn-cancel-click-close-button"
|
||||
data-close-modal
|
||||
>
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
|
||||
</svg>
|
||||
</button>
|
||||
{% else %}
|
||||
<button
|
||||
type="button"
|
||||
class="usa-button usa-modal__close"
|
||||
|
@ -107,5 +81,4 @@
|
|||
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
|
||||
</svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
|
@ -36,7 +36,7 @@
|
|||
{% with sublabel_text="We recommend using a Login.gov account that's only connected to your work email address. If you need to change your email, you'll need to make a change to your Login.gov account. Get help with updating your email address." %}
|
||||
{% with link_text="Get help with updating your email address" %}
|
||||
{% with target_blank=True %}
|
||||
{% with do_not_show_max_chars=True %}
|
||||
{% with hide_character_count=True %}
|
||||
{% input_with_errors form.email %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
|
|
|
@ -6,7 +6,50 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block form_fields %}
|
||||
{% if requires_feb_questions %}
|
||||
{{forms.2.management_form}}
|
||||
{{forms.3.management_form}}
|
||||
{{forms.4.management_form}}
|
||||
{{forms.5.management_form}}
|
||||
<fieldset class="usa-fieldset">
|
||||
<h2 class="margin-top-0 margin-bottom-0">Are you working with someone in the Executive Office of the President (EOP) on this request?</h2>
|
||||
|
||||
<p class="margin-bottom-0 margin-top-1">
|
||||
<em>Select one. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></em>
|
||||
</p>
|
||||
{% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
|
||||
{% input_with_errors forms.0.working_with_eop %}
|
||||
{% endwith %}
|
||||
|
||||
<div id="eop-contact-container" class="conditional-panel display-none">
|
||||
<p class="margin-bottom-0 margin-top-1">
|
||||
Provide the name and email of the person you're working with.<span class="usa-label--required">*</span>
|
||||
</p>
|
||||
{% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
|
||||
{% input_with_errors forms.1.first_name %}
|
||||
{% input_with_errors forms.1.last_name %}
|
||||
{% input_with_errors forms.1.email %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
<h2 class="margin-top-0 margin-bottom-0">Is there anything else you'd like us to know about your domain request?</h2>
|
||||
<p class="margin-bottom-0 margin-top-1">
|
||||
<em>Select one. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></em>
|
||||
</p>
|
||||
{% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
|
||||
{% input_with_errors forms.2.has_anything_else_text %}
|
||||
{% endwith %}
|
||||
|
||||
<div id="anything-else-details-container" class="conditional-panel display-none">
|
||||
<p class="usa-label">
|
||||
<em>Provide details below <span class="usa-label--required">*</span></em>
|
||||
</p>
|
||||
{% with add_label_class="usa-sr-only" attr_required="required" attr_maxlength="2000" %}
|
||||
{% input_with_errors forms.3.anything_else %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</fieldset>
|
||||
{% else %}
|
||||
<fieldset class="usa-fieldset">
|
||||
<h2 class="margin-top-0 margin-bottom-0">Is there anything else you’d like us to know about your domain request?</h2>
|
||||
</legend>
|
||||
|
@ -18,4 +61,5 @@
|
|||
{% input_with_errors forms.0.anything_else %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -114,7 +114,7 @@ def input_with_errors(context, field=None): # noqa: C901
|
|||
|
||||
# do some work for various edge cases
|
||||
|
||||
if "maxlength" in attrs:
|
||||
if "maxlength" in attrs and "hide_character_count" not in attrs:
|
||||
# associate the field programmatically with its hint text
|
||||
described_by.append(f"{attrs['id']}__message")
|
||||
|
||||
|
@ -175,7 +175,7 @@ def input_with_errors(context, field=None): # noqa: C901
|
|||
|
||||
# Conditionally add the data-initial-value attribute
|
||||
if context.get("add_initial_value_attr", False):
|
||||
attrs["data-initial-value"] = field.initial or ""
|
||||
attrs["data-initial-value"] = field.initial if field.initial is not None else ""
|
||||
|
||||
# ask Django to give us the widget dict
|
||||
# see Widget.get_context() on
|
||||
|
|
|
@ -1010,6 +1010,27 @@ def create_user(**kwargs):
|
|||
return user
|
||||
|
||||
|
||||
def create_omb_analyst_user(**kwargs):
|
||||
"""Creates a analyst user with is_staff=True and the group cisa_analysts_group"""
|
||||
User = get_user_model()
|
||||
p = "userpass"
|
||||
user = User.objects.create_user(
|
||||
username=kwargs.get("username", "ombanalystuser"),
|
||||
email=kwargs.get("email", "ombanalyst@example.com"),
|
||||
first_name=kwargs.get("first_name", "first"),
|
||||
last_name=kwargs.get("last_name", "last"),
|
||||
is_staff=kwargs.get("is_staff", True),
|
||||
title=kwargs.get("title", "title"),
|
||||
password=kwargs.get("password", p),
|
||||
phone=kwargs.get("phone", "8003111234"),
|
||||
)
|
||||
# Retrieve the group or create it if it doesn't exist
|
||||
group, _ = UserGroup.objects.get_or_create(name="omb_analysts_group")
|
||||
# Add the user to the group
|
||||
user.groups.set([group])
|
||||
return user
|
||||
|
||||
|
||||
def create_test_user():
|
||||
username = "test_user"
|
||||
first_name = "First"
|
||||
|
@ -1423,10 +1444,8 @@ class MockEppLib(TestCase):
|
|||
],
|
||||
)
|
||||
|
||||
mockDefaultTechnicalContact = InfoDomainWithContacts.dummyInfoContactResultData(
|
||||
"defaultTech", "dotgov@cisa.dhs.gov"
|
||||
)
|
||||
mockDefaultSecurityContact = InfoDomainWithContacts.dummyInfoContactResultData("defaultSec", "dotgov@cisa.dhs.gov")
|
||||
mockDefaultTechnicalContact = InfoDomainWithContacts.dummyInfoContactResultData("defaultTech", "help@get.gov")
|
||||
mockDefaultSecurityContact = InfoDomainWithContacts.dummyInfoContactResultData("defaultSec", "help@get.gov")
|
||||
mockSecurityContact = InfoDomainWithContacts.dummyInfoContactResultData("securityContact", "security@mail.gov")
|
||||
mockTechnicalContact = InfoDomainWithContacts.dummyInfoContactResultData("technicalContact", "tech@mail.gov")
|
||||
mockAdministrativeContact = InfoDomainWithContacts.dummyInfoContactResultData("adminContact", "admin@mail.gov")
|
||||
|
@ -1941,14 +1960,23 @@ class MockEppLib(TestCase):
|
|||
self.mockedSendFunction = self.mockSendPatch.start()
|
||||
self.mockedSendFunction.side_effect = self.mockSend
|
||||
|
||||
def _convertPublicContactToEpp(self, contact: PublicContact, disclose_email=False, createContact=True):
|
||||
def _convertPublicContactToEpp(
|
||||
self,
|
||||
contact: PublicContact,
|
||||
disclose=False,
|
||||
createContact=True,
|
||||
disclose_fields=None,
|
||||
disclose_types=None,
|
||||
):
|
||||
DF = common.DiscloseField
|
||||
fields = {DF.EMAIL}
|
||||
if disclose_fields is None:
|
||||
fields = {DF.NOTIFY_EMAIL, DF.VAT, DF.IDENT, DF.EMAIL}
|
||||
disclose_fields = {field for field in DF} - fields
|
||||
|
||||
di = common.Disclose(
|
||||
flag=disclose_email,
|
||||
fields=fields,
|
||||
)
|
||||
if disclose_types is None:
|
||||
disclose_types = {DF.ADDR: "loc", DF.NAME: "loc"}
|
||||
|
||||
di = common.Disclose(flag=disclose, fields=disclose_fields, types=disclose_types)
|
||||
|
||||
# check docs here looks like we may have more than one address field but
|
||||
addr = common.ContactAddr(
|
||||
|
|
|
@ -3,6 +3,7 @@ from django.utils import timezone
|
|||
from django.test import TestCase, RequestFactory, Client
|
||||
from django.contrib.admin.sites import AdminSite
|
||||
from registrar import models
|
||||
from registrar.utility.constants import BranchChoices
|
||||
from registrar.utility.email import EmailSendingError
|
||||
from registrar.utility.errors import MissingEmailError
|
||||
from waffle.testutils import override_flag
|
||||
|
@ -57,6 +58,7 @@ from .common import (
|
|||
MockDbForSharedTests,
|
||||
AuditedAdminMockData,
|
||||
completed_domain_request,
|
||||
create_omb_analyst_user,
|
||||
create_test_user,
|
||||
generic_domain_object,
|
||||
less_console_noise,
|
||||
|
@ -136,18 +138,25 @@ class TestDomainInvitationAdmin(WebTest):
|
|||
csrf_checks = False
|
||||
|
||||
@classmethod
|
||||
def setUpClass(self):
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
self.site = AdminSite()
|
||||
self.factory = RequestFactory()
|
||||
self.superuser = create_superuser()
|
||||
cls.site = AdminSite()
|
||||
cls.factory = RequestFactory()
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.superuser = create_superuser()
|
||||
self.cisa_analyst = create_user()
|
||||
self.omb_analyst = create_omb_analyst_user()
|
||||
self.admin = ListHeaderAdmin(model=DomainInvitationAdmin, admin_site=AdminSite())
|
||||
self.domain = Domain.objects.create(name="example.com")
|
||||
self.fed_agency = FederalAgency.objects.create(
|
||||
agency="New FedExec Agency", federal_type=BranchChoices.EXECUTIVE
|
||||
)
|
||||
self.portfolio = Portfolio.objects.create(organization_name="new portfolio", creator=self.superuser)
|
||||
DomainInformation.objects.create(domain=self.domain, portfolio=self.portfolio, creator=self.superuser)
|
||||
self.domain_info = DomainInformation.objects.create(
|
||||
domain=self.domain, portfolio=self.portfolio, creator=self.superuser
|
||||
)
|
||||
"""Create a client object"""
|
||||
self.client = Client(HTTP_HOST="localhost:8080")
|
||||
self.client.force_login(self.superuser)
|
||||
|
@ -159,10 +168,124 @@ class TestDomainInvitationAdmin(WebTest):
|
|||
DomainInvitation.objects.all().delete()
|
||||
DomainInformation.objects.all().delete()
|
||||
Portfolio.objects.all().delete()
|
||||
self.fed_agency.delete()
|
||||
Domain.objects.all().delete()
|
||||
Contact.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_analyst_view(self):
|
||||
"""Ensure regular analysts can view domain invitations."""
|
||||
invitation = DomainInvitation.objects.create(email="test@example.com", domain=self.domain)
|
||||
self.client.force_login(self.cisa_analyst)
|
||||
response = self.client.get(reverse("admin:registrar_domaininvitation_changelist"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, invitation.email)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_omb_analyst_view_non_feb_domain(self):
|
||||
"""Ensure OMB analysts cannot view non-federal domains."""
|
||||
invitation = DomainInvitation.objects.create(email="test@example.com", domain=self.domain)
|
||||
self.client.force_login(self.omb_analyst)
|
||||
response = self.client.get(reverse("admin:registrar_domaininvitation_changelist"))
|
||||
self.assertNotContains(response, invitation.email)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_omb_analyst_view_feb_domain(self):
|
||||
"""Ensure OMB analysts can view federal executive branch domains."""
|
||||
invitation = DomainInvitation.objects.create(email="test@example.com", domain=self.domain)
|
||||
self.portfolio.organization_type = DomainRequest.OrganizationChoices.FEDERAL
|
||||
self.portfolio.federal_agency = self.fed_agency
|
||||
self.portfolio.save()
|
||||
self.client.force_login(self.omb_analyst)
|
||||
response = self.client.get(reverse("admin:registrar_domaininvitation_changelist"))
|
||||
self.assertContains(response, invitation.email)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_superuser_view(self):
|
||||
"""Ensure superusers can view domain invitations."""
|
||||
invitation = DomainInvitation.objects.create(email="test@example.com", domain=self.domain)
|
||||
response = self.client.get(reverse("admin:registrar_domaininvitation_changelist"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, invitation.email)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_analyst_change(self):
|
||||
"""Ensure regular analysts can view domain invitations but not update."""
|
||||
invitation = DomainInvitation.objects.create(email="test@example.com", domain=self.domain)
|
||||
self.client.force_login(self.cisa_analyst)
|
||||
response = self.client.get(reverse("admin:registrar_domaininvitation_change", args=[invitation.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, invitation.email)
|
||||
# test whether fields are readonly or editable
|
||||
self.assertNotContains(response, "id_domain")
|
||||
self.assertNotContains(response, "id_email")
|
||||
self.assertContains(response, "closelink")
|
||||
self.assertNotContains(response, "Save")
|
||||
self.assertNotContains(response, "Delete")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_omb_analyst_change_non_feb_domain(self):
|
||||
"""Ensure OMB analysts cannot change non-federal domains."""
|
||||
invitation = DomainInvitation.objects.create(email="test@example.com", domain=self.domain)
|
||||
self.client.force_login(self.omb_analyst)
|
||||
response = self.client.get(reverse("admin:registrar_domaininvitation_change", args=[invitation.id]))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_omb_analyst_change_feb_domain(self):
|
||||
"""Ensure OMB analysts can view federal executive branch domains."""
|
||||
invitation = DomainInvitation.objects.create(email="test@example.com", domain=self.domain)
|
||||
# update domain
|
||||
self.portfolio.organization_type = DomainRequest.OrganizationChoices.FEDERAL
|
||||
self.portfolio.federal_agency = self.fed_agency
|
||||
self.portfolio.save()
|
||||
self.client.force_login(self.omb_analyst)
|
||||
response = self.client.get(reverse("admin:registrar_domaininvitation_change", args=[invitation.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, invitation.email)
|
||||
# test whether fields are readonly or editable
|
||||
self.assertNotContains(response, "id_domain")
|
||||
self.assertNotContains(response, "id_email")
|
||||
self.assertContains(response, "closelink")
|
||||
self.assertNotContains(response, "Save")
|
||||
self.assertNotContains(response, "Delete")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_superuser_change(self):
|
||||
"""Ensure superusers can change domain invitations."""
|
||||
invitation = DomainInvitation.objects.create(email="test@example.com", domain=self.domain)
|
||||
response = self.client.get(reverse("admin:registrar_domaininvitation_change", args=[invitation.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, invitation.email)
|
||||
# test whether fields are readonly or editable
|
||||
self.assertContains(response, "id_domain")
|
||||
self.assertContains(response, "id_email")
|
||||
self.assertNotContains(response, "closelink")
|
||||
self.assertContains(response, "Save")
|
||||
self.assertContains(response, "Delete")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_omb_analyst_filter_feb_domain(self):
|
||||
"""Ensure OMB analysts can apply filters and only federal executive branch domains show."""
|
||||
# create invitation on domain that is not FEB
|
||||
invitation = DomainInvitation.objects.create(email="test@example.com", domain=self.domain)
|
||||
self.client.force_login(self.omb_analyst)
|
||||
response = self.client.get(
|
||||
reverse("admin:registrar_domaininvitation_changelist"),
|
||||
{"status": DomainInvitation.DomainInvitationStatus.INVITED},
|
||||
)
|
||||
self.assertNotContains(response, invitation.email)
|
||||
# update domain
|
||||
self.portfolio.organization_type = DomainRequest.OrganizationChoices.FEDERAL
|
||||
self.portfolio.federal_agency = self.fed_agency
|
||||
self.portfolio.save()
|
||||
response = self.client.get(
|
||||
reverse("admin:registrar_domaininvitation_changelist"),
|
||||
{"status": DomainInvitation.DomainInvitationStatus.INVITED},
|
||||
)
|
||||
self.assertContains(response, invitation.email)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_has_model_description(self):
|
||||
"""Tests if this model has a model description on the table view"""
|
||||
|
@ -1139,6 +1262,7 @@ class TestUserPortfolioPermissionAdmin(TestCase):
|
|||
self.client = Client(HTTP_HOST="localhost:8080")
|
||||
self.superuser = create_superuser()
|
||||
self.testuser = create_test_user()
|
||||
self.omb_analyst = create_omb_analyst_user()
|
||||
self.portfolio = Portfolio.objects.create(organization_name="Test Portfolio", creator=self.superuser)
|
||||
|
||||
def tearDown(self):
|
||||
|
@ -1148,6 +1272,26 @@ class TestUserPortfolioPermissionAdmin(TestCase):
|
|||
User.objects.all().delete()
|
||||
UserPortfolioPermission.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_omb_analyst_view(self):
|
||||
"""Ensure OMB analysts cannot view user portfolio permissions list."""
|
||||
self.client.force_login(self.omb_analyst)
|
||||
response = self.client.get(reverse("admin:registrar_userportfoliopermission_changelist"))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_omb_analyst_change(self):
|
||||
"""Ensure OMB analysts cannot change user portfolio permission."""
|
||||
self.client.force_login(self.omb_analyst)
|
||||
user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.superuser, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
response = self.client.get(
|
||||
"/admin/registrar/userportfoliopermission/{}/change/".format(user_portfolio_permission.pk),
|
||||
follow=True,
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_has_change_form_description(self):
|
||||
"""Tests if this model has a model description on the change form view"""
|
||||
|
@ -1204,6 +1348,7 @@ class TestPortfolioInvitationAdmin(TestCase):
|
|||
def setUp(self):
|
||||
"""Create a client object"""
|
||||
self.client = Client(HTTP_HOST="localhost:8080")
|
||||
self.omb_analyst = create_omb_analyst_user()
|
||||
self.portfolio = Portfolio.objects.create(organization_name="Test Portfolio", creator=self.superuser)
|
||||
|
||||
def tearDown(self):
|
||||
|
@ -1217,6 +1362,26 @@ class TestPortfolioInvitationAdmin(TestCase):
|
|||
def tearDownClass(self):
|
||||
User.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_omb_analyst_view(self):
|
||||
"""Ensure OMB analysts cannot view portfolio invitations list."""
|
||||
self.client.force_login(self.omb_analyst)
|
||||
response = self.client.get(reverse("admin:registrar_portfolioinvitation_changelist"))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_omb_analyst_change(self):
|
||||
"""Ensure OMB analysts cannot change portfolio invitation."""
|
||||
self.client.force_login(self.omb_analyst)
|
||||
invitation, _ = PortfolioInvitation.objects.get_or_create(
|
||||
email=self.superuser.email, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
response = self.client.get(
|
||||
"/admin/registrar/portfolioinvitation/{}/change/".format(invitation.pk),
|
||||
follow=True,
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_has_model_description(self):
|
||||
"""Tests if this model has a model description on the table view"""
|
||||
|
@ -1791,6 +1956,8 @@ class TestHostAdmin(TestCase):
|
|||
cls.factory = RequestFactory()
|
||||
cls.admin = MyHostAdmin(model=Host, admin_site=cls.site)
|
||||
cls.superuser = create_superuser()
|
||||
cls.staffuser = create_user()
|
||||
cls.omb_analyst = create_omb_analyst_user()
|
||||
|
||||
def setUp(self):
|
||||
"""Setup environment for a mock admin user"""
|
||||
|
@ -1806,6 +1973,20 @@ class TestHostAdmin(TestCase):
|
|||
def tearDownClass(cls):
|
||||
User.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_analyst_view(self):
|
||||
"""Ensure analysts cannot view hosts list."""
|
||||
self.client.force_login(self.staffuser)
|
||||
response = self.client.get(reverse("admin:registrar_host_changelist"))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_omb_analyst_view(self):
|
||||
"""Ensure OMB analysts cannot view hosts list."""
|
||||
self.client.force_login(self.omb_analyst)
|
||||
response = self.client.get(reverse("admin:registrar_host_changelist"))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_has_model_description(self):
|
||||
"""Tests if this model has a model description on the table view"""
|
||||
|
@ -1870,6 +2051,7 @@ class TestDomainInformationAdmin(TestCase):
|
|||
cls.admin = DomainInformationAdmin(model=DomainInformation, admin_site=cls.site)
|
||||
cls.superuser = create_superuser()
|
||||
cls.staffuser = create_user()
|
||||
cls.omb_analyst = create_omb_analyst_user()
|
||||
cls.mock_data_generator = AuditedAdminMockData()
|
||||
cls.test_helper = GenericTestHelper(
|
||||
factory=cls.factory,
|
||||
|
@ -1881,12 +2063,24 @@ class TestDomainInformationAdmin(TestCase):
|
|||
|
||||
def setUp(self):
|
||||
self.client = Client(HTTP_HOST="localhost:8080")
|
||||
self.nonfeddomain = Domain.objects.create(name="nonfeddomain.com")
|
||||
self.feddomain = Domain.objects.create(name="feddomain.com")
|
||||
self.fed_agency = FederalAgency.objects.create(
|
||||
agency="New FedExec Agency", federal_type=BranchChoices.EXECUTIVE
|
||||
)
|
||||
self.portfolio = Portfolio.objects.create(organization_name="new portfolio", creator=self.superuser)
|
||||
self.domain_info = DomainInformation.objects.create(
|
||||
domain=self.feddomain, portfolio=self.portfolio, creator=self.superuser
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
"""Delete all Users, Domains, and UserDomainRoles"""
|
||||
DomainInformation.objects.all().delete()
|
||||
DomainRequest.objects.all().delete()
|
||||
Domain.objects.all().delete()
|
||||
DomainInformation.objects.all().delete()
|
||||
Portfolio.objects.all().delete()
|
||||
self.fed_agency.delete()
|
||||
Contact.objects.all().delete()
|
||||
|
||||
@classmethod
|
||||
|
@ -1894,6 +2088,56 @@ class TestDomainInformationAdmin(TestCase):
|
|||
User.objects.all().delete()
|
||||
SeniorOfficial.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_analyst_view(self):
|
||||
"""Ensure regular analysts cannot view domain information list."""
|
||||
self.client.force_login(self.staffuser)
|
||||
response = self.client.get(reverse("admin:registrar_domaininformation_changelist"))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_omb_analyst_view(self):
|
||||
"""Ensure OMB analysts cannot view domain information list."""
|
||||
self.client.force_login(self.omb_analyst)
|
||||
response = self.client.get(reverse("admin:registrar_domaininformation_changelist"))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_superuser_view(self):
|
||||
"""Ensure superusers can view domain information list."""
|
||||
self.client.force_login(self.superuser)
|
||||
response = self.client.get(reverse("admin:registrar_domaininformation_changelist"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, self.feddomain.name)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_analyst_change(self):
|
||||
"""Ensure regular analysts cannot view/edit domain information directly."""
|
||||
self.client.force_login(self.staffuser)
|
||||
response = self.client.get(
|
||||
reverse("admin:registrar_domaininformation_change", args=[self.feddomain.domain_info.id])
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_omb_analyst_change(self):
|
||||
"""Ensure OMB analysts cannot view/edit domain information directly."""
|
||||
self.client.force_login(self.omb_analyst)
|
||||
response = self.client.get(
|
||||
reverse("admin:registrar_domaininformation_change", args=[self.feddomain.domain_info.id])
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_superuser_change(self):
|
||||
"""Ensure superusers can view/change domain information directly."""
|
||||
self.client.force_login(self.superuser)
|
||||
response = self.client.get(
|
||||
reverse("admin:registrar_domaininformation_change", args=[self.feddomain.domain_info.id])
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, self.feddomain.name)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_domain_information_senior_official_is_alphabetically_sorted(self):
|
||||
"""Tests if the senior offical dropdown is alphanetically sorted in the django admin display"""
|
||||
|
@ -2258,6 +2502,8 @@ class TestUserDomainRoleAdmin(WebTest):
|
|||
cls.factory = RequestFactory()
|
||||
cls.admin = UserDomainRoleAdmin(model=UserDomainRole, admin_site=cls.site)
|
||||
cls.superuser = create_superuser()
|
||||
cls.staffuser = create_user()
|
||||
cls.omb_analyst = create_omb_analyst_user()
|
||||
cls.test_helper = GenericTestHelper(
|
||||
factory=cls.factory,
|
||||
user=cls.superuser,
|
||||
|
@ -2285,6 +2531,31 @@ class TestUserDomainRoleAdmin(WebTest):
|
|||
super().tearDownClass()
|
||||
User.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_analyst_view(self):
|
||||
"""Ensure analysts cannot view user domain roles list."""
|
||||
self.client.force_login(self.staffuser)
|
||||
response = self.client.get(reverse("admin:registrar_userdomainrole_changelist"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_omb_analyst_view(self):
|
||||
"""Ensure OMB analysts cannot view user domain roles list."""
|
||||
self.client.force_login(self.omb_analyst)
|
||||
response = self.client.get(reverse("admin:registrar_userdomainrole_changelist"))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_omb_analyst_change(self):
|
||||
"""Ensure OMB analysts cannot view/edit user domain roles list."""
|
||||
domain, _ = Domain.objects.get_or_create(name="anyrandomdomain.com")
|
||||
user_domain_role, _ = UserDomainRole.objects.get_or_create(
|
||||
user=self.superuser, domain=domain, role=[UserDomainRole.Roles.MANAGER]
|
||||
)
|
||||
self.client.force_login(self.omb_analyst)
|
||||
response = self.client.get(reverse("admin:registrar_userdomainrole_change", args=[user_domain_role.id]))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_has_model_description(self):
|
||||
"""Tests if this model has a model description on the table view"""
|
||||
|
@ -2580,6 +2851,7 @@ class TestMyUserAdmin(MockDbForSharedTests, WebTest):
|
|||
cls.admin = MyUserAdmin(model=get_user_model(), admin_site=admin_site)
|
||||
cls.superuser = create_superuser()
|
||||
cls.staffuser = create_user()
|
||||
cls.omb_analyst = create_omb_analyst_user()
|
||||
cls.test_helper = GenericTestHelper(admin=cls.admin)
|
||||
|
||||
def setUp(self):
|
||||
|
@ -2596,6 +2868,13 @@ class TestMyUserAdmin(MockDbForSharedTests, WebTest):
|
|||
super().tearDownClass()
|
||||
User.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_omb_analyst_view(self):
|
||||
"""Ensure OMB analysts cannot view users list."""
|
||||
self.client.force_login(self.omb_analyst)
|
||||
response = self.client.get(reverse("admin:registrar_user_changelist"))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_has_model_description(self):
|
||||
"""Tests if this model has a model description on the table view"""
|
||||
|
@ -3221,6 +3500,7 @@ class TestContactAdmin(TestCase):
|
|||
cls.admin = ContactAdmin(model=Contact, admin_site=None)
|
||||
cls.superuser = create_superuser()
|
||||
cls.staffuser = create_user()
|
||||
cls.omb_analyst = create_omb_analyst_user()
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
@ -3236,6 +3516,13 @@ class TestContactAdmin(TestCase):
|
|||
super().tearDownClass()
|
||||
User.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_omb_analyst_view(self):
|
||||
"""Ensure OMB analysts cannot view contact list."""
|
||||
self.client.force_login(self.omb_analyst)
|
||||
response = self.client.get(reverse("admin:registrar_contact_changelist"))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_has_model_description(self):
|
||||
"""Tests if this model has a model description on the table view"""
|
||||
|
@ -3282,6 +3569,7 @@ class TestVerifiedByStaffAdmin(TestCase):
|
|||
super().setUpClass()
|
||||
cls.site = AdminSite()
|
||||
cls.superuser = create_superuser()
|
||||
cls.omb_analyst = create_omb_analyst_user()
|
||||
cls.admin = VerifiedByStaffAdmin(model=VerifiedByStaff, admin_site=cls.site)
|
||||
cls.factory = RequestFactory()
|
||||
cls.test_helper = GenericTestHelper(admin=cls.admin)
|
||||
|
@ -3299,18 +3587,20 @@ class TestVerifiedByStaffAdmin(TestCase):
|
|||
super().tearDownClass()
|
||||
User.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_omb_analyst_view(self):
|
||||
"""Ensure OMB analysts cannot view verified by staff list."""
|
||||
self.client.force_login(self.omb_analyst)
|
||||
response = self.client.get(reverse("admin:registrar_verifiedbystaff_changelist"))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_has_model_description(self):
|
||||
"""Tests if this model has a model description on the table view"""
|
||||
self.client.force_login(self.superuser)
|
||||
response = self.client.get(
|
||||
"/admin/registrar/verifiedbystaff/",
|
||||
follow=True,
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("admin:registrar_verifiedbystaff_changelist"))
|
||||
# Make sure that the page is loaded correctly
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Test for a description snippet
|
||||
self.assertContains(
|
||||
response, "This table contains users who have been allowed to bypass " "identity proofing through Login.gov"
|
||||
|
@ -3365,6 +3655,7 @@ class TestWebsiteAdmin(TestCase):
|
|||
super().setUp()
|
||||
self.site = AdminSite()
|
||||
self.superuser = create_superuser()
|
||||
self.omb_analyst = create_omb_analyst_user()
|
||||
self.admin = WebsiteAdmin(model=Website, admin_site=self.site)
|
||||
self.factory = RequestFactory()
|
||||
self.client = Client(HTTP_HOST="localhost:8080")
|
||||
|
@ -3375,15 +3666,18 @@ class TestWebsiteAdmin(TestCase):
|
|||
Website.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_omb_analyst_view(self):
|
||||
"""Ensure OMB analysts cannot view website list."""
|
||||
self.client.force_login(self.omb_analyst)
|
||||
response = self.client.get(reverse("admin:registrar_website_changelist"))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_has_model_description(self):
|
||||
"""Tests if this model has a model description on the table view"""
|
||||
self.client.force_login(self.superuser)
|
||||
response = self.client.get(
|
||||
"/admin/registrar/website/",
|
||||
follow=True,
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("admin:registrar_website_changelist"))
|
||||
# Make sure that the page is loaded correctly
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
@ -3392,13 +3686,14 @@ class TestWebsiteAdmin(TestCase):
|
|||
self.assertContains(response, "Show more")
|
||||
|
||||
|
||||
class TestDraftDomain(TestCase):
|
||||
class TestDraftDomainAdmin(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.site = AdminSite()
|
||||
cls.superuser = create_superuser()
|
||||
cls.omb_analyst = create_omb_analyst_user()
|
||||
cls.admin = DraftDomainAdmin(model=DraftDomain, admin_site=cls.site)
|
||||
cls.factory = RequestFactory()
|
||||
cls.test_helper = GenericTestHelper(admin=cls.admin)
|
||||
|
@ -3416,15 +3711,18 @@ class TestDraftDomain(TestCase):
|
|||
super().tearDownClass()
|
||||
User.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_omb_analyst_view(self):
|
||||
"""Ensure OMB analysts cannot view draft domain list."""
|
||||
self.client.force_login(self.omb_analyst)
|
||||
response = self.client.get(reverse("admin:registrar_draftdomain_changelist"))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_has_model_description(self):
|
||||
"""Tests if this model has a model description on the table view"""
|
||||
self.client.force_login(self.superuser)
|
||||
response = self.client.get(
|
||||
"/admin/registrar/draftdomain/",
|
||||
follow=True,
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("admin:registrar_draftdomain_changelist"))
|
||||
# Make sure that the page is loaded correctly
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
@ -3435,13 +3733,21 @@ class TestDraftDomain(TestCase):
|
|||
self.assertContains(response, "Show more")
|
||||
|
||||
|
||||
class TestFederalAgency(TestCase):
|
||||
class TestFederalAgencyAdmin(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.site = AdminSite()
|
||||
cls.superuser = create_superuser()
|
||||
cls.staffuser = create_user()
|
||||
cls.omb_analyst = create_omb_analyst_user()
|
||||
cls.non_feb_agency = FederalAgency.objects.create(
|
||||
agency="Fake judicial agency", federal_type=BranchChoices.JUDICIAL
|
||||
)
|
||||
cls.feb_agency = FederalAgency.objects.create(
|
||||
agency="Fake executive agency", federal_type=BranchChoices.EXECUTIVE
|
||||
)
|
||||
cls.admin = FederalAgencyAdmin(model=FederalAgency, admin_site=cls.site)
|
||||
cls.factory = RequestFactory()
|
||||
cls.test_helper = GenericTestHelper(admin=cls.admin)
|
||||
|
@ -3454,6 +3760,100 @@ class TestFederalAgency(TestCase):
|
|||
super().tearDownClass()
|
||||
User.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_analyst_view(self):
|
||||
"""Ensure regular analysts can view federal agencies."""
|
||||
self.client.force_login(self.staffuser)
|
||||
response = self.client.get(reverse("admin:registrar_federalagency_changelist"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, self.non_feb_agency.agency)
|
||||
self.assertContains(response, self.feb_agency.agency)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_omb_analyst_view(self):
|
||||
"""Ensure OMB analysts can view FEB agencies but not other branches."""
|
||||
self.client.force_login(self.omb_analyst)
|
||||
response = self.client.get(reverse("admin:registrar_federalagency_changelist"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotContains(response, self.non_feb_agency.agency)
|
||||
self.assertContains(response, self.feb_agency.agency)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_superuser_view(self):
|
||||
"""Ensure superusers can view domain invitations."""
|
||||
self.client.force_login(self.superuser)
|
||||
response = self.client.get(reverse("admin:registrar_federalagency_changelist"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, self.non_feb_agency.agency)
|
||||
self.assertContains(response, self.feb_agency.agency)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_analyst_change(self):
|
||||
"""Ensure regular analysts can view/edit federal agencies list."""
|
||||
self.client.force_login(self.staffuser)
|
||||
response = self.client.get(reverse("admin:registrar_federalagency_change", args=[self.non_feb_agency.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
response = self.client.get(reverse("admin:registrar_federalagency_change", args=[self.feb_agency.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, self.feb_agency.agency)
|
||||
# test whether fields are readonly or editable
|
||||
self.assertContains(response, "id_agency")
|
||||
self.assertContains(response, "id_federal_type")
|
||||
self.assertContains(response, "id_acronym")
|
||||
self.assertContains(response, "id_is_fceb")
|
||||
self.assertNotContains(response, "closelink")
|
||||
self.assertContains(response, "Save")
|
||||
self.assertContains(response, "Delete")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_omb_analyst_change(self):
|
||||
"""Ensure OMB analysts can change FEB agencies but not others."""
|
||||
self.client.force_login(self.omb_analyst)
|
||||
response = self.client.get(reverse("admin:registrar_federalagency_change", args=[self.non_feb_agency.id]))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
response = self.client.get(reverse("admin:registrar_federalagency_change", args=[self.feb_agency.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, self.feb_agency.agency)
|
||||
# test whether fields are readonly or editable
|
||||
self.assertNotContains(response, "id_agency")
|
||||
self.assertNotContains(response, "id_federal_type")
|
||||
self.assertNotContains(response, "id_acronym")
|
||||
self.assertNotContains(response, "id_is_fceb")
|
||||
self.assertContains(response, "closelink")
|
||||
self.assertNotContains(response, "Save")
|
||||
self.assertNotContains(response, "Delete")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_superuser_change(self):
|
||||
"""Ensure superusers can change all federal agencies."""
|
||||
self.client.force_login(self.superuser)
|
||||
response = self.client.get(reverse("admin:registrar_federalagency_change", args=[self.non_feb_agency.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
response = self.client.get(reverse("admin:registrar_federalagency_change", args=[self.feb_agency.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, self.feb_agency.agency)
|
||||
# test whether fields are readonly or editable
|
||||
self.assertContains(response, "id_agency")
|
||||
self.assertContains(response, "id_federal_type")
|
||||
self.assertContains(response, "id_acronym")
|
||||
self.assertContains(response, "id_is_fceb")
|
||||
self.assertNotContains(response, "closelink")
|
||||
self.assertContains(response, "Save")
|
||||
self.assertContains(response, "Delete")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_omb_analyst_filter_feb_agencies(self):
|
||||
"""Ensure OMB analysts can apply filters and only federal agencies show."""
|
||||
self.client.force_login(self.omb_analyst)
|
||||
# in setup, created two agencies: Fake judicial agency and Fake executive agency
|
||||
# only executive agency should show up with the search for 'fake'
|
||||
response = self.client.get(
|
||||
reverse("admin:registrar_federalagency_changelist"),
|
||||
data={"q": "fake"},
|
||||
)
|
||||
self.assertNotContains(response, self.non_feb_agency.agency)
|
||||
self.assertContains(response, self.feb_agency.agency)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_has_model_description(self):
|
||||
"""Tests if this model has a model description on the table view"""
|
||||
|
@ -3471,11 +3871,12 @@ class TestFederalAgency(TestCase):
|
|||
self.assertContains(response, "Show more")
|
||||
|
||||
|
||||
class TestPublicContact(TestCase):
|
||||
class TestPublicContactAdmin(TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.site = AdminSite()
|
||||
self.superuser = create_superuser()
|
||||
self.omb_analyst = create_omb_analyst_user()
|
||||
self.admin = PublicContactAdmin(model=PublicContact, admin_site=self.site)
|
||||
self.factory = RequestFactory()
|
||||
self.client = Client(HTTP_HOST="localhost:8080")
|
||||
|
@ -3486,16 +3887,19 @@ class TestPublicContact(TestCase):
|
|||
PublicContact.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_omb_analyst_view(self):
|
||||
"""Ensure OMB analysts cannot view public contact list."""
|
||||
self.client.force_login(self.omb_analyst)
|
||||
response = self.client.get(reverse("admin:registrar_publiccontact_changelist"))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_has_model_description(self):
|
||||
"""Tests if this model has a model description on the table view"""
|
||||
p = "adminpass"
|
||||
self.client.login(username="superuser", password=p)
|
||||
response = self.client.get(
|
||||
"/admin/registrar/publiccontact/",
|
||||
follow=True,
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("admin:registrar_publiccontact_changelist"))
|
||||
# Make sure that the page is loaded correctly
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
@ -3504,11 +3908,12 @@ class TestPublicContact(TestCase):
|
|||
self.assertContains(response, "Show more")
|
||||
|
||||
|
||||
class TestTransitionDomain(TestCase):
|
||||
class TestTransitionDomainAdmin(TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.site = AdminSite()
|
||||
self.superuser = create_superuser()
|
||||
self.omb_analyst = create_omb_analyst_user()
|
||||
self.admin = TransitionDomainAdmin(model=TransitionDomain, admin_site=self.site)
|
||||
self.factory = RequestFactory()
|
||||
self.client = Client(HTTP_HOST="localhost:8080")
|
||||
|
@ -3519,15 +3924,18 @@ class TestTransitionDomain(TestCase):
|
|||
PublicContact.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_omb_analyst_view(self):
|
||||
"""Ensure OMB analysts cannot view transition domain list."""
|
||||
self.client.force_login(self.omb_analyst)
|
||||
response = self.client.get(reverse("admin:registrar_transitiondomain_changelist"))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_has_model_description(self):
|
||||
"""Tests if this model has a model description on the table view"""
|
||||
self.client.force_login(self.superuser)
|
||||
response = self.client.get(
|
||||
"/admin/registrar/transitiondomain/",
|
||||
follow=True,
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("admin:registrar_transitiondomain_changelist"))
|
||||
# Make sure that the page is loaded correctly
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
@ -3536,11 +3944,12 @@ class TestTransitionDomain(TestCase):
|
|||
self.assertContains(response, "Show more")
|
||||
|
||||
|
||||
class TestUserGroup(TestCase):
|
||||
class TestUserGroupAdmin(TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.site = AdminSite()
|
||||
self.superuser = create_superuser()
|
||||
self.omb_analyst = create_omb_analyst_user()
|
||||
self.admin = UserGroupAdmin(model=UserGroup, admin_site=self.site)
|
||||
self.factory = RequestFactory()
|
||||
self.client = Client(HTTP_HOST="localhost:8080")
|
||||
|
@ -3550,15 +3959,18 @@ class TestUserGroup(TestCase):
|
|||
super().tearDown()
|
||||
User.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_omb_analyst_view(self):
|
||||
"""Ensure OMB analysts cannot view user group list."""
|
||||
self.client.force_login(self.omb_analyst)
|
||||
response = self.client.get(reverse("admin:registrar_usergroup_changelist"))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_has_model_description(self):
|
||||
"""Tests if this model has a model description on the table view"""
|
||||
self.client.force_login(self.superuser)
|
||||
response = self.client.get(
|
||||
"/admin/registrar/usergroup/",
|
||||
follow=True,
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("admin:registrar_usergroup_changelist"))
|
||||
# Make sure that the page is loaded correctly
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
@ -3575,12 +3987,23 @@ class TestPortfolioAdmin(TestCase):
|
|||
super().setUpClass()
|
||||
cls.site = AdminSite()
|
||||
cls.superuser = create_superuser()
|
||||
cls.staffuser = create_user()
|
||||
cls.omb_analyst = create_omb_analyst_user()
|
||||
cls.admin = PortfolioAdmin(model=Portfolio, admin_site=cls.site)
|
||||
cls.factory = RequestFactory()
|
||||
|
||||
def setUp(self):
|
||||
self.client = Client(HTTP_HOST="localhost:8080")
|
||||
self.portfolio = Portfolio.objects.create(organization_name="Test Portfolio", creator=self.superuser)
|
||||
self.portfolio = Portfolio.objects.create(organization_name="Test portfolio", creator=self.superuser)
|
||||
self.feb_agency = FederalAgency.objects.create(
|
||||
agency="Test FedExec Agency", federal_type=BranchChoices.EXECUTIVE
|
||||
)
|
||||
self.feb_portfolio = Portfolio.objects.create(
|
||||
organization_name="Test FEB portfolio",
|
||||
creator=self.superuser,
|
||||
federal_agency=self.feb_agency,
|
||||
organization_type=DomainRequest.OrganizationChoices.FEDERAL,
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
Suborganization.objects.all().delete()
|
||||
|
@ -3588,8 +4011,118 @@ class TestPortfolioAdmin(TestCase):
|
|||
DomainRequest.objects.all().delete()
|
||||
Domain.objects.all().delete()
|
||||
Portfolio.objects.all().delete()
|
||||
self.feb_agency.delete()
|
||||
User.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_analyst_view(self):
|
||||
"""Ensure regular analysts can view portfolios."""
|
||||
self.client.force_login(self.staffuser)
|
||||
response = self.client.get(reverse("admin:registrar_portfolio_changelist"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, self.portfolio.organization_name)
|
||||
self.assertContains(response, self.feb_portfolio.organization_name)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_omb_analyst_view(self):
|
||||
"""Ensure OMB analysts can view FEB portfolios but not others."""
|
||||
self.client.force_login(self.omb_analyst)
|
||||
response = self.client.get(reverse("admin:registrar_portfolio_changelist"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotContains(response, self.portfolio.organization_name)
|
||||
self.assertContains(response, self.feb_portfolio.organization_name)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_superuser_view(self):
|
||||
"""Ensure superusers can view portfolios."""
|
||||
self.client.force_login(self.superuser)
|
||||
response = self.client.get(reverse("admin:registrar_portfolio_changelist"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, self.portfolio.organization_name)
|
||||
self.assertContains(response, self.feb_portfolio.organization_name)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_analyst_change(self):
|
||||
"""Ensure regular analysts can view/edit portfolios."""
|
||||
self.client.force_login(self.staffuser)
|
||||
response = self.client.get(reverse("admin:registrar_portfolio_change", args=[self.portfolio.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
response = self.client.get(reverse("admin:registrar_portfolio_change", args=[self.feb_portfolio.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, self.feb_portfolio.organization_name)
|
||||
# test whether fields are readonly or editable
|
||||
self.assertContains(response, "id_organization_name")
|
||||
self.assertContains(response, "id_notes")
|
||||
self.assertContains(response, "id_organization_type")
|
||||
self.assertContains(response, "id_state_territory")
|
||||
self.assertContains(response, "id_address_line1")
|
||||
self.assertContains(response, "id_address_line2")
|
||||
self.assertContains(response, "id_city")
|
||||
self.assertContains(response, "id_zipcode")
|
||||
self.assertContains(response, "id_urbanization")
|
||||
self.assertNotContains(response, "closelink")
|
||||
self.assertContains(response, "Save")
|
||||
self.assertContains(response, "Delete")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_omb_analyst_change(self):
|
||||
"""Ensure OMB analysts can change FEB portfolios but not others."""
|
||||
self.client.force_login(self.omb_analyst)
|
||||
response = self.client.get(reverse("admin:registrar_portfolio_change", args=[self.portfolio.id]))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
response = self.client.get(reverse("admin:registrar_portfolio_change", args=[self.feb_portfolio.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, self.feb_portfolio.organization_name)
|
||||
# test whether fields are readonly or editable
|
||||
self.assertNotContains(response, "id_organization_name")
|
||||
self.assertNotContains(response, "id_notes")
|
||||
self.assertNotContains(response, "id_organization_type")
|
||||
self.assertNotContains(response, "id_state_territory")
|
||||
self.assertNotContains(response, "id_address_line1")
|
||||
self.assertNotContains(response, "id_address_line2")
|
||||
self.assertNotContains(response, "id_city")
|
||||
self.assertNotContains(response, "id_zipcode")
|
||||
self.assertNotContains(response, "id_urbanization")
|
||||
self.assertContains(response, "closelink")
|
||||
self.assertNotContains(response, "Save")
|
||||
self.assertNotContains(response, "Delete")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_superuser_change(self):
|
||||
"""Ensure superusers can change all portfolios."""
|
||||
self.client.force_login(self.superuser)
|
||||
response = self.client.get(reverse("admin:registrar_portfolio_change", args=[self.portfolio.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
response = self.client.get(reverse("admin:registrar_portfolio_change", args=[self.feb_portfolio.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, self.feb_portfolio.organization_name)
|
||||
# test whether fields are readonly or editable
|
||||
self.assertContains(response, "id_organization_name")
|
||||
self.assertContains(response, "id_notes")
|
||||
self.assertContains(response, "id_organization_type")
|
||||
self.assertContains(response, "id_state_territory")
|
||||
self.assertContains(response, "id_address_line1")
|
||||
self.assertContains(response, "id_address_line2")
|
||||
self.assertContains(response, "id_city")
|
||||
self.assertContains(response, "id_zipcode")
|
||||
self.assertContains(response, "id_urbanization")
|
||||
self.assertNotContains(response, "closelink")
|
||||
self.assertContains(response, "Save")
|
||||
self.assertContains(response, "Delete")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_omb_analyst_filter_feb_portfolios(self):
|
||||
"""Ensure OMB analysts can apply filters and only feb portfolios show."""
|
||||
self.client.force_login(self.omb_analyst)
|
||||
# in setup, created two portfolios: Test portfolio and Test FEB portfolio
|
||||
# only executive portfolio should show up with the search for 'portfolio'
|
||||
response = self.client.get(
|
||||
reverse("admin:registrar_portfolio_changelist"),
|
||||
data={"q": "test"},
|
||||
)
|
||||
self.assertNotContains(response, self.portfolio.organization_name)
|
||||
self.assertContains(response, self.feb_portfolio.organization_name)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_created_on_display(self):
|
||||
"""Tests the custom created on which is a reskin of the created_at field"""
|
||||
|
@ -3777,6 +4310,7 @@ class TestTransferUser(WebTest):
|
|||
super().setUpClass()
|
||||
cls.site = AdminSite()
|
||||
cls.superuser = create_superuser()
|
||||
cls.omb_analyst = create_omb_analyst_user()
|
||||
cls.admin = PortfolioAdmin(model=Portfolio, admin_site=cls.site)
|
||||
cls.factory = RequestFactory()
|
||||
|
||||
|
@ -3797,6 +4331,13 @@ class TestTransferUser(WebTest):
|
|||
Portfolio.objects.all().delete()
|
||||
UserDomainRole.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_omb_analyst(self):
|
||||
"""Ensure OMB analysts cannot view transfer_user."""
|
||||
self.client.force_login(self.omb_analyst)
|
||||
response = self.client.get(reverse("transfer_user", args=[self.user1.pk]))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_transfer_user_shows_current_and_selected_user_information(self):
|
||||
"""Assert we pull the current user info and display it on the transfer page"""
|
||||
|
|
|
@ -17,14 +17,17 @@ from registrar.models import (
|
|||
Host,
|
||||
Portfolio,
|
||||
)
|
||||
from registrar.models.federal_agency import FederalAgency
|
||||
from registrar.models.public_contact import PublicContact
|
||||
from registrar.models.user_domain_role import UserDomainRole
|
||||
from registrar.utility.constants import BranchChoices
|
||||
from .common import (
|
||||
MockSESClient,
|
||||
completed_domain_request,
|
||||
less_console_noise,
|
||||
create_superuser,
|
||||
create_user,
|
||||
create_omb_analyst_user,
|
||||
create_ready_domain,
|
||||
MockEppLib,
|
||||
GenericTestHelper,
|
||||
|
@ -48,7 +51,9 @@ class TestDomainAdminAsStaff(MockEppLib):
|
|||
@classmethod
|
||||
def setUpClass(self):
|
||||
super().setUpClass()
|
||||
self.superuser = create_superuser()
|
||||
self.staffuser = create_user()
|
||||
self.omb_analyst = create_omb_analyst_user()
|
||||
self.site = AdminSite()
|
||||
self.admin = DomainAdmin(model=Domain, admin_site=self.site)
|
||||
self.factory = RequestFactory()
|
||||
|
@ -56,6 +61,24 @@ class TestDomainAdminAsStaff(MockEppLib):
|
|||
def setUp(self):
|
||||
self.client = Client(HTTP_HOST="localhost:8080")
|
||||
self.client.force_login(self.staffuser)
|
||||
self.nonfebdomain = Domain.objects.create(name="nonfebexample.com")
|
||||
self.febdomain = Domain.objects.create(name="febexample.com", state=Domain.State.READY)
|
||||
self.fed_agency = FederalAgency.objects.create(
|
||||
agency="New FedExec Agency", federal_type=BranchChoices.EXECUTIVE
|
||||
)
|
||||
self.portfolio = Portfolio.objects.create(
|
||||
organization_name="new portfolio",
|
||||
organization_type=DomainRequest.OrganizationChoices.FEDERAL,
|
||||
federal_agency=self.fed_agency,
|
||||
creator=self.staffuser,
|
||||
)
|
||||
self.domain_info = DomainInformation.objects.create(
|
||||
domain=self.febdomain, portfolio=self.portfolio, creator=self.staffuser
|
||||
)
|
||||
self.nonfebportfolio = Portfolio.objects.create(
|
||||
organization_name="non feb portfolio",
|
||||
creator=self.staffuser,
|
||||
)
|
||||
super().setUp()
|
||||
|
||||
def tearDown(self):
|
||||
|
@ -65,12 +88,134 @@ class TestDomainAdminAsStaff(MockEppLib):
|
|||
Domain.objects.all().delete()
|
||||
DomainInformation.objects.all().delete()
|
||||
DomainRequest.objects.all().delete()
|
||||
Portfolio.objects.all().delete()
|
||||
self.fed_agency.delete()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(self):
|
||||
User.objects.all().delete()
|
||||
super().tearDownClass()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_omb_analyst_view(self):
|
||||
"""Ensure OMB analysts can view domain list."""
|
||||
self.client.force_login(self.omb_analyst)
|
||||
response = self.client.get(reverse("admin:registrar_domain_changelist"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, self.febdomain.name)
|
||||
self.assertNotContains(response, self.nonfebdomain.name)
|
||||
self.assertNotContains(response, ">Import<")
|
||||
self.assertNotContains(response, ">Export<")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_omb_analyst_change(self):
|
||||
"""Ensure OMB analysts can view/edit federal executive branch domains."""
|
||||
self.client.force_login(self.omb_analyst)
|
||||
response = self.client.get(reverse("admin:registrar_domain_change", args=[self.nonfebdomain.id]))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
response = self.client.get(reverse("admin:registrar_domain_change", args=[self.febdomain.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, self.febdomain.name)
|
||||
# test portfolio dropdown
|
||||
self.assertContains(response, self.portfolio.organization_name)
|
||||
self.assertNotContains(response, self.nonfebportfolio.organization_name)
|
||||
# test buttons
|
||||
self.assertNotContains(response, "Manage domain")
|
||||
self.assertNotContains(response, "Get registry status")
|
||||
self.assertNotContains(response, "Extend expiration date")
|
||||
self.assertNotContains(response, "Remove from registry")
|
||||
self.assertContains(response, "Place hold")
|
||||
self.assertContains(response, "Save")
|
||||
self.assertNotContains(response, ">Delete<")
|
||||
# test whether fields are readonly or editable
|
||||
self.assertNotContains(response, "id_domain_info-0-portfolio")
|
||||
self.assertNotContains(response, "id_domain_info-0-sub_organization")
|
||||
self.assertNotContains(response, "id_domain_info-0-creator")
|
||||
self.assertNotContains(response, "id_domain_info-0-federal_agency")
|
||||
self.assertNotContains(response, "id_domain_info-0-about_your_organization")
|
||||
self.assertNotContains(response, "id_domain_info-0-anything_else")
|
||||
self.assertNotContains(response, "id_domain_info-0-cisa_representative_first_name")
|
||||
self.assertNotContains(response, "id_domain_info-0-cisa_representative_last_name")
|
||||
self.assertNotContains(response, "id_domain_info-0-cisa_representative_email")
|
||||
self.assertNotContains(response, "id_domain_info-0-domain_request")
|
||||
self.assertNotContains(response, "id_domain_info-0-notes")
|
||||
self.assertNotContains(response, "id_domain_info-0-senior_official")
|
||||
self.assertNotContains(response, "id_domain_info-0-organization_type")
|
||||
self.assertNotContains(response, "id_domain_info-0-state_territory")
|
||||
self.assertNotContains(response, "id_domain_info-0-address_line1")
|
||||
self.assertNotContains(response, "id_domain_info-0-address_line2")
|
||||
self.assertNotContains(response, "id_domain_info-0-city")
|
||||
self.assertNotContains(response, "id_domain_info-0-zipcode")
|
||||
self.assertNotContains(response, "id_domain_info-0-urbanization")
|
||||
self.assertNotContains(response, "id_domain_info-0-portfolio_organization_type")
|
||||
self.assertNotContains(response, "id_domain_info-0-portfolio_federal_type")
|
||||
self.assertNotContains(response, "id_domain_info-0-portfolio_organization_name")
|
||||
self.assertNotContains(response, "id_domain_info-0-portfolio_federal_agency")
|
||||
self.assertNotContains(response, "id_domain_info-0-portfolio_state_territory")
|
||||
self.assertNotContains(response, "id_domain_info-0-portfolio_address_line1")
|
||||
self.assertNotContains(response, "id_domain_info-0-portfolio_address_line2")
|
||||
self.assertNotContains(response, "id_domain_info-0-portfolio_city")
|
||||
self.assertNotContains(response, "id_domain_info-0-portfolio_zipcode")
|
||||
self.assertNotContains(response, "id_domain_info-0-portfolio_urbanization")
|
||||
self.assertNotContains(response, "id_domain_info-0-organization_type")
|
||||
self.assertNotContains(response, "id_domain_info-0-federal_type")
|
||||
self.assertNotContains(response, "id_domain_info-0-federal_agency")
|
||||
self.assertNotContains(response, "id_domain_info-0-tribe_name")
|
||||
self.assertNotContains(response, "id_domain_info-0-federally_recognized_tribe")
|
||||
self.assertNotContains(response, "id_domain_info-0-state_recognized_tribe")
|
||||
self.assertNotContains(response, "id_domain_info-0-about_your_organization")
|
||||
self.assertNotContains(response, "id_domain_info-0-portfolio")
|
||||
self.assertNotContains(response, "id_domain_info-0-sub_organization")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_superuser_change(self):
|
||||
"""Ensure super user can view/edit all domains."""
|
||||
self.client.force_login(self.superuser)
|
||||
response = self.client.get(reverse("admin:registrar_domain_change", args=[self.nonfebdomain.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
response = self.client.get(reverse("admin:registrar_domain_change", args=[self.febdomain.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, self.febdomain.name)
|
||||
# test portfolio dropdown
|
||||
self.assertContains(response, self.portfolio.organization_name)
|
||||
# test buttons
|
||||
self.assertContains(response, "Manage domain")
|
||||
self.assertContains(response, "Get registry status")
|
||||
self.assertContains(response, "Extend expiration date")
|
||||
self.assertContains(response, "Remove from registry")
|
||||
self.assertContains(response, "Place hold")
|
||||
self.assertContains(response, "Save")
|
||||
self.assertContains(response, ">Delete<")
|
||||
# test whether fields are readonly or editable
|
||||
self.assertContains(response, "id_domain_info-0-portfolio")
|
||||
self.assertContains(response, "id_domain_info-0-sub_organization")
|
||||
self.assertContains(response, "id_domain_info-0-creator")
|
||||
self.assertContains(response, "id_domain_info-0-federal_agency")
|
||||
self.assertContains(response, "id_domain_info-0-about_your_organization")
|
||||
self.assertContains(response, "id_domain_info-0-anything_else")
|
||||
self.assertContains(response, "id_domain_info-0-cisa_representative_first_name")
|
||||
self.assertContains(response, "id_domain_info-0-cisa_representative_last_name")
|
||||
self.assertContains(response, "id_domain_info-0-cisa_representative_email")
|
||||
self.assertContains(response, "id_domain_info-0-domain_request")
|
||||
self.assertContains(response, "id_domain_info-0-notes")
|
||||
self.assertContains(response, "id_domain_info-0-senior_official")
|
||||
self.assertContains(response, "id_domain_info-0-organization_type")
|
||||
self.assertContains(response, "id_domain_info-0-state_territory")
|
||||
self.assertContains(response, "id_domain_info-0-address_line1")
|
||||
self.assertContains(response, "id_domain_info-0-address_line2")
|
||||
self.assertContains(response, "id_domain_info-0-city")
|
||||
self.assertContains(response, "id_domain_info-0-zipcode")
|
||||
self.assertContains(response, "id_domain_info-0-urbanization")
|
||||
self.assertContains(response, "id_domain_info-0-organization_type")
|
||||
self.assertContains(response, "id_domain_info-0-federal_type")
|
||||
self.assertContains(response, "id_domain_info-0-federal_agency")
|
||||
self.assertContains(response, "id_domain_info-0-tribe_name")
|
||||
self.assertContains(response, "id_domain_info-0-federally_recognized_tribe")
|
||||
self.assertContains(response, "id_domain_info-0-state_recognized_tribe")
|
||||
self.assertContains(response, "id_domain_info-0-about_your_organization")
|
||||
self.assertContains(response, "id_domain_info-0-portfolio")
|
||||
self.assertContains(response, "id_domain_info-0-sub_organization")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_staff_can_see_cisa_region_federal(self):
|
||||
"""Tests if staff can see CISA Region: N/A"""
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
from datetime import datetime
|
||||
from django.forms import ValidationError
|
||||
from django.utils import timezone
|
||||
from registrar.models.federal_agency import FederalAgency
|
||||
from registrar.utility.constants import BranchChoices
|
||||
from waffle.testutils import override_flag
|
||||
import re
|
||||
from django.test import RequestFactory, Client, TestCase, override_settings
|
||||
|
@ -37,6 +39,7 @@ from .common import (
|
|||
less_console_noise,
|
||||
create_superuser,
|
||||
create_user,
|
||||
create_omb_analyst_user,
|
||||
multiple_unalphabetical_domain_objects,
|
||||
MockEppLib,
|
||||
GenericTestHelper,
|
||||
|
@ -69,6 +72,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
self.admin = DomainRequestAdmin(model=DomainRequest, admin_site=self.site)
|
||||
self.superuser = create_superuser()
|
||||
self.staffuser = create_user()
|
||||
self.ombanalyst = create_omb_analyst_user()
|
||||
self.client = Client(HTTP_HOST="localhost:8080")
|
||||
self.test_helper = GenericTestHelper(
|
||||
factory=self.factory,
|
||||
|
@ -81,6 +85,12 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
allowed_emails = [AllowedEmail(email="mayor@igorville.gov"), AllowedEmail(email="help@get.gov")]
|
||||
AllowedEmail.objects.bulk_create(allowed_emails)
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.fed_agency = FederalAgency.objects.create(
|
||||
agency="New FedExec Agency", federal_type=BranchChoices.EXECUTIVE
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
super().tearDown()
|
||||
Host.objects.all().delete()
|
||||
|
@ -93,6 +103,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
SeniorOfficial.objects.all().delete()
|
||||
Suborganization.objects.all().delete()
|
||||
Portfolio.objects.all().delete()
|
||||
self.fed_agency.delete()
|
||||
self.mock_client.EMAILS_SENT.clear()
|
||||
|
||||
@classmethod
|
||||
|
@ -101,6 +112,71 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
User.objects.all().delete()
|
||||
AllowedEmail.objects.all().delete()
|
||||
|
||||
@override_flag("organization_feature", active=True)
|
||||
@less_console_noise_decorator
|
||||
def test_omb_analyst_view(self):
|
||||
"""Ensure OMB analysts can view domain request list."""
|
||||
febportfolio = Portfolio.objects.create(
|
||||
organization_name="new portfolio",
|
||||
organization_type=DomainRequest.OrganizationChoices.FEDERAL,
|
||||
federal_agency=self.fed_agency,
|
||||
creator=self.ombanalyst,
|
||||
)
|
||||
nonfebportfolio = Portfolio.objects.create(
|
||||
organization_name="non feb portfolio",
|
||||
creator=self.ombanalyst,
|
||||
)
|
||||
nonfebdomainrequest = completed_domain_request(
|
||||
name="test1234nonfeb.gov",
|
||||
portfolio=nonfebportfolio,
|
||||
status=DomainRequest.DomainRequestStatus.SUBMITTED,
|
||||
)
|
||||
febdomainrequest = completed_domain_request(
|
||||
name="test1234feb.gov",
|
||||
portfolio=febportfolio,
|
||||
status=DomainRequest.DomainRequestStatus.SUBMITTED,
|
||||
)
|
||||
self.client.force_login(self.ombanalyst)
|
||||
response = self.client.get(reverse("admin:registrar_domainrequest_changelist"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, febdomainrequest.requested_domain.name)
|
||||
self.assertNotContains(response, nonfebdomainrequest.requested_domain.name)
|
||||
self.assertNotContains(response, ">Import<")
|
||||
self.assertNotContains(response, ">Export<")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_omb_analyst_change(self):
|
||||
"""Ensure OMB analysts can view/edit federal executive branch domain requests."""
|
||||
self.client.force_login(self.ombanalyst)
|
||||
febportfolio = Portfolio.objects.create(
|
||||
organization_name="new portfolio",
|
||||
organization_type=DomainRequest.OrganizationChoices.FEDERAL,
|
||||
federal_agency=self.fed_agency,
|
||||
creator=self.ombanalyst,
|
||||
)
|
||||
nonfebportfolio = Portfolio.objects.create(
|
||||
organization_name="non feb portfolio",
|
||||
creator=self.ombanalyst,
|
||||
)
|
||||
nonfebdomainrequest = completed_domain_request(
|
||||
name="test1234nonfeb.gov",
|
||||
portfolio=nonfebportfolio,
|
||||
status=DomainRequest.DomainRequestStatus.SUBMITTED,
|
||||
)
|
||||
febdomainrequest = completed_domain_request(
|
||||
name="test1234feb.gov",
|
||||
portfolio=febportfolio,
|
||||
status=DomainRequest.DomainRequestStatus.SUBMITTED,
|
||||
)
|
||||
response = self.client.get(reverse("admin:registrar_domainrequest_change", args=[nonfebdomainrequest.id]))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
response = self.client.get(reverse("admin:registrar_domainrequest_change", args=[febdomainrequest.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, febdomainrequest.requested_domain.name)
|
||||
# test buttons
|
||||
self.assertContains(response, "Save")
|
||||
self.assertNotContains(response, ">Delete<")
|
||||
|
||||
@override_flag("organization_feature", active=True)
|
||||
@less_console_noise_decorator
|
||||
def test_clean_validates_duplicate_suborganization(self):
|
||||
|
@ -1985,6 +2061,10 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
"feb_naming_requirements",
|
||||
"feb_naming_requirements_details",
|
||||
"feb_purpose_choice",
|
||||
"working_with_eop",
|
||||
"eop_stakeholder_first_name",
|
||||
"eop_stakeholder_last_name",
|
||||
"eop_stakeholder_email",
|
||||
"purpose",
|
||||
"has_timeframe",
|
||||
"time_frame_details",
|
||||
|
@ -2073,6 +2153,86 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
|
||||
self.assertEqual(readonly_fields, expected_fields)
|
||||
|
||||
def test_readonly_fields_for_omb_analyst(self):
|
||||
with less_console_noise():
|
||||
request = self.factory.get("/") # Use the correct method and path
|
||||
request.user = self.ombanalyst
|
||||
|
||||
readonly_fields = self.admin.get_readonly_fields(request)
|
||||
|
||||
expected_fields = [
|
||||
"portfolio_senior_official",
|
||||
"portfolio_organization_type",
|
||||
"portfolio_federal_type",
|
||||
"portfolio_organization_name",
|
||||
"portfolio_federal_agency",
|
||||
"portfolio_state_territory",
|
||||
"portfolio_address_line1",
|
||||
"portfolio_address_line2",
|
||||
"portfolio_city",
|
||||
"portfolio_zipcode",
|
||||
"portfolio_urbanization",
|
||||
"other_contacts",
|
||||
"current_websites",
|
||||
"alternative_domains",
|
||||
"is_election_board",
|
||||
"status_history",
|
||||
"federal_agency",
|
||||
"creator",
|
||||
"about_your_organization",
|
||||
"requested_domain",
|
||||
"approved_domain",
|
||||
"alternative_domains",
|
||||
"purpose",
|
||||
"no_other_contacts_rationale",
|
||||
"anything_else",
|
||||
"is_policy_acknowledged",
|
||||
"cisa_representative_first_name",
|
||||
"cisa_representative_last_name",
|
||||
"cisa_representative_email",
|
||||
"status",
|
||||
"investigator",
|
||||
"notes",
|
||||
"senior_official",
|
||||
"organization_type",
|
||||
"organization_name",
|
||||
"state_territory",
|
||||
"address_line1",
|
||||
"address_line2",
|
||||
"city",
|
||||
"zipcode",
|
||||
"urbanization",
|
||||
"portfolio_organization_type",
|
||||
"portfolio_federal_type",
|
||||
"portfolio_organization_name",
|
||||
"portfolio_federal_agency",
|
||||
"portfolio_state_territory",
|
||||
"portfolio_address_line1",
|
||||
"portfolio_address_line2",
|
||||
"portfolio_city",
|
||||
"portfolio_zipcode",
|
||||
"portfolio_urbanization",
|
||||
"is_election_board",
|
||||
"organization_type",
|
||||
"federal_type",
|
||||
"federal_agency",
|
||||
"tribe_name",
|
||||
"federally_recognized_tribe",
|
||||
"state_recognized_tribe",
|
||||
"about_your_organization",
|
||||
"rejection_reason",
|
||||
"rejection_reason_email",
|
||||
"action_needed_reason",
|
||||
"action_needed_reason_email",
|
||||
"portfolio",
|
||||
"sub_organization",
|
||||
"requested_suborganization",
|
||||
"suborganization_city",
|
||||
"suborganization_state_territory",
|
||||
]
|
||||
|
||||
self.assertEqual(readonly_fields, expected_fields)
|
||||
|
||||
def test_saving_when_restricted_creator(self):
|
||||
with less_console_noise():
|
||||
# Create an instance of the model
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from unittest.mock import patch, MagicMock, ANY
|
||||
from datetime import date
|
||||
from registrar.models.domain import Domain
|
||||
from registrar.models.portfolio import Portfolio
|
||||
|
@ -13,13 +13,15 @@ from registrar.utility.email_invitations import (
|
|||
_send_portfolio_admin_addition_emails_to_portfolio_admins,
|
||||
_send_portfolio_admin_removal_emails_to_portfolio_admins,
|
||||
send_domain_invitation_email,
|
||||
send_emails_to_domain_managers,
|
||||
_send_domain_invitation_update_emails_to_domain_managers,
|
||||
send_domain_manager_removal_emails_to_domain_managers,
|
||||
send_portfolio_admin_addition_emails,
|
||||
send_portfolio_admin_removal_emails,
|
||||
send_portfolio_invitation_email,
|
||||
send_portfolio_invitation_remove_email,
|
||||
send_portfolio_member_permission_remove_email,
|
||||
send_portfolio_member_permission_update_email,
|
||||
send_portfolio_update_emails_to_portfolio_admins,
|
||||
)
|
||||
|
||||
from api.tests.common import less_console_noise_decorator
|
||||
|
@ -33,7 +35,7 @@ class DomainInvitationEmail(unittest.TestCase):
|
|||
@patch("registrar.utility.email_invitations.UserDomainRole.objects.filter")
|
||||
@patch("registrar.utility.email_invitations._validate_invitation")
|
||||
@patch("registrar.utility.email_invitations._get_requestor_email")
|
||||
@patch("registrar.utility.email_invitations.send_invitation_email")
|
||||
@patch("registrar.utility.email_invitations._send_domain_invitation_email")
|
||||
@patch("registrar.utility.email_invitations._normalize_domains")
|
||||
def test_send_domain_invitation_email(
|
||||
self,
|
||||
|
@ -98,7 +100,7 @@ class DomainInvitationEmail(unittest.TestCase):
|
|||
@patch("registrar.utility.email_invitations.UserDomainRole.objects.filter")
|
||||
@patch("registrar.utility.email_invitations._validate_invitation")
|
||||
@patch("registrar.utility.email_invitations._get_requestor_email")
|
||||
@patch("registrar.utility.email_invitations.send_invitation_email")
|
||||
@patch("registrar.utility.email_invitations._send_domain_invitation_email")
|
||||
@patch("registrar.utility.email_invitations._normalize_domains")
|
||||
def test_send_domain_invitation_email_multiple_domains(
|
||||
self,
|
||||
|
@ -234,7 +236,7 @@ class DomainInvitationEmail(unittest.TestCase):
|
|||
@less_console_noise_decorator
|
||||
@patch("registrar.utility.email_invitations._validate_invitation")
|
||||
@patch("registrar.utility.email_invitations._get_requestor_email")
|
||||
@patch("registrar.utility.email_invitations.send_invitation_email")
|
||||
@patch("registrar.utility.email_invitations._send_domain_invitation_email")
|
||||
@patch("registrar.utility.email_invitations._normalize_domains")
|
||||
def test_send_domain_invitation_email_raises_sending_email_exception(
|
||||
self,
|
||||
|
@ -281,10 +283,10 @@ class DomainInvitationEmail(unittest.TestCase):
|
|||
self.assertEqual(str(context.exception), "Error sending email")
|
||||
|
||||
@less_console_noise_decorator
|
||||
@patch("registrar.utility.email_invitations.send_emails_to_domain_managers")
|
||||
@patch("registrar.utility.email_invitations._send_domain_invitation_update_emails_to_domain_managers")
|
||||
@patch("registrar.utility.email_invitations._validate_invitation")
|
||||
@patch("registrar.utility.email_invitations._get_requestor_email")
|
||||
@patch("registrar.utility.email_invitations.send_invitation_email")
|
||||
@patch("registrar.utility.email_invitations._send_domain_invitation_email")
|
||||
@patch("registrar.utility.email_invitations._normalize_domains")
|
||||
def test_send_domain_invitation_email_manager_emails_send_mail_exception(
|
||||
self,
|
||||
|
@ -295,7 +297,7 @@ class DomainInvitationEmail(unittest.TestCase):
|
|||
mock_send_domain_manager_emails,
|
||||
):
|
||||
"""Test sending domain invitation email for one domain and assert exception
|
||||
when send_emails_to_domain_managers fails.
|
||||
when _send_domain_invitation_update_emails_to_domain_managers fails.
|
||||
"""
|
||||
# Setup
|
||||
mock_domain = MagicMock(name="domain1")
|
||||
|
@ -354,7 +356,7 @@ class DomainInvitationEmail(unittest.TestCase):
|
|||
mock_send_templated_email.return_value = None # No exception means success
|
||||
|
||||
# Call function
|
||||
result = send_emails_to_domain_managers(mock_email, mock_requestor_email, mock_domain)
|
||||
result = _send_domain_invitation_update_emails_to_domain_managers(mock_email, mock_requestor_email, mock_domain)
|
||||
|
||||
# Assertions
|
||||
self.assertTrue(result) # All emails should be successfully sent
|
||||
|
@ -394,7 +396,7 @@ class DomainInvitationEmail(unittest.TestCase):
|
|||
mock_send_templated_email.side_effect = EmailSendingError("Email sending failed")
|
||||
|
||||
# Call function
|
||||
result = send_emails_to_domain_managers(mock_email, mock_requestor_email, mock_domain)
|
||||
result = _send_domain_invitation_update_emails_to_domain_managers(mock_email, mock_requestor_email, mock_domain)
|
||||
|
||||
# Assertions
|
||||
self.assertFalse(result) # The result should be False as email sending failed
|
||||
|
@ -426,7 +428,7 @@ class DomainInvitationEmail(unittest.TestCase):
|
|||
mock_filter.return_value = []
|
||||
|
||||
# Call function
|
||||
result = send_emails_to_domain_managers(mock_email, mock_requestor_email, mock_domain)
|
||||
result = _send_domain_invitation_update_emails_to_domain_managers(mock_email, mock_requestor_email, mock_domain)
|
||||
|
||||
# Assertions
|
||||
self.assertTrue(result) # No emails to send, so it should return True
|
||||
|
@ -457,7 +459,7 @@ class DomainInvitationEmail(unittest.TestCase):
|
|||
mock_send_templated_email.side_effect = [None, EmailSendingError("Failed to send email")]
|
||||
|
||||
# Call function
|
||||
result = send_emails_to_domain_managers(mock_email, mock_requestor_email, mock_domain)
|
||||
result = _send_domain_invitation_update_emails_to_domain_managers(mock_email, mock_requestor_email, mock_domain)
|
||||
|
||||
# Assertions
|
||||
self.assertFalse(result) # One email failed, so result should be False
|
||||
|
@ -1112,3 +1114,208 @@ class TestSendPortfolioInvitationRemoveEmail(unittest.TestCase):
|
|||
|
||||
# Assertions
|
||||
mock_logger.warning.assert_not_called() # Function should fail before logging email failure
|
||||
|
||||
|
||||
class SendDomainManagerRemovalEmailsToManagersTests(unittest.TestCase):
|
||||
"""Unit tests for send_domain_manager_removal_emails_to_domain_managers function."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.email = "removed.admin@example.com"
|
||||
self.requestor_email = "requestor@example.com"
|
||||
self.domain = MagicMock(spec=Domain)
|
||||
self.domain.name = "Test Domain"
|
||||
|
||||
# Mock domain manager users
|
||||
self.manager_user1 = MagicMock(spec=User)
|
||||
self.manager_user1.email = "manager1@example.com"
|
||||
|
||||
self.manager_user2 = MagicMock(spec=User)
|
||||
self.manager_user2.email = "manager2@example.com"
|
||||
|
||||
self.domain_manager1 = MagicMock(spec=UserDomainRole)
|
||||
self.domain_manager1.user = self.manager_user1
|
||||
|
||||
self.domain_manager2 = MagicMock(spec=UserDomainRole)
|
||||
self.domain_manager2.user = self.manager_user2
|
||||
|
||||
@less_console_noise_decorator
|
||||
@patch("registrar.utility.email_invitations.send_templated_email")
|
||||
@patch("registrar.utility.email_invitations.UserDomainRole.objects.filter")
|
||||
def test_send_email_success(self, mock_filter, mock_send_templated_email):
|
||||
"""Test successful sending of domain manager removal emails."""
|
||||
mock_filter.return_value.exclude.return_value = [self.domain_manager1]
|
||||
mock_send_templated_email.return_value = None # No exception means success
|
||||
|
||||
result = send_domain_manager_removal_emails_to_domain_managers(
|
||||
removed_by_user=self.manager_user1,
|
||||
manager_removed=self.manager_user2,
|
||||
manager_removed_email=self.manager_user2.email,
|
||||
domain=self.domain,
|
||||
)
|
||||
|
||||
mock_filter.assert_called_once_with(domain=self.domain)
|
||||
mock_send_templated_email.assert_any_call(
|
||||
"emails/domain_manager_deleted_notification.txt",
|
||||
"emails/domain_manager_deleted_notification_subject.txt",
|
||||
to_address=self.manager_user1.email,
|
||||
context={
|
||||
"domain": self.domain,
|
||||
"removed_by": self.manager_user1,
|
||||
"manager_removed_email": self.manager_user2.email,
|
||||
"date": date.today(),
|
||||
},
|
||||
)
|
||||
self.assertTrue(result)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@patch("registrar.utility.email_invitations.send_templated_email")
|
||||
@patch("registrar.utility.email_invitations.UserDomainRole.objects.filter")
|
||||
def test_send_email_success_when_no_user(self, mock_filter, mock_send_templated_email):
|
||||
"""Test successful sending of domain manager removal emails."""
|
||||
mock_filter.return_value = [self.domain_manager1, self.domain_manager2]
|
||||
mock_send_templated_email.return_value = None # No exception means success
|
||||
|
||||
result = send_domain_manager_removal_emails_to_domain_managers(
|
||||
removed_by_user=self.manager_user1,
|
||||
manager_removed=None,
|
||||
manager_removed_email=self.manager_user2.email,
|
||||
domain=self.domain,
|
||||
)
|
||||
|
||||
mock_filter.assert_called_once_with(domain=self.domain)
|
||||
mock_send_templated_email.assert_any_call(
|
||||
"emails/domain_manager_deleted_notification.txt",
|
||||
"emails/domain_manager_deleted_notification_subject.txt",
|
||||
to_address=self.manager_user1.email,
|
||||
context={
|
||||
"domain": self.domain,
|
||||
"removed_by": self.manager_user1,
|
||||
"manager_removed_email": self.manager_user2.email,
|
||||
"date": date.today(),
|
||||
},
|
||||
)
|
||||
mock_send_templated_email.assert_any_call(
|
||||
"emails/domain_manager_deleted_notification.txt",
|
||||
"emails/domain_manager_deleted_notification_subject.txt",
|
||||
to_address=self.manager_user2.email,
|
||||
context={
|
||||
"domain": self.domain,
|
||||
"removed_by": self.manager_user1,
|
||||
"manager_removed_email": self.manager_user2.email,
|
||||
"date": date.today(),
|
||||
},
|
||||
)
|
||||
self.assertTrue(result)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@patch("registrar.utility.email_invitations.send_templated_email", side_effect=EmailSendingError)
|
||||
@patch("registrar.utility.email_invitations.UserDomainRole.objects.filter")
|
||||
def test_send_email_failure(self, mock_filter, mock_send_templated_email):
|
||||
"""Test handling of failure in sending admin removal emails."""
|
||||
mock_filter.return_value.exclude.return_value = [self.domain_manager1, self.domain_manager2]
|
||||
|
||||
result = send_domain_manager_removal_emails_to_domain_managers(
|
||||
removed_by_user=self.manager_user1,
|
||||
manager_removed=self.manager_user2,
|
||||
manager_removed_email=self.manager_user2.email,
|
||||
domain=self.domain,
|
||||
)
|
||||
|
||||
self.assertFalse(result)
|
||||
mock_filter.assert_called_once_with(domain=self.domain)
|
||||
mock_send_templated_email.assert_any_call(
|
||||
"emails/domain_manager_deleted_notification.txt",
|
||||
"emails/domain_manager_deleted_notification_subject.txt",
|
||||
to_address=self.manager_user1.email,
|
||||
context={
|
||||
"domain": self.domain,
|
||||
"removed_by": self.manager_user1,
|
||||
"manager_removed_email": self.manager_user2.email,
|
||||
"date": date.today(),
|
||||
},
|
||||
)
|
||||
mock_send_templated_email.assert_any_call(
|
||||
"emails/domain_manager_deleted_notification.txt",
|
||||
"emails/domain_manager_deleted_notification_subject.txt",
|
||||
to_address=self.manager_user2.email,
|
||||
context={
|
||||
"domain": self.domain,
|
||||
"removed_by": self.manager_user1,
|
||||
"manager_removed_email": self.manager_user2.email,
|
||||
"date": date.today(),
|
||||
},
|
||||
)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@patch("registrar.utility.email_invitations.UserDomainRole.objects.filter")
|
||||
def test_no_managers_to_notify(self, mock_filter):
|
||||
"""Test case where there are no domain managers to notify."""
|
||||
mock_filter.return_value.exclude.return_value = [] # No managers
|
||||
|
||||
result = send_domain_manager_removal_emails_to_domain_managers(
|
||||
removed_by_user=self.manager_user1,
|
||||
manager_removed=self.manager_user2,
|
||||
manager_removed_email=self.manager_user2.email,
|
||||
domain=self.domain,
|
||||
)
|
||||
|
||||
self.assertTrue(result) # No emails sent, but also no failures
|
||||
mock_filter.assert_called_once_with(domain=self.domain)
|
||||
|
||||
|
||||
class TestSendPortfolioOrganizationUpdateEmail(unittest.TestCase):
|
||||
"""Unit tests for send_portfolio_update_emails_to_portfolio_admins function."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.email = "removed.admin@example.com"
|
||||
self.requestor_email = "requestor@example.com"
|
||||
self.portfolio = MagicMock(spec=Portfolio, name="Portfolio")
|
||||
self.portfolio.organization_name = "Test Organization"
|
||||
|
||||
# Mock portfolio admin users
|
||||
self.admin_user1 = MagicMock(spec=User)
|
||||
self.admin_user1.email = "admin1@example.com"
|
||||
|
||||
self.admin_user2 = MagicMock(spec=User)
|
||||
self.admin_user2.email = "admin2@example.com"
|
||||
|
||||
self.portfolio_admin1 = MagicMock(spec=UserPortfolioPermission)
|
||||
self.portfolio_admin1.user = self.admin_user1
|
||||
self.portfolio_admin1.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
|
||||
self.portfolio_admin2 = MagicMock(spec=UserPortfolioPermission)
|
||||
self.portfolio_admin2.user = self.admin_user2
|
||||
self.portfolio_admin2.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
|
||||
@patch("registrar.utility.email_invitations.send_templated_email")
|
||||
@patch("registrar.utility.email_invitations.UserPortfolioPermission.objects.filter")
|
||||
def test_send_portfolio_update_emails_to_portfolio_admins(self, mock_filter, mock_send_templated_email):
|
||||
"""Test send_portfolio_update_emails_to_portfolio_admins sends templated email."""
|
||||
# Mock data
|
||||
editor = self.admin_user1
|
||||
updated_page = "Organization"
|
||||
|
||||
mock_filter.return_value = [self.portfolio_admin1, self.portfolio_admin2]
|
||||
mock_send_templated_email.return_value = None # No exception means success
|
||||
|
||||
# Call function
|
||||
result = send_portfolio_update_emails_to_portfolio_admins(editor, self.portfolio, updated_page)
|
||||
|
||||
mock_filter.assert_called_once_with(
|
||||
portfolio=self.portfolio, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
mock_send_templated_email.assert_any_call(
|
||||
"emails/portfolio_org_update_notification.txt",
|
||||
"emails/portfolio_org_update_notification_subject.txt",
|
||||
to_address=self.admin_user1.email,
|
||||
context=ANY,
|
||||
)
|
||||
mock_send_templated_email.assert_any_call(
|
||||
"emails/portfolio_org_update_notification.txt",
|
||||
"emails/portfolio_org_update_notification_subject.txt",
|
||||
to_address=self.admin_user2.email,
|
||||
context=ANY,
|
||||
)
|
||||
self.assertTrue(result)
|
||||
|
|
48
src/registrar/tests/test_middleware_logging.py
Normal file
48
src/registrar/tests/test_middleware_logging.py
Normal file
|
@ -0,0 +1,48 @@
|
|||
from django.test import TestCase, RequestFactory, override_settings
|
||||
from unittest.mock import patch, MagicMock
|
||||
from django.contrib.auth.models import AnonymousUser, User
|
||||
|
||||
from registrar.registrar_middleware import RequestLoggingMiddleware
|
||||
|
||||
|
||||
class RequestLoggingMiddlewareTest(TestCase):
|
||||
"""Test 'our' middleware logging."""
|
||||
|
||||
def setUp(self):
|
||||
self.factory = RequestFactory()
|
||||
self.get_response_mock = MagicMock()
|
||||
self.middleware = RequestLoggingMiddleware(self.get_response_mock)
|
||||
|
||||
@override_settings(IS_PRODUCTION=True) # Scopes change to this test only
|
||||
@patch("logging.Logger.info")
|
||||
def test_logging_enabled_in_production(self, mock_logger):
|
||||
"""Test that logging occurs when IS_PRODUCTION is True"""
|
||||
request = self.factory.get("/test-path", **{"REMOTE_ADDR": "Unknown IP"}) # Override IP
|
||||
request.user = User(username="testuser", email="testuser@example.com")
|
||||
|
||||
self.middleware(request) # Call middleware
|
||||
|
||||
mock_logger.assert_called_once_with(
|
||||
"Router log | User: testuser@example.com | IP: Unknown IP | Path: /test-path"
|
||||
)
|
||||
|
||||
@patch("logging.Logger.info")
|
||||
def test_logging_disabled_in_non_production(self, mock_logger):
|
||||
"""Test that logging does not occur when IS_PRODUCTION is False"""
|
||||
request = self.factory.get("/test-path")
|
||||
request.user = User(username="testuser", email="testuser@example.com")
|
||||
|
||||
self.middleware(request) # Call middleware
|
||||
|
||||
mock_logger.assert_not_called() # Ensure no logs are generated
|
||||
|
||||
@override_settings(IS_PRODUCTION=True) # Scopes change to this test only
|
||||
@patch("logging.Logger.info")
|
||||
def test_logging_anonymous_user(self, mock_logger):
|
||||
"""Test logging for an anonymous user"""
|
||||
request = self.factory.get("/anonymous-path", **{"REMOTE_ADDR": "Unknown IP"}) # Override IP
|
||||
request.user = AnonymousUser() # Simulate an anonymous user
|
||||
|
||||
self.middleware(request) # Call middleware
|
||||
|
||||
mock_logger.assert_called_once_with("Router log | User: Anonymous | IP: Unknown IP | Path: /anonymous-path")
|
|
@ -771,6 +771,9 @@ class TestRegistrantContacts(MockEppLib):
|
|||
self.domain, _ = Domain.objects.get_or_create(name="security.gov")
|
||||
# Creates a domain with an associated contact
|
||||
self.domain_contact, _ = Domain.objects.get_or_create(name="freeman.gov")
|
||||
DF = common.DiscloseField
|
||||
excluded_disclose_fields = {DF.NOTIFY_EMAIL, DF.VAT, DF.IDENT}
|
||||
self.all_disclose_fields = {field for field in DF} - excluded_disclose_fields
|
||||
|
||||
def tearDown(self):
|
||||
super().tearDown()
|
||||
|
@ -807,7 +810,9 @@ class TestRegistrantContacts(MockEppLib):
|
|||
contact_type=PublicContact.ContactTypeChoices.SECURITY,
|
||||
).registry_id
|
||||
expectedSecContact.registry_id = id
|
||||
expectedCreateCommand = self._convertPublicContactToEpp(expectedSecContact, disclose_email=False)
|
||||
expectedCreateCommand = self._convertPublicContactToEpp(
|
||||
expectedSecContact, disclose=False, disclose_fields=self.all_disclose_fields
|
||||
)
|
||||
expectedUpdateDomain = commands.UpdateDomain(
|
||||
name=self.domain.name,
|
||||
add=[common.DomainContact(contact=expectedSecContact.registry_id, type="security")],
|
||||
|
@ -837,7 +842,7 @@ class TestRegistrantContacts(MockEppLib):
|
|||
# self.domain.security_contact=expectedSecContact
|
||||
expectedSecContact.save()
|
||||
# no longer the default email it should be disclosed
|
||||
expectedCreateCommand = self._convertPublicContactToEpp(expectedSecContact, disclose_email=True)
|
||||
expectedCreateCommand = self._convertPublicContactToEpp(expectedSecContact, disclose=False)
|
||||
expectedUpdateDomain = commands.UpdateDomain(
|
||||
name=self.domain.name,
|
||||
add=[common.DomainContact(contact=expectedSecContact.registry_id, type="security")],
|
||||
|
@ -862,7 +867,9 @@ class TestRegistrantContacts(MockEppLib):
|
|||
security_contact.registry_id = "fail"
|
||||
security_contact.save()
|
||||
self.domain.security_contact = security_contact
|
||||
expectedCreateCommand = self._convertPublicContactToEpp(security_contact, disclose_email=False)
|
||||
expectedCreateCommand = self._convertPublicContactToEpp(
|
||||
security_contact, disclose=False, disclose_fields=self.all_disclose_fields
|
||||
)
|
||||
expectedUpdateDomain = commands.UpdateDomain(
|
||||
name=self.domain.name,
|
||||
add=[common.DomainContact(contact=security_contact.registry_id, type="security")],
|
||||
|
@ -895,7 +902,7 @@ class TestRegistrantContacts(MockEppLib):
|
|||
new_contact.registry_id = "fail"
|
||||
new_contact.email = ""
|
||||
self.domain.security_contact = new_contact
|
||||
firstCreateContactCall = self._convertPublicContactToEpp(old_contact, disclose_email=True)
|
||||
firstCreateContactCall = self._convertPublicContactToEpp(old_contact, disclose=False)
|
||||
updateDomainAddCall = commands.UpdateDomain(
|
||||
name=self.domain.name,
|
||||
add=[common.DomainContact(contact=old_contact.registry_id, type="security")],
|
||||
|
@ -905,7 +912,7 @@ class TestRegistrantContacts(MockEppLib):
|
|||
PublicContact.get_default_security().email,
|
||||
)
|
||||
# this one triggers the fail
|
||||
secondCreateContact = self._convertPublicContactToEpp(new_contact, disclose_email=True)
|
||||
secondCreateContact = self._convertPublicContactToEpp(new_contact, disclose=False)
|
||||
updateDomainRemCall = commands.UpdateDomain(
|
||||
name=self.domain.name,
|
||||
rem=[common.DomainContact(contact=old_contact.registry_id, type="security")],
|
||||
|
@ -913,7 +920,9 @@ class TestRegistrantContacts(MockEppLib):
|
|||
defaultSecID = PublicContact.objects.filter(domain=self.domain).get().registry_id
|
||||
default_security = PublicContact.get_default_security()
|
||||
default_security.registry_id = defaultSecID
|
||||
createDefaultContact = self._convertPublicContactToEpp(default_security, disclose_email=False)
|
||||
createDefaultContact = self._convertPublicContactToEpp(
|
||||
default_security, disclose=False, disclose_fields=self.all_disclose_fields
|
||||
)
|
||||
updateDomainWDefault = commands.UpdateDomain(
|
||||
name=self.domain.name,
|
||||
add=[common.DomainContact(contact=defaultSecID, type="security")],
|
||||
|
@ -941,15 +950,15 @@ class TestRegistrantContacts(MockEppLib):
|
|||
security_contact.email = "originalUserEmail@gmail.com"
|
||||
security_contact.registry_id = "fail"
|
||||
security_contact.save()
|
||||
expectedCreateCommand = self._convertPublicContactToEpp(security_contact, disclose_email=True)
|
||||
expectedCreateCommand = self._convertPublicContactToEpp(security_contact, disclose=False)
|
||||
expectedUpdateDomain = commands.UpdateDomain(
|
||||
name=self.domain.name,
|
||||
add=[common.DomainContact(contact=security_contact.registry_id, type="security")],
|
||||
)
|
||||
security_contact.email = "changedEmail@email.com"
|
||||
security_contact.save()
|
||||
expectedSecondCreateCommand = self._convertPublicContactToEpp(security_contact, disclose_email=True)
|
||||
updateContact = self._convertPublicContactToEpp(security_contact, disclose_email=True, createContact=False)
|
||||
expectedSecondCreateCommand = self._convertPublicContactToEpp(security_contact, disclose=False)
|
||||
updateContact = self._convertPublicContactToEpp(security_contact, disclose=False, createContact=False)
|
||||
expected_calls = [
|
||||
call(expectedCreateCommand, cleaned=True),
|
||||
call(expectedUpdateDomain, cleaned=True),
|
||||
|
@ -1037,9 +1046,23 @@ class TestRegistrantContacts(MockEppLib):
|
|||
for contact in contacts:
|
||||
expected_contact = contact[0]
|
||||
actual_contact = contact[1]
|
||||
is_security = expected_contact.contact_type == "security"
|
||||
expectedCreateCommand = self._convertPublicContactToEpp(expected_contact, disclose_email=is_security)
|
||||
# Should only be disclosed if the type is security, as the email is valid
|
||||
if expected_contact.contact_type == PublicContact.ContactTypeChoices.SECURITY:
|
||||
disclose_fields = self.all_disclose_fields - {"email"}
|
||||
expectedCreateCommand = self._convertPublicContactToEpp(
|
||||
expected_contact, disclose=False, disclose_fields=disclose_fields
|
||||
)
|
||||
elif expected_contact.contact_type == PublicContact.ContactTypeChoices.ADMINISTRATIVE:
|
||||
disclose_fields = self.all_disclose_fields - {"name", "email", "voice", "addr"}
|
||||
expectedCreateCommand = self._convertPublicContactToEpp(
|
||||
expected_contact,
|
||||
disclose=False,
|
||||
disclose_fields=disclose_fields,
|
||||
disclose_types={"addr": "loc", "name": "loc"},
|
||||
)
|
||||
else:
|
||||
expectedCreateCommand = self._convertPublicContactToEpp(
|
||||
expected_contact, disclose=False, disclose_fields=self.all_disclose_fields
|
||||
)
|
||||
self.mockedSendFunction.assert_any_call(expectedCreateCommand, cleaned=True)
|
||||
# The emails should match on both items
|
||||
self.assertEqual(expected_contact.email, actual_contact.email)
|
||||
|
@ -1048,23 +1071,26 @@ class TestRegistrantContacts(MockEppLib):
|
|||
with less_console_noise():
|
||||
domain, _ = Domain.objects.get_or_create(name="freeman.gov")
|
||||
dummy_contact = domain.get_default_security_contact()
|
||||
test_disclose = self._convertPublicContactToEpp(dummy_contact, disclose_email=True).__dict__
|
||||
test_not_disclose = self._convertPublicContactToEpp(dummy_contact, disclose_email=False).__dict__
|
||||
test_disclose = self._convertPublicContactToEpp(dummy_contact, disclose=False).__dict__
|
||||
test_not_disclose = self._convertPublicContactToEpp(dummy_contact, disclose=False).__dict__
|
||||
# Separated for linter
|
||||
disclose_email_field = {common.DiscloseField.EMAIL}
|
||||
disclose_email_field = self.all_disclose_fields - {common.DiscloseField.EMAIL}
|
||||
DF = common.DiscloseField
|
||||
expected_disclose = {
|
||||
"auth_info": common.ContactAuthInfo(pw="2fooBAR123fooBaz"),
|
||||
"disclose": common.Disclose(flag=True, fields=disclose_email_field, types=None),
|
||||
"email": "dotgov@cisa.dhs.gov",
|
||||
"disclose": common.Disclose(
|
||||
flag=False, fields=disclose_email_field, types={DF.ADDR: "loc", DF.NAME: "loc"}
|
||||
),
|
||||
"email": "help@get.gov",
|
||||
"extensions": [],
|
||||
"fax": None,
|
||||
"id": "ThIq2NcRIDN7PauO",
|
||||
"ident": None,
|
||||
"notify_email": None,
|
||||
"postal_info": common.PostalInfo(
|
||||
name="Registry Customer Service",
|
||||
name="CSD/CB – Attn: .gov TLD",
|
||||
addr=common.ContactAddr(
|
||||
street=["4200 Wilson Blvd.", None, None],
|
||||
street=["1110 N. Glebe Rd", None, None],
|
||||
city="Arlington",
|
||||
pc="22201",
|
||||
cc="US",
|
||||
|
@ -1079,17 +1105,19 @@ class TestRegistrantContacts(MockEppLib):
|
|||
# Separated for linter
|
||||
expected_not_disclose = {
|
||||
"auth_info": common.ContactAuthInfo(pw="2fooBAR123fooBaz"),
|
||||
"disclose": common.Disclose(flag=False, fields=disclose_email_field, types=None),
|
||||
"email": "dotgov@cisa.dhs.gov",
|
||||
"disclose": common.Disclose(
|
||||
flag=False, fields=disclose_email_field, types={DF.ADDR: "loc", DF.NAME: "loc"}
|
||||
),
|
||||
"email": "help@get.gov",
|
||||
"extensions": [],
|
||||
"fax": None,
|
||||
"id": "ThrECENCHI76PGLh",
|
||||
"ident": None,
|
||||
"notify_email": None,
|
||||
"postal_info": common.PostalInfo(
|
||||
name="Registry Customer Service",
|
||||
name="CSD/CB – Attn: .gov TLD",
|
||||
addr=common.ContactAddr(
|
||||
street=["4200 Wilson Blvd.", None, None],
|
||||
street=["1110 N. Glebe Rd", None, None],
|
||||
city="Arlington",
|
||||
pc="22201",
|
||||
cc="US",
|
||||
|
@ -1107,6 +1135,39 @@ class TestRegistrantContacts(MockEppLib):
|
|||
self.assertEqual(test_disclose, expected_disclose)
|
||||
self.assertEqual(test_not_disclose, expected_not_disclose)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_convert_public_contact_with_custom_fields(self):
|
||||
"""Test converting a contact with custom disclosure fields."""
|
||||
domain, _ = Domain.objects.get_or_create(name="freeman.gov")
|
||||
dummy_contact = domain.get_default_administrative_contact()
|
||||
DF = common.DiscloseField
|
||||
|
||||
# Create contact with multiple disclosure fields
|
||||
result = self._convertPublicContactToEpp(
|
||||
dummy_contact,
|
||||
disclose=True,
|
||||
disclose_fields={DF.EMAIL, DF.VOICE, DF.ADDR},
|
||||
disclose_types={},
|
||||
)
|
||||
self.assertEqual(result.disclose.flag, True)
|
||||
self.assertEqual(result.disclose.fields, {DF.EMAIL, DF.VOICE, DF.ADDR})
|
||||
self.assertEqual(result.disclose.types, {})
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_convert_public_contact_with_empty_fields(self):
|
||||
"""Test converting a contact with empty disclosure fields."""
|
||||
domain, _ = Domain.objects.get_or_create(name="freeman.gov")
|
||||
dummy_contact = domain.get_default_security_contact()
|
||||
|
||||
DF = common.DiscloseField
|
||||
# Create contact with empty fields list
|
||||
result = self._convertPublicContactToEpp(dummy_contact, disclose=True, disclose_fields={DF.EMAIL})
|
||||
|
||||
# Verify disclosure settings
|
||||
self.assertEqual(result.disclose.flag, True)
|
||||
self.assertEqual(result.disclose.fields, {DF.EMAIL})
|
||||
self.assertEqual(result.disclose.types, {DF.ADDR: "loc", DF.NAME: "loc"})
|
||||
|
||||
def test_not_disclosed_on_default_security_contact(self):
|
||||
"""
|
||||
Scenario: Registrant creates a new domain with no security email
|
||||
|
@ -1120,7 +1181,9 @@ class TestRegistrantContacts(MockEppLib):
|
|||
expectedSecContact.domain = domain
|
||||
expectedSecContact.registry_id = "defaultSec"
|
||||
domain.security_contact = expectedSecContact
|
||||
expectedCreateCommand = self._convertPublicContactToEpp(expectedSecContact, disclose_email=False)
|
||||
expectedCreateCommand = self._convertPublicContactToEpp(
|
||||
expectedSecContact, disclose=False, disclose_fields=self.all_disclose_fields
|
||||
)
|
||||
self.mockedSendFunction.assert_any_call(expectedCreateCommand, cleaned=True)
|
||||
# Confirm that we are getting a default email
|
||||
self.assertEqual(domain.security_contact.email, expectedSecContact.email)
|
||||
|
@ -1138,7 +1201,9 @@ class TestRegistrantContacts(MockEppLib):
|
|||
expectedTechContact.domain = domain
|
||||
expectedTechContact.registry_id = "defaultTech"
|
||||
domain.technical_contact = expectedTechContact
|
||||
expectedCreateCommand = self._convertPublicContactToEpp(expectedTechContact, disclose_email=False)
|
||||
expectedCreateCommand = self._convertPublicContactToEpp(
|
||||
expectedTechContact, disclose=False, disclose_fields=self.all_disclose_fields
|
||||
)
|
||||
self.mockedSendFunction.assert_any_call(expectedCreateCommand, cleaned=True)
|
||||
# Confirm that we are getting a default email
|
||||
self.assertEqual(domain.technical_contact.email, expectedTechContact.email)
|
||||
|
@ -1157,7 +1222,7 @@ class TestRegistrantContacts(MockEppLib):
|
|||
expectedSecContact.domain = domain
|
||||
expectedSecContact.email = "security@mail.gov"
|
||||
domain.security_contact = expectedSecContact
|
||||
expectedCreateCommand = self._convertPublicContactToEpp(expectedSecContact, disclose_email=True)
|
||||
expectedCreateCommand = self._convertPublicContactToEpp(expectedSecContact, disclose=False)
|
||||
self.mockedSendFunction.assert_any_call(expectedCreateCommand, cleaned=True)
|
||||
# Confirm that we are getting the desired email
|
||||
self.assertEqual(domain.security_contact.email, expectedSecContact.email)
|
||||
|
|
|
@ -72,7 +72,7 @@ class CsvReportsTest(MockDbForSharedTests):
|
|||
fake_open = mock_open()
|
||||
expected_file_content = [
|
||||
call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"),
|
||||
call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
|
||||
call("cdomain11.gov,Federal,World War I Centennial Commission,,,,(blank)\r\n"),
|
||||
call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
|
||||
call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
|
||||
call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
|
||||
|
@ -94,7 +94,7 @@ class CsvReportsTest(MockDbForSharedTests):
|
|||
fake_open = mock_open()
|
||||
expected_file_content = [
|
||||
call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"),
|
||||
call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
|
||||
call("cdomain11.gov,Federal,World War I Centennial Commission,,,,(blank)\r\n"),
|
||||
call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
|
||||
call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
|
||||
call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
|
||||
|
@ -261,9 +261,6 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
|
|||
"defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,"
|
||||
"Portfolio 1 Federal Agency,Portfolio 1 Federal Agency,,, ,,(blank),"
|
||||
'"big_lebowski@dude.co, info@example.com, meoward@rocks.com",woofwardthethird@rocks.com\n'
|
||||
"cdomain11.gov,Ready,2024-04-02,(blank),Federal - Executive,"
|
||||
"World War I Centennial Commission,,,, ,,(blank),"
|
||||
"meoward@rocks.com,\n"
|
||||
"adomain10.gov,Ready,2024-04-03,(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,"
|
||||
"squeaker@rocks.com\n"
|
||||
"bdomain4.gov,Unknown,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n"
|
||||
|
@ -274,6 +271,9 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
|
|||
"sdomain8.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n"
|
||||
"xdomain7.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n"
|
||||
"zdomain9.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n"
|
||||
"cdomain11.gov,Ready,2024-04-02,(blank),Federal,"
|
||||
"World War I Centennial Commission,,,, ,,(blank),"
|
||||
"meoward@rocks.com,\n"
|
||||
"zdomain12.gov,Ready,2024-04-02,(blank),Interstate,,,,, ,,(blank),meoward@rocks.com,\n"
|
||||
)
|
||||
|
||||
|
@ -498,7 +498,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
|
|||
# sorted alphabetially by domain name
|
||||
expected_content = (
|
||||
"Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n"
|
||||
"cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n"
|
||||
"cdomain11.gov,Federal,World War I Centennial Commission,,,,(blank)\n"
|
||||
"defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n"
|
||||
"adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n"
|
||||
"ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n"
|
||||
|
@ -538,7 +538,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
|
|||
# sorted alphabetially by domain name
|
||||
expected_content = (
|
||||
"Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n"
|
||||
"cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n"
|
||||
"cdomain11.gov,Federal,World War I Centennial Commission,,,,(blank)\n"
|
||||
"defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n"
|
||||
"adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n"
|
||||
"ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n"
|
||||
|
@ -594,7 +594,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
|
|||
"State,Status,Expiration date, Deleted\n"
|
||||
"cdomain1.gov,Federal-Executive,Portfolio1FederalAgency,Portfolio1FederalAgency,Ready,(blank)\n"
|
||||
"adomain10.gov,Federal,ArmedForcesRetirementHome,Ready,(blank)\n"
|
||||
"cdomain11.gov,Federal-Executive,WorldWarICentennialCommission,Ready,(blank)\n"
|
||||
"cdomain11.gov,Federal,WorldWarICentennialCommission,Ready,(blank)\n"
|
||||
"zdomain12.gov,Interstate,Ready,(blank)\n"
|
||||
"zdomain9.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-01\n"
|
||||
"sdomain8.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-02\n"
|
||||
|
@ -642,7 +642,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
|
|||
"3,2,1,0,0,0,0,0,0,0\n"
|
||||
"\n"
|
||||
"Domain name,Domain type,Domain managers,Invited domain managers\n"
|
||||
"cdomain11.gov,Federal - Executive,meoward@rocks.com,\n"
|
||||
"cdomain11.gov,Federal,meoward@rocks.com,\n"
|
||||
'cdomain1.gov,Federal - Executive,"big_lebowski@dude.co, info@example.com, meoward@rocks.com",'
|
||||
"woofwardthethird@rocks.com\n"
|
||||
"zdomain12.gov,Interstate,meoward@rocks.com,\n"
|
||||
|
@ -716,7 +716,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
|
|||
expected_content = (
|
||||
"Domain request,Domain type,Federal type\n"
|
||||
"city3.gov,Federal,Executive\n"
|
||||
"city4.gov,City,Executive\n"
|
||||
"city4.gov,City,\n"
|
||||
"city6.gov,Federal,Executive\n"
|
||||
)
|
||||
|
||||
|
@ -783,7 +783,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
|
|||
"SO last name,SO email,SO title/role,Request purpose,Request additional details,Other contacts,"
|
||||
"CISA regional representative,Current websites,Investigator\n"
|
||||
# Content
|
||||
"city5.gov,Approved,Federal,No,Executive,,Testorg,N/A,,NY,2,requested_suborg,SanFran,CA,,,,,1,0,"
|
||||
"city5.gov,Approved,Federal,No,,,Testorg,N/A,,NY,2,requested_suborg,SanFran,CA,,,,,1,0,"
|
||||
"city1.gov,Testy,Tester,testy@town.com,Chief Tester,Purpose of the site,There is more,"
|
||||
"Testy Tester testy2@town.com,,city.com,\n"
|
||||
"city2.gov,In review,Federal,Yes,Executive,Portfolio 1 Federal Agency,Portfolio 1 Federal Agency,"
|
||||
|
@ -795,7 +795,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
|
|||
'There is more,"Meow Tester24 te2@town.com, Testy1232 Tester24 te2@town.com, '
|
||||
'Testy Tester testy2@town.com",'
|
||||
'test@igorville.com,"city.com, https://www.example2.com, https://www.example.com",\n'
|
||||
"city4.gov,Submitted,City,No,Executive,,Testorg,Yes,,NY,2,,,,,,,,0,1,city1.gov,Testy,"
|
||||
"city4.gov,Submitted,City,No,,,Testorg,Yes,,NY,2,,,,,,,,0,1,city1.gov,Testy,"
|
||||
"Tester,testy@town.com,"
|
||||
"Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more,"
|
||||
"Testy Tester testy2@town.com,"
|
||||
|
|
|
@ -1084,8 +1084,8 @@ class TestDomainManagers(TestDomainOverview):
|
|||
|
||||
@boto3_mocking.patching
|
||||
@less_console_noise_decorator
|
||||
@patch("registrar.views.domain.send_templated_email")
|
||||
def test_domain_remove_manager(self, mock_send_templated_email):
|
||||
@patch("registrar.views.domain.send_domain_manager_removal_emails_to_domain_managers")
|
||||
def test_domain_remove_manager(self, mock_send_email):
|
||||
"""Removing a domain manager sends notification email to other domain managers."""
|
||||
self.manager, _ = User.objects.get_or_create(email="mayor@igorville.com", first_name="Hello", last_name="World")
|
||||
self.manager_domain_permission, _ = UserDomainRole.objects.get_or_create(user=self.manager, domain=self.domain)
|
||||
|
@ -1094,11 +1094,11 @@ class TestDomainManagers(TestDomainOverview):
|
|||
)
|
||||
|
||||
# Verify that the notification emails were sent to domain manager
|
||||
mock_send_templated_email.assert_called_once_with(
|
||||
"emails/domain_manager_deleted_notification.txt",
|
||||
"emails/domain_manager_deleted_notification_subject.txt",
|
||||
to_address="info@example.com",
|
||||
context=ANY,
|
||||
mock_send_email.assert_called_once_with(
|
||||
removed_by_user=self.user,
|
||||
manager_removed=self.manager,
|
||||
manager_removed_email=self.manager.email,
|
||||
domain=self.domain,
|
||||
)
|
||||
|
||||
@less_console_noise_decorator
|
||||
|
@ -2547,8 +2547,8 @@ class TestDomainDNSSEC(TestDomainOverview):
|
|||
domain DNSSEC data and shows a button to Add new record"""
|
||||
|
||||
page = self.client.get(reverse("domain-dns-dnssec-dsdata", kwargs={"domain_pk": self.domain_dnssec_none.id}))
|
||||
self.assertContains(page, "You have no DS data added")
|
||||
self.assertContains(page, "Add new record")
|
||||
self.assertEqual(page.status_code, 200)
|
||||
self.assertContains(page, "Add DS record")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_ds_form_loads_with_ds_data(self):
|
||||
|
@ -2556,26 +2556,8 @@ class TestDomainDNSSEC(TestDomainOverview):
|
|||
domain DNSSEC DS data and shows the data"""
|
||||
|
||||
page = self.client.get(reverse("domain-dns-dnssec-dsdata", kwargs={"domain_pk": self.domain_dsdata.id}))
|
||||
self.assertContains(page, "DS data record 1")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_ds_data_form_modal(self):
|
||||
"""When user clicks on save, a modal pops up."""
|
||||
add_data_page = self.app.get(reverse("domain-dns-dnssec-dsdata", kwargs={"domain_pk": self.domain_dsdata.id}))
|
||||
# Assert that a hidden trigger for the modal does not exist.
|
||||
# This hidden trigger will pop on the page when certain condition are met:
|
||||
# 1) Initial form contained DS data, 2) All data is deleted and form is
|
||||
# submitted.
|
||||
self.assertNotContains(add_data_page, "Trigger Disable DNSSEC Modal")
|
||||
# Simulate a delete all data
|
||||
form_data = {}
|
||||
response = self.client.post(
|
||||
reverse("domain-dns-dnssec-dsdata", kwargs={"domain_pk": self.domain_dsdata.id}),
|
||||
data=form_data,
|
||||
)
|
||||
self.assertEqual(response.status_code, 200) # Adjust status code as needed
|
||||
# Now check to see whether the JS trigger for the modal is present on the page
|
||||
self.assertContains(response, "Trigger Disable DNSSEC Modal")
|
||||
self.assertContains(page, "Add DS record") # assert add form is present
|
||||
self.assertContains(page, "Action") # assert table is present
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_ds_data_form_submits(self):
|
||||
|
@ -2620,6 +2602,32 @@ class TestDomainDNSSEC(TestDomainOverview):
|
|||
self.assertContains(result, "Digest type is required", count=2, status_code=200)
|
||||
self.assertContains(result, "Digest is required", count=2, status_code=200)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_ds_data_form_duplicate(self):
|
||||
"""DS data form errors with invalid data (duplicate DS)
|
||||
|
||||
Uses self.app WebTest because we need to interact with forms.
|
||||
"""
|
||||
add_data_page = self.app.get(reverse("domain-dns-dnssec-dsdata", kwargs={"domain_pk": self.domain_dsdata.id}))
|
||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
# all four form fields are required, so will test with each blank
|
||||
add_data_page.forms[0]["form-0-key_tag"] = 1234
|
||||
add_data_page.forms[0]["form-0-algorithm"] = 3
|
||||
add_data_page.forms[0]["form-0-digest_type"] = 1
|
||||
add_data_page.forms[0]["form-0-digest"] = "ec0bdd990b39feead889f0ba613db4adec0bdd99"
|
||||
add_data_page.forms[0]["form-1-key_tag"] = 1234
|
||||
add_data_page.forms[0]["form-1-algorithm"] = 3
|
||||
add_data_page.forms[0]["form-1-digest_type"] = 1
|
||||
add_data_page.forms[0]["form-1-digest"] = "ec0bdd990b39feead889f0ba613db4adec0bdd99"
|
||||
result = add_data_page.forms[0].submit()
|
||||
# form submission was a post with an error, response should be a 200
|
||||
# error text appears twice, once at the top of the page, once around
|
||||
# the field.
|
||||
self.assertContains(
|
||||
result, "You already entered this DS record. DS records must be unique.", count=2, status_code=200
|
||||
)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_ds_data_form_invalid_keytag(self):
|
||||
"""DS data form errors with invalid data (key tag too large)
|
||||
|
@ -2643,6 +2651,29 @@ class TestDomainDNSSEC(TestDomainOverview):
|
|||
result, str(DsDataError(code=DsDataErrorCodes.INVALID_KEYTAG_SIZE)), count=2, status_code=200
|
||||
)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_ds_data_form_invalid_keytag_chars(self):
|
||||
"""DS data form errors with invalid data (key tag not numeric)
|
||||
|
||||
Uses self.app WebTest because we need to interact with forms.
|
||||
"""
|
||||
add_data_page = self.app.get(reverse("domain-dns-dnssec-dsdata", kwargs={"domain_pk": self.domain_dsdata.id}))
|
||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
# first two nameservers are required, so if we empty one out we should
|
||||
# get a form error
|
||||
add_data_page.forms[0]["form-0-key_tag"] = "invalid" # not numeric
|
||||
add_data_page.forms[0]["form-0-algorithm"] = ""
|
||||
add_data_page.forms[0]["form-0-digest_type"] = ""
|
||||
add_data_page.forms[0]["form-0-digest"] = ""
|
||||
result = add_data_page.forms[0].submit()
|
||||
# form submission was a post with an error, response should be a 200
|
||||
# error text appears twice, once at the top of the page, once around
|
||||
# the field.
|
||||
self.assertContains(
|
||||
result, str(DsDataError(code=DsDataErrorCodes.INVALID_KEYTAG_CHARS)), count=2, status_code=200
|
||||
)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_ds_data_form_invalid_digest_chars(self):
|
||||
"""DS data form errors with invalid data (digest contains non hexadecimal chars)
|
||||
|
@ -2698,8 +2729,6 @@ class TestDomainDNSSEC(TestDomainOverview):
|
|||
add_data_page = self.app.get(reverse("domain-dns-dnssec-dsdata", kwargs={"domain_pk": self.domain_dsdata.id}))
|
||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
# first two nameservers are required, so if we empty one out we should
|
||||
# get a form error
|
||||
add_data_page.forms[0]["form-0-key_tag"] = "1234"
|
||||
add_data_page.forms[0]["form-0-algorithm"] = "3"
|
||||
add_data_page.forms[0]["form-0-digest_type"] = "2" # SHA-256
|
||||
|
|
|
@ -376,8 +376,8 @@ class TestPortfolio(WebTest):
|
|||
self.assertContains(page, "Non-Federal Agency")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_domain_org_name_address_form(self):
|
||||
"""Submitting changes works on the org name address page."""
|
||||
def test_org_form_invalid_update(self):
|
||||
"""Organization form will not redirect on invalid formsets."""
|
||||
with override_flag("organization_feature", active=True):
|
||||
self.app.set_user(self.user.username)
|
||||
portfolio_additional_permissions = [
|
||||
|
@ -398,11 +398,79 @@ class TestPortfolio(WebTest):
|
|||
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
success_result_page = portfolio_org_name_page.form.submit()
|
||||
# Form will not validate with missing required field (zipcode)
|
||||
self.assertEqual(success_result_page.status_code, 200)
|
||||
|
||||
self.assertContains(success_result_page, "6 Downing st")
|
||||
self.assertContains(success_result_page, "London")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_org_form_valid_update(self):
|
||||
"""Organization form will redirect on valid formsets."""
|
||||
with override_flag("organization_feature", active=True):
|
||||
self.app.set_user(self.user.username)
|
||||
portfolio_additional_permissions = [
|
||||
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
||||
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
|
||||
]
|
||||
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user, portfolio=self.portfolio, additional_permissions=portfolio_additional_permissions
|
||||
)
|
||||
|
||||
self.portfolio.address_line1 = "1600 Penn Ave"
|
||||
self.portfolio.save()
|
||||
portfolio_org_name_page = self.app.get(reverse("organization"))
|
||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||
|
||||
# Form validates and redirects with all required fields
|
||||
portfolio_org_name_page.form["address_line1"] = "6 Downing st"
|
||||
portfolio_org_name_page.form["city"] = "London"
|
||||
portfolio_org_name_page.form["zipcode"] = "11111"
|
||||
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
success_result_page = portfolio_org_name_page.form.submit()
|
||||
self.assertEqual(success_result_page.status_code, 302)
|
||||
|
||||
@boto3_mocking.patching
|
||||
@less_console_noise_decorator
|
||||
@patch("registrar.views.portfolios.send_portfolio_update_emails_to_portfolio_admins")
|
||||
def test_org_update_sends_admin_email(self, mock_send_organization_update_email):
|
||||
"""Updating organization information emails organization admin."""
|
||||
with override_flag("organization_feature", active=True):
|
||||
self.app.set_user(self.user.username)
|
||||
self.admin, _ = User.objects.get_or_create(
|
||||
email="mayor@igorville.com", first_name="Hello", last_name="World"
|
||||
)
|
||||
|
||||
portfolio_additional_permissions = [
|
||||
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
||||
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
|
||||
]
|
||||
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user, portfolio=self.portfolio, additional_permissions=portfolio_additional_permissions
|
||||
)
|
||||
portfolio_permission_admin, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.admin,
|
||||
portfolio=self.portfolio,
|
||||
additional_permissions=portfolio_additional_permissions,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
)
|
||||
|
||||
self.portfolio.address_line1 = "1600 Penn Ave"
|
||||
self.portfolio.save()
|
||||
portfolio_org_name_page = self.app.get(reverse("organization"))
|
||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||
portfolio_org_name_page.form["address_line1"] = "6 Downing st"
|
||||
portfolio_org_name_page.form["city"] = "London"
|
||||
portfolio_org_name_page.form["zipcode"] = "11111"
|
||||
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
success_result_page = portfolio_org_name_page.form.submit()
|
||||
self.assertEqual(success_result_page.status_code, 302)
|
||||
|
||||
# Verify that the notification emails were sent to domain manager
|
||||
mock_send_organization_update_email.assert_called_once()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_portfolio_in_session_when_organization_feature_active(self):
|
||||
"""When organization_feature flag is true and user has a portfolio,
|
||||
|
@ -1657,16 +1725,19 @@ class TestPortfolioMemberDeleteView(WebTest):
|
|||
self.user = create_test_user()
|
||||
self.domain, _ = Domain.objects.get_or_create(name="igorville.gov")
|
||||
self.portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California")
|
||||
self.domain_information, _ = DomainInformation.objects.get_or_create(
|
||||
creator=self.user, domain=self.domain, portfolio=self.portfolio
|
||||
)
|
||||
self.role, _ = UserDomainRole.objects.get_or_create(
|
||||
user=self.user, domain=self.domain, role=UserDomainRole.Roles.MANAGER
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
UserPortfolioPermission.objects.all().delete()
|
||||
DomainInformation.objects.all().delete()
|
||||
Portfolio.objects.all().delete()
|
||||
UserDomainRole.objects.all().delete()
|
||||
DomainRequest.objects.all().delete()
|
||||
DomainInformation.objects.all().delete()
|
||||
Domain.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
super().tearDown()
|
||||
|
@ -1676,7 +1747,10 @@ class TestPortfolioMemberDeleteView(WebTest):
|
|||
@override_flag("organization_members", active=True)
|
||||
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
|
||||
@patch("registrar.views.portfolios.send_portfolio_member_permission_remove_email")
|
||||
def test_portfolio_member_delete_view_members_table_active_requests(self, send_member_removal, send_removal_emails):
|
||||
@patch("registrar.views.portfolios.send_domain_manager_removal_emails_to_domain_managers")
|
||||
def test_portfolio_member_delete_view_members_table_active_requests(
|
||||
self, send_domain_manager_removal_emails, send_member_removal, send_removal_emails
|
||||
):
|
||||
"""Error state w/ deleting a member with active request on Members Table"""
|
||||
# I'm a user
|
||||
UserPortfolioPermission.objects.get_or_create(
|
||||
|
@ -1718,13 +1792,18 @@ class TestPortfolioMemberDeleteView(WebTest):
|
|||
send_removal_emails.assert_not_called()
|
||||
# assert that send_portfolio_member_permission_remove_email is not called
|
||||
send_member_removal.assert_not_called()
|
||||
# assert that send_domain_manager_removal_emails is not called
|
||||
send_domain_manager_removal_emails.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_admin_removal_emails")
|
||||
@patch("registrar.views.portfolios.send_portfolio_member_permission_remove_email")
|
||||
def test_portfolio_member_delete_view_members_table_only_admin(self, send_member_removal, send_removal_emails):
|
||||
@patch("registrar.views.portfolios.send_domain_manager_removal_emails_to_domain_managers")
|
||||
def test_portfolio_member_delete_view_members_table_only_admin(
|
||||
self, send_domain_manager_removal_emails, send_member_removal, send_removal_emails
|
||||
):
|
||||
"""Error state w/ deleting a member that's the only admin on Members Table"""
|
||||
|
||||
# I'm a user with admin permission
|
||||
|
@ -1757,13 +1836,18 @@ class TestPortfolioMemberDeleteView(WebTest):
|
|||
send_removal_emails.assert_not_called()
|
||||
# assert that send_portfolio_member_permission_remove_email is not called
|
||||
send_member_removal.assert_not_called()
|
||||
# assert that send_domain_manager_removal_emails is not called
|
||||
send_domain_manager_removal_emails.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_admin_removal_emails")
|
||||
@patch("registrar.views.portfolios.send_portfolio_member_permission_remove_email")
|
||||
def test_portfolio_member_table_delete_member_success(self, send_member_removal, mock_send_removal_emails):
|
||||
@patch("registrar.views.portfolios.send_domain_manager_removal_emails_to_domain_managers")
|
||||
def test_portfolio_member_table_delete_member_success(
|
||||
self, send_domain_manager_removal_emails, send_member_removal, mock_send_removal_emails
|
||||
):
|
||||
"""Success state with deleting on Members Table page bc no active request AND not only admin"""
|
||||
|
||||
# I'm a user
|
||||
|
@ -1788,6 +1872,9 @@ class TestPortfolioMemberDeleteView(WebTest):
|
|||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
)
|
||||
|
||||
# Set up the member as a domain manager
|
||||
UserDomainRole.objects.get_or_create(user=member, domain=self.domain, role=UserDomainRole.Roles.MANAGER)
|
||||
|
||||
# Member removal email sent successfully
|
||||
send_member_removal.return_value = True
|
||||
|
||||
|
@ -1815,6 +1902,8 @@ class TestPortfolioMemberDeleteView(WebTest):
|
|||
mock_send_removal_emails.assert_not_called()
|
||||
# assert that send_portfolio_member_permission_remove_email is called
|
||||
send_member_removal.assert_called_once()
|
||||
# assert that send_domain_manager_removal_emails_to_domain_managers
|
||||
send_domain_manager_removal_emails.assert_called_once()
|
||||
|
||||
# Get the arguments passed to send_portfolio_member_permission_remove_email
|
||||
_, called_kwargs = send_member_removal.call_args
|
||||
|
@ -1824,6 +1913,15 @@ class TestPortfolioMemberDeleteView(WebTest):
|
|||
self.assertEqual(called_kwargs["permissions"].user, upp.user)
|
||||
self.assertEqual(called_kwargs["permissions"].portfolio, upp.portfolio)
|
||||
|
||||
# Get the arguments passed to send_domain_manager_removal_emails_to_domain_managers
|
||||
_, called_kwargs = send_domain_manager_removal_emails.call_args
|
||||
|
||||
# Assert the email content
|
||||
self.assertEqual(called_kwargs["removed_by_user"], self.user)
|
||||
self.assertEqual(called_kwargs["manager_removed"], upp.user)
|
||||
self.assertEqual(called_kwargs["manager_removed_email"], upp.user.email)
|
||||
self.assertEqual(called_kwargs["domain"], self.domain)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
|
@ -2639,7 +2737,8 @@ class TestPortfolioMemberDomainsEditView(TestWithUser, WebTest):
|
|||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
@patch("registrar.views.portfolios.send_domain_invitation_email")
|
||||
def test_post_with_valid_added_domains(self, mock_send_domain_email):
|
||||
@patch("registrar.views.portfolios.send_domain_manager_removal_emails_to_domain_managers")
|
||||
def test_post_with_valid_added_domains(self, send_domain_manager_removal_emails, mock_send_domain_email):
|
||||
"""Test that domains can be successfully added."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
|
@ -2658,6 +2757,8 @@ class TestPortfolioMemberDomainsEditView(TestWithUser, WebTest):
|
|||
self.assertEqual(str(messages[0]), "The domain assignment changes have been saved.")
|
||||
|
||||
expected_domains = [self.domain1, self.domain2, self.domain3]
|
||||
# assert that send_domain_manager_removal_emails_to_domain_managers is not called
|
||||
send_domain_manager_removal_emails.assert_not_called()
|
||||
# Verify that the invitation email was sent
|
||||
mock_send_domain_email.assert_called_once()
|
||||
call_args = mock_send_domain_email.call_args.kwargs
|
||||
|
@ -2670,7 +2771,8 @@ class TestPortfolioMemberDomainsEditView(TestWithUser, WebTest):
|
|||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
@patch("registrar.views.portfolios.send_domain_invitation_email")
|
||||
def test_post_with_valid_removed_domains(self, mock_send_domain_email):
|
||||
@patch("registrar.views.portfolios.send_domain_manager_removal_emails_to_domain_managers")
|
||||
def test_post_with_valid_removed_domains(self, send_domain_manager_removal_emails, mock_send_domain_email):
|
||||
"""Test that domains can be successfully removed."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
|
@ -2678,6 +2780,8 @@ class TestPortfolioMemberDomainsEditView(TestWithUser, WebTest):
|
|||
domains = [self.domain1, self.domain2, self.domain3]
|
||||
UserDomainRole.objects.bulk_create([UserDomainRole(domain=domain, user=self.user) for domain in domains])
|
||||
|
||||
send_domain_manager_removal_emails.return_value = True
|
||||
|
||||
data = {
|
||||
"removed_domains": json.dumps([self.domain1.id, self.domain2.id]),
|
||||
}
|
||||
|
@ -2694,7 +2798,19 @@ class TestPortfolioMemberDomainsEditView(TestWithUser, WebTest):
|
|||
self.assertEqual(str(messages[0]), "The domain assignment changes have been saved.")
|
||||
# assert that send_domain_invitation_email is not called
|
||||
mock_send_domain_email.assert_not_called()
|
||||
|
||||
# assert that send_domain_manager_removal_emails_to_domain_managers is called twice
|
||||
send_domain_manager_removal_emails.assert_any_call(
|
||||
removed_by_user=self.user,
|
||||
manager_removed=self.portfolio_permission.user,
|
||||
manager_removed_email=self.portfolio_permission.user.email,
|
||||
domain=self.domain1,
|
||||
)
|
||||
send_domain_manager_removal_emails.assert_any_call(
|
||||
removed_by_user=self.user,
|
||||
manager_removed=self.portfolio_permission.user,
|
||||
manager_removed_email=self.portfolio_permission.user.email,
|
||||
domain=self.domain2,
|
||||
)
|
||||
UserDomainRole.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
|
@ -3930,17 +4046,59 @@ class TestPortfolioInviteNewMemberView(MockEppLib, WebTest):
|
|||
response = self.client.post(
|
||||
reverse("new-member"),
|
||||
{
|
||||
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
|
||||
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
|
||||
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
|
||||
"email": self.user.email,
|
||||
},
|
||||
follow=True,
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
with open("debug_response.html", "w") as f:
|
||||
f.write(response.content.decode("utf-8"))
|
||||
|
||||
# Verify messages
|
||||
self.assertContains(
|
||||
response,
|
||||
f"{self.user.email} is already a member of another .gov organization.",
|
||||
"User is already a member of this portfolio.",
|
||||
)
|
||||
|
||||
# Validate Database has not changed
|
||||
invite_count_after = PortfolioInvitation.objects.count()
|
||||
self.assertEqual(invite_count_after, invite_count_before)
|
||||
|
||||
# assert that send_portfolio_invitation_email is not called
|
||||
mock_send_email.assert_not_called()
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
@patch("registrar.views.portfolios.send_portfolio_invitation_email")
|
||||
def test_member_invite_for_existing_member_uppercase(self, mock_send_email):
|
||||
"""Tests the member invitation flow for existing portfolio member with a different case."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
# Simulate a session to ensure continuity
|
||||
session_id = self.client.session.session_key
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
|
||||
invite_count_before = PortfolioInvitation.objects.count()
|
||||
|
||||
# Simulate submission of member invite for user who has already been invited
|
||||
response = self.client.post(
|
||||
reverse("new-member"),
|
||||
{
|
||||
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
|
||||
"email": self.user.email.upper(),
|
||||
},
|
||||
follow=True,
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
with open("debug_response.html", "w") as f:
|
||||
f.write(response.content.decode("utf-8"))
|
||||
|
||||
# Verify messages
|
||||
self.assertContains(
|
||||
response,
|
||||
"User is already a member of this portfolio.",
|
||||
)
|
||||
|
||||
# Validate Database has not changed
|
||||
|
|
|
@ -2550,7 +2550,7 @@ class DomainRequestTests(TestWithUser, WebTest):
|
|||
|
||||
# @less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
def test_domain_request_dotgov_domain_FEB_questions(self):
|
||||
def test_domain_request_FEB_questions(self):
|
||||
"""
|
||||
Test that for a member of a federal executive branch portfolio with org feature on, the dotgov domain page
|
||||
contains additional questions for OMB.
|
||||
|
@ -2612,7 +2612,6 @@ class DomainRequestTests(TestWithUser, WebTest):
|
|||
# separate out these tests for readability
|
||||
self.feb_dotgov_domain_tests(dotgov_page)
|
||||
|
||||
# Now proceed with the actual test
|
||||
domain_form = dotgov_page.forms[0]
|
||||
domain = "test.gov"
|
||||
domain_form["dotgov_domain-requested_domain"] = domain
|
||||
|
@ -2630,6 +2629,36 @@ class DomainRequestTests(TestWithUser, WebTest):
|
|||
|
||||
self.feb_purpose_page_tests(purpose_page)
|
||||
|
||||
purpose_form = purpose_page.forms[0]
|
||||
purpose_form["purpose-feb_purpose_choice"] = "redirect"
|
||||
purpose_form["purpose-purpose"] = "test"
|
||||
purpose_form["purpose-has_timeframe"] = "True"
|
||||
purpose_form["purpose-time_frame_details"] = "test"
|
||||
purpose_form["purpose-is_interagency_initiative"] = "True"
|
||||
purpose_form["purpose-interagency_initiative_details"] = "test"
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
purpose_result = purpose_form.submit()
|
||||
|
||||
# ---- ADDITIONAL DETAILS PAGE ----
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
additional_details_page = purpose_result.follow()
|
||||
self.feb_additional_details_page_tests(additional_details_page)
|
||||
|
||||
additional_details_form = additional_details_page.forms[0]
|
||||
additional_details_form["portfolio_additional_details-working_with_eop"] = "True"
|
||||
additional_details_form["portfolio_additional_details-first_name"] = "Testy"
|
||||
additional_details_form["portfolio_additional_details-last_name"] = "Tester"
|
||||
additional_details_form["portfolio_additional_details-email"] = "testy@town.com"
|
||||
additional_details_form["portfolio_additional_details-has_anything_else_text"] = "True"
|
||||
additional_details_form["portfolio_additional_details-anything_else"] = "test"
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
additional_details_result = additional_details_form.submit()
|
||||
|
||||
# ---- REQUIREMENTS PAGE ----
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
requirements_page = additional_details_result.follow()
|
||||
self.feb_requirements_page_tests(requirements_page)
|
||||
|
||||
def feb_purpose_page_tests(self, purpose_page):
|
||||
self.assertContains(purpose_page, "What is the purpose of your requested domain?")
|
||||
|
||||
|
@ -2670,6 +2699,40 @@ class DomainRequestTests(TestWithUser, WebTest):
|
|||
# Check that the details form was included
|
||||
self.assertContains(dotgov_page, "feb_naming_requirements_details")
|
||||
|
||||
def feb_additional_details_page_tests(self, additional_details_page):
|
||||
test_text = "Are you working with someone in the Executive Office of the President (EOP) on this request?"
|
||||
self.assertContains(additional_details_page, test_text)
|
||||
|
||||
# Make sure the EOP form is present
|
||||
self.assertContains(additional_details_page, "working_with_eop")
|
||||
|
||||
# Make sure the EOP contact form is present
|
||||
self.assertContains(additional_details_page, "eop-contact-container")
|
||||
self.assertContains(additional_details_page, "additional_details-first_name")
|
||||
self.assertContains(additional_details_page, "additional_details-last_name")
|
||||
self.assertContains(additional_details_page, "additional_details-email")
|
||||
|
||||
# Make sure the additional details form is present
|
||||
self.assertContains(additional_details_page, "additional_details-has_anything_else_text")
|
||||
self.assertContains(additional_details_page, "additional_details-anything_else")
|
||||
|
||||
def feb_requirements_page_tests(self, requirements_page):
|
||||
# Check for the 21st Century IDEA Act links
|
||||
self.assertContains(
|
||||
requirements_page, "https://digital.gov/resources/delivering-digital-first-public-experience-act/"
|
||||
)
|
||||
self.assertContains(
|
||||
requirements_page,
|
||||
"https://bidenwhitehouse.gov/wp-content/uploads/2023/09/M-23-22-Delivering-a-Digital-First-Public-Experience.pdf", # noqa
|
||||
)
|
||||
|
||||
# Check for the policy acknowledgement form
|
||||
self.assertContains(requirements_page, "is_policy_acknowledged")
|
||||
self.assertContains(
|
||||
requirements_page,
|
||||
"I read and understand the guidance outlined in the DOTGOV Act for operating a .gov domain.",
|
||||
)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_domain_request_formsets(self):
|
||||
"""Users are able to add more than one of some fields."""
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
from registrar.models.domain_request import DomainRequest
|
||||
from django.conf import settings
|
||||
from django.template.loader import get_template
|
||||
from django.utils.html import format_html
|
||||
from django.urls import reverse
|
||||
|
@ -35,8 +36,13 @@ def _get_default_email(domain_request, file_path, reason, excluded_reasons=None)
|
|||
return None
|
||||
|
||||
recipient = domain_request.creator
|
||||
env_base_url = settings.BASE_URL
|
||||
# If NOT in prod, update instances of "manage.get.gov" links to point to
|
||||
# current environment, ie "getgov-rh.app.cloud.gov"
|
||||
manage_url = env_base_url if not settings.IS_PRODUCTION else "https://manage.get.gov"
|
||||
|
||||
# Return the context of the rendered views
|
||||
context = {"domain_request": domain_request, "recipient": recipient, "reason": reason}
|
||||
context = {"domain_request": domain_request, "recipient": recipient, "reason": reason, "manage_url": manage_url}
|
||||
|
||||
email_body_text = get_template(file_path).render(context=context)
|
||||
email_body_text_cleaned = email_body_text.strip().lstrip("\n") if email_body_text else None
|
||||
|
|
|
@ -579,8 +579,8 @@ class DomainExport(BaseExport):
|
|||
Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False),
|
||||
then=F("portfolio__federal_agency__federal_type"),
|
||||
),
|
||||
# Otherwise, return the natively assigned value
|
||||
default=F("federal_type"),
|
||||
# Otherwise, return the federal type from federal agency
|
||||
default=F("federal_agency__federal_type"),
|
||||
output_field=CharField(),
|
||||
),
|
||||
"converted_organization_name": Case(
|
||||
|
@ -740,7 +740,7 @@ class DomainExport(BaseExport):
|
|||
domain_type = f"{human_readable_domain_org_type} - {human_readable_domain_federal_type}"
|
||||
|
||||
security_contact_email = model.get("security_contact_email")
|
||||
invalid_emails = {DefaultEmail.LEGACY_DEFAULT.value, DefaultEmail.PUBLIC_CONTACT_DEFAULT.value}
|
||||
invalid_emails = DefaultEmail.get_all_emails()
|
||||
if (
|
||||
not security_contact_email
|
||||
or not isinstance(security_contact_email, str)
|
||||
|
@ -1654,8 +1654,8 @@ class DomainRequestExport(BaseExport):
|
|||
Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False),
|
||||
then=F("portfolio__federal_agency__federal_type"),
|
||||
),
|
||||
# Otherwise, return the natively assigned value
|
||||
default=F("federal_type"),
|
||||
# Otherwise, return the federal type from federal agency
|
||||
default=F("federal_agency__federal_type"),
|
||||
output_field=CharField(),
|
||||
),
|
||||
"converted_organization_name": Case(
|
||||
|
|
|
@ -3,6 +3,7 @@ from django.conf import settings
|
|||
from registrar.models import Domain, DomainInvitation, UserDomainRole
|
||||
from registrar.models.portfolio import Portfolio
|
||||
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||
from registrar.models.user import User
|
||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
||||
from registrar.utility.errors import (
|
||||
|
@ -18,6 +19,88 @@ import logging
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_domains(domains: Domain | list[Domain]) -> list[Domain]:
|
||||
"""Ensures domains is always a list."""
|
||||
return [domains] if isinstance(domains, Domain) else domains
|
||||
|
||||
|
||||
def _get_requestor_email(requestor, domains=None, portfolio=None):
|
||||
"""Get the requestor's email or raise an error if it's missing.
|
||||
|
||||
If the requestor is staff, default email is returned.
|
||||
|
||||
Raises:
|
||||
MissingEmailError
|
||||
"""
|
||||
if requestor.is_staff:
|
||||
return settings.DEFAULT_FROM_EMAIL
|
||||
|
||||
if not requestor.email or requestor.email.strip() == "":
|
||||
domain_names = None
|
||||
if domains:
|
||||
domain_names = ", ".join([domain.name for domain in domains])
|
||||
raise MissingEmailError(email=requestor.email, domain=domain_names, portfolio=portfolio)
|
||||
|
||||
return requestor.email
|
||||
|
||||
|
||||
def _validate_invitation(email, user, domains, requestor, is_member_of_different_org):
|
||||
"""Validate the invitation conditions."""
|
||||
_check_outside_org_membership(email, requestor, is_member_of_different_org)
|
||||
|
||||
for domain in domains:
|
||||
_validate_existing_invitation(email, user, domain)
|
||||
|
||||
# NOTE: should we also be validating against existing user_domain_roles
|
||||
|
||||
|
||||
def _check_outside_org_membership(email, requestor, is_member_of_different_org):
|
||||
"""Raise an error if the email belongs to a different organization."""
|
||||
if (
|
||||
flag_is_active_for_user(requestor, "organization_feature")
|
||||
and not flag_is_active_for_user(requestor, "multiple_portfolios")
|
||||
and is_member_of_different_org
|
||||
):
|
||||
raise OutsideOrgMemberError(email=email)
|
||||
|
||||
|
||||
def _validate_existing_invitation(email, user, domain):
|
||||
"""Check for existing invitations and handle their status."""
|
||||
try:
|
||||
invite = DomainInvitation.objects.get(email=email, domain=domain)
|
||||
if invite.status == DomainInvitation.DomainInvitationStatus.RETRIEVED:
|
||||
raise AlreadyDomainManagerError(email)
|
||||
elif invite.status == DomainInvitation.DomainInvitationStatus.CANCELED:
|
||||
invite.update_cancellation_status()
|
||||
invite.save()
|
||||
else:
|
||||
raise AlreadyDomainInvitedError(email)
|
||||
except DomainInvitation.DoesNotExist:
|
||||
pass
|
||||
if user:
|
||||
if UserDomainRole.objects.filter(user=user, domain=domain).exists():
|
||||
raise AlreadyDomainManagerError(email)
|
||||
|
||||
|
||||
def _send_domain_invitation_email(email, requestor_email, domains, requested_user):
|
||||
"""Send the invitation email."""
|
||||
try:
|
||||
send_templated_email(
|
||||
"emails/domain_invitation.txt",
|
||||
"emails/domain_invitation_subject.txt",
|
||||
to_address=email,
|
||||
context={
|
||||
"domains": domains,
|
||||
"requestor_email": requestor_email,
|
||||
"invitee_email_address": email,
|
||||
"requested_user": requested_user,
|
||||
},
|
||||
)
|
||||
except EmailSendingError as err:
|
||||
domain_names = ", ".join([domain.name for domain in domains])
|
||||
raise EmailSendingError(f"Could not send email invitation to {email} for domains: {domain_names}") from err
|
||||
|
||||
|
||||
def send_domain_invitation_email(
|
||||
email: str, requestor, domains: Domain | list[Domain], is_member_of_different_org, requested_user=None
|
||||
):
|
||||
|
@ -46,12 +129,12 @@ def send_domain_invitation_email(
|
|||
|
||||
_validate_invitation(email, requested_user, domains, requestor, is_member_of_different_org)
|
||||
|
||||
send_invitation_email(email, requestor_email, domains, requested_user)
|
||||
_send_domain_invitation_email(email, requestor_email, domains, requested_user)
|
||||
|
||||
all_manager_emails_sent = True
|
||||
# send emails to domain managers
|
||||
for domain in domains:
|
||||
if not send_emails_to_domain_managers(
|
||||
if not _send_domain_invitation_update_emails_to_domain_managers(
|
||||
email=email,
|
||||
requestor_email=requestor_email,
|
||||
domain=domain,
|
||||
|
@ -62,7 +145,9 @@ def send_domain_invitation_email(
|
|||
return all_manager_emails_sent
|
||||
|
||||
|
||||
def send_emails_to_domain_managers(email: str, requestor_email, domain: Domain, requested_user=None):
|
||||
def _send_domain_invitation_update_emails_to_domain_managers(
|
||||
email: str, requestor_email, domain: Domain, requested_user=None
|
||||
):
|
||||
"""
|
||||
Notifies all domain managers of the provided domain of a change
|
||||
|
||||
|
@ -96,86 +181,54 @@ def send_emails_to_domain_managers(email: str, requestor_email, domain: Domain,
|
|||
return all_emails_sent
|
||||
|
||||
|
||||
def _normalize_domains(domains: Domain | list[Domain]) -> list[Domain]:
|
||||
"""Ensures domains is always a list."""
|
||||
return [domains] if isinstance(domains, Domain) else domains
|
||||
|
||||
|
||||
def _get_requestor_email(requestor, domains=None, portfolio=None):
|
||||
"""Get the requestor's email or raise an error if it's missing.
|
||||
|
||||
If the requestor is staff, default email is returned.
|
||||
|
||||
Raises:
|
||||
MissingEmailError
|
||||
"""
|
||||
if requestor.is_staff:
|
||||
return settings.DEFAULT_FROM_EMAIL
|
||||
|
||||
if not requestor.email or requestor.email.strip() == "":
|
||||
domain_names = None
|
||||
if domains:
|
||||
domain_names = ", ".join([domain.name for domain in domains])
|
||||
raise MissingEmailError(email=requestor.email, domain=domain_names, portfolio=portfolio)
|
||||
|
||||
return requestor.email
|
||||
|
||||
|
||||
def _validate_invitation(email, user, domains, requestor, is_member_of_different_org):
|
||||
"""Validate the invitation conditions."""
|
||||
check_outside_org_membership(email, requestor, is_member_of_different_org)
|
||||
|
||||
for domain in domains:
|
||||
_validate_existing_invitation(email, user, domain)
|
||||
|
||||
# NOTE: should we also be validating against existing user_domain_roles
|
||||
|
||||
|
||||
def check_outside_org_membership(email, requestor, is_member_of_different_org):
|
||||
"""Raise an error if the email belongs to a different organization."""
|
||||
if (
|
||||
flag_is_active_for_user(requestor, "organization_feature")
|
||||
and not flag_is_active_for_user(requestor, "multiple_portfolios")
|
||||
and is_member_of_different_org
|
||||
def send_domain_manager_removal_emails_to_domain_managers(
|
||||
removed_by_user: User,
|
||||
manager_removed: User,
|
||||
manager_removed_email: str,
|
||||
domain: Domain,
|
||||
):
|
||||
raise OutsideOrgMemberError(email=email)
|
||||
"""
|
||||
Notifies all domain managers that a domain manager has been removed.
|
||||
|
||||
Args:
|
||||
removed_by_user(User): The user who initiated the removal.
|
||||
manager_removed(User): The user being removed.
|
||||
manager_removed_email(str): The email of the user being removed (in case no User).
|
||||
domain(Domain): The domain the user is being removed from.
|
||||
|
||||
def _validate_existing_invitation(email, user, domain):
|
||||
"""Check for existing invitations and handle their status."""
|
||||
try:
|
||||
invite = DomainInvitation.objects.get(email=email, domain=domain)
|
||||
if invite.status == DomainInvitation.DomainInvitationStatus.RETRIEVED:
|
||||
raise AlreadyDomainManagerError(email)
|
||||
elif invite.status == DomainInvitation.DomainInvitationStatus.CANCELED:
|
||||
invite.update_cancellation_status()
|
||||
invite.save()
|
||||
else:
|
||||
raise AlreadyDomainInvitedError(email)
|
||||
except DomainInvitation.DoesNotExist:
|
||||
pass
|
||||
if user:
|
||||
if UserDomainRole.objects.filter(user=user, domain=domain).exists():
|
||||
raise AlreadyDomainManagerError(email)
|
||||
Returns:
|
||||
Boolean indicating if all messages were sent successfully.
|
||||
|
||||
|
||||
def send_invitation_email(email, requestor_email, domains, requested_user):
|
||||
"""Send the invitation email."""
|
||||
"""
|
||||
all_emails_sent = True
|
||||
# Get each domain manager from list
|
||||
user_domain_roles = UserDomainRole.objects.filter(domain=domain)
|
||||
if manager_removed:
|
||||
user_domain_roles = user_domain_roles.exclude(user=manager_removed)
|
||||
for user_domain_role in user_domain_roles:
|
||||
# Send email to each domain manager
|
||||
user = user_domain_role.user
|
||||
try:
|
||||
send_templated_email(
|
||||
"emails/domain_invitation.txt",
|
||||
"emails/domain_invitation_subject.txt",
|
||||
to_address=email,
|
||||
"emails/domain_manager_deleted_notification.txt",
|
||||
"emails/domain_manager_deleted_notification_subject.txt",
|
||||
to_address=user.email,
|
||||
context={
|
||||
"domains": domains,
|
||||
"requestor_email": requestor_email,
|
||||
"invitee_email_address": email,
|
||||
"requested_user": requested_user,
|
||||
"domain": domain,
|
||||
"removed_by": removed_by_user,
|
||||
"manager_removed_email": manager_removed_email,
|
||||
"date": date.today(),
|
||||
},
|
||||
)
|
||||
except EmailSendingError as err:
|
||||
domain_names = ", ".join([domain.name for domain in domains])
|
||||
raise EmailSendingError(f"Could not send email invitation to {email} for domains: {domain_names}") from err
|
||||
except EmailSendingError:
|
||||
logger.warning(
|
||||
"Could not send notification email to %s for domain %s",
|
||||
user.email,
|
||||
domain.name,
|
||||
exc_info=True,
|
||||
)
|
||||
all_emails_sent = False
|
||||
return all_emails_sent
|
||||
|
||||
|
||||
def send_portfolio_invitation_email(email: str, requestor, portfolio, is_admin_invitation):
|
||||
|
@ -227,6 +280,56 @@ def send_portfolio_invitation_email(email: str, requestor, portfolio, is_admin_i
|
|||
return all_admin_emails_sent
|
||||
|
||||
|
||||
def send_portfolio_update_emails_to_portfolio_admins(editor, portfolio, updated_page):
|
||||
"""
|
||||
Sends an email notification to all portfolio admin when portfolio organization is updated.
|
||||
|
||||
Raises exceptions for validation or email-sending issues.
|
||||
|
||||
Args:
|
||||
editor (User): The user editing the portfolio organization.
|
||||
portfolio (Portfolio): The portfolio object whose organization information is changed.
|
||||
|
||||
Returns:
|
||||
Boolean indicating if all messages were sent successfully.
|
||||
|
||||
Raises:
|
||||
MissingEmailError: If the requestor has no email associated with their account.
|
||||
EmailSendingError: If there is an error while sending the email.
|
||||
"""
|
||||
all_emails_sent = True
|
||||
# Get each portfolio admin from list
|
||||
user_portfolio_permissions = UserPortfolioPermission.objects.filter(
|
||||
portfolio=portfolio, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
for user_portfolio_permission in user_portfolio_permissions:
|
||||
# Send email to each portfolio_admin
|
||||
user = user_portfolio_permission.user
|
||||
try:
|
||||
send_templated_email(
|
||||
"emails/portfolio_org_update_notification.txt",
|
||||
"emails/portfolio_org_update_notification_subject.txt",
|
||||
to_address=user.email,
|
||||
context={
|
||||
"requested_user": user,
|
||||
"portfolio": portfolio,
|
||||
"editor": editor,
|
||||
"portfolio_admin": user,
|
||||
"date": date.today(),
|
||||
"updated_info": updated_page,
|
||||
},
|
||||
)
|
||||
except EmailSendingError:
|
||||
logger.warning(
|
||||
"Could not send email organization admin notification to %s " "for portfolio: %s",
|
||||
user.email,
|
||||
portfolio,
|
||||
exc_info=True,
|
||||
)
|
||||
all_emails_sent = False
|
||||
return all_emails_sent
|
||||
|
||||
|
||||
def send_portfolio_member_permission_update_email(requestor, permissions: UserPortfolioPermission):
|
||||
"""
|
||||
Sends an email notification to a portfolio member when their permissions are updated.
|
||||
|
|
|
@ -29,18 +29,26 @@ class LogCode(Enum):
|
|||
DEFAULT = 5
|
||||
|
||||
|
||||
class DefaultEmail(Enum):
|
||||
class DefaultEmail(StrEnum):
|
||||
"""Stores the string values of default emails
|
||||
|
||||
Overview of emails:
|
||||
- PUBLIC_CONTACT_DEFAULT: "dotgov@cisa.dhs.gov"
|
||||
- PUBLIC_CONTACT_DEFAULT: "help@get.gov"
|
||||
- OLD_PUBLIC_CONTACT_DEFAULT: "dotgov@cisa.dhs.gov"
|
||||
- LEGACY_DEFAULT: "registrar@dotgov.gov"
|
||||
- HELP_EMAIL: "help@get.gov"
|
||||
"""
|
||||
|
||||
PUBLIC_CONTACT_DEFAULT = "dotgov@cisa.dhs.gov"
|
||||
PUBLIC_CONTACT_DEFAULT = "help@get.gov"
|
||||
# We used to use this email for default public contacts.
|
||||
# This is retained for data correctness, but it will be phased out.
|
||||
# help@get.gov is the current email that we use for these now.
|
||||
OLD_PUBLIC_CONTACT_DEFAULT = "dotgov@cisa.dhs.gov"
|
||||
LEGACY_DEFAULT = "registrar@dotgov.gov"
|
||||
|
||||
@classmethod
|
||||
def get_all_emails(cls):
|
||||
return [email for email in cls]
|
||||
|
||||
|
||||
class DefaultUserValues(StrEnum):
|
||||
"""Stores default values for a default user.
|
||||
|
|
|
@ -241,6 +241,7 @@ class DsDataErrorCodes(IntEnum):
|
|||
- 3 INVALID_DIGEST_SHA256 invalid digest for digest type SHA-256
|
||||
- 4 INVALID_DIGEST_CHARS invalid chars in digest
|
||||
- 5 INVALID_KEYTAG_SIZE invalid key tag size > 65535
|
||||
- 6 INVALID_KEYTAG_CHARS invalid key tag, not numeric
|
||||
"""
|
||||
|
||||
BAD_DATA = 1
|
||||
|
@ -248,6 +249,7 @@ class DsDataErrorCodes(IntEnum):
|
|||
INVALID_DIGEST_SHA256 = 3
|
||||
INVALID_DIGEST_CHARS = 4
|
||||
INVALID_KEYTAG_SIZE = 5
|
||||
INVALID_KEYTAG_CHARS = 6
|
||||
|
||||
|
||||
class DsDataError(Exception):
|
||||
|
@ -263,7 +265,8 @@ class DsDataError(Exception):
|
|||
DsDataErrorCodes.INVALID_DIGEST_SHA1: ("SHA-1 digest must be exactly 40 characters."),
|
||||
DsDataErrorCodes.INVALID_DIGEST_SHA256: ("SHA-256 digest must be exactly 64 characters."),
|
||||
DsDataErrorCodes.INVALID_DIGEST_CHARS: ("Digest must contain only alphanumeric characters (0-9, a-f)."),
|
||||
DsDataErrorCodes.INVALID_KEYTAG_SIZE: ("Key tag must be less than 65535."),
|
||||
DsDataErrorCodes.INVALID_KEYTAG_SIZE: ("Enter a number between 0 and 65535."),
|
||||
DsDataErrorCodes.INVALID_KEYTAG_CHARS: ("Key tag must be numeric (0-9)."),
|
||||
}
|
||||
|
||||
def __init__(self, *args, code=None, **kwargs):
|
||||
|
|
|
@ -68,7 +68,11 @@ from epplibwrapper import (
|
|||
)
|
||||
|
||||
from ..utility.email import send_templated_email, EmailSendingError
|
||||
from ..utility.email_invitations import send_domain_invitation_email, send_portfolio_invitation_email
|
||||
from ..utility.email_invitations import (
|
||||
send_domain_invitation_email,
|
||||
send_domain_manager_removal_emails_to_domain_managers,
|
||||
send_portfolio_invitation_email,
|
||||
)
|
||||
from django import forms
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -398,7 +402,7 @@ class DomainView(DomainBaseView):
|
|||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
default_emails = [DefaultEmail.PUBLIC_CONTACT_DEFAULT.value, DefaultEmail.LEGACY_DEFAULT.value]
|
||||
default_emails = DefaultEmail.get_all_emails()
|
||||
|
||||
context["hidden_security_emails"] = default_emails
|
||||
|
||||
|
@ -456,7 +460,7 @@ class DomainRenewalView(DomainBaseView):
|
|||
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
default_emails = [DefaultEmail.PUBLIC_CONTACT_DEFAULT.value, DefaultEmail.LEGACY_DEFAULT.value]
|
||||
default_emails = DefaultEmail.get_all_emails()
|
||||
|
||||
context["hidden_security_emails"] = default_emails
|
||||
|
||||
|
@ -1060,10 +1064,6 @@ class DomainDsDataView(DomainFormBaseView):
|
|||
for record in dnssecdata.dsData
|
||||
)
|
||||
|
||||
# Ensure at least 1 record, filled or empty
|
||||
while len(initial_data) == 0:
|
||||
initial_data.append({})
|
||||
|
||||
return initial_data
|
||||
|
||||
def get_success_url(self):
|
||||
|
@ -1082,29 +1082,8 @@ class DomainDsDataView(DomainFormBaseView):
|
|||
"""Formset submission posts to this view."""
|
||||
self._get_domain(request)
|
||||
formset = self.get_form()
|
||||
override = False
|
||||
|
||||
# This is called by the form cancel button,
|
||||
# and also by the modal's X and cancel buttons
|
||||
if "btn-cancel-click" in request.POST:
|
||||
url = self.get_success_url()
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
# This is called by the Disable DNSSEC modal to override
|
||||
if "disable-override-click" in request.POST:
|
||||
override = True
|
||||
|
||||
# This is called when all DNSSEC data has been deleted and the
|
||||
# Save button is pressed
|
||||
if len(formset) == 0 and formset.initial != [{}] and override is False:
|
||||
# trigger the modal
|
||||
# get context data from super() rather than self
|
||||
# to preserve the context["form"]
|
||||
context = super().get_context_data(form=formset)
|
||||
context["trigger_modal"] = True
|
||||
return self.render_to_response(context)
|
||||
|
||||
if formset.is_valid() or override:
|
||||
if formset.is_valid():
|
||||
return self.form_valid(formset)
|
||||
else:
|
||||
return self.form_invalid(formset)
|
||||
|
@ -1116,11 +1095,12 @@ class DomainDsDataView(DomainFormBaseView):
|
|||
dnssecdata = extensions.DNSSECExtension()
|
||||
|
||||
for form in formset:
|
||||
if form.cleaned_data.get("DELETE"): # Check if form is marked for deletion
|
||||
continue # Skip processing this form
|
||||
|
||||
try:
|
||||
# if 'delete' not in form.cleaned_data
|
||||
# or form.cleaned_data['delete'] == False:
|
||||
dsrecord = {
|
||||
"keyTag": form.cleaned_data["key_tag"],
|
||||
"keyTag": int(form.cleaned_data["key_tag"]),
|
||||
"alg": int(form.cleaned_data["algorithm"]),
|
||||
"digestType": int(form.cleaned_data["digest_type"]),
|
||||
"digest": form.cleaned_data["digest"],
|
||||
|
@ -1166,7 +1146,7 @@ class DomainSecurityEmailView(DomainFormBaseView):
|
|||
initial = super().get_initial()
|
||||
security_contact = self.object.security_contact
|
||||
|
||||
invalid_emails = [DefaultEmail.PUBLIC_CONTACT_DEFAULT.value, DefaultEmail.LEGACY_DEFAULT.value]
|
||||
invalid_emails = DefaultEmail.get_all_emails()
|
||||
if security_contact is None or security_contact.email in invalid_emails:
|
||||
initial["security_email"] = None
|
||||
return initial
|
||||
|
@ -1474,48 +1454,17 @@ class DomainDeleteUserView(DeleteView):
|
|||
super().form_valid(form)
|
||||
|
||||
# Email all domain managers that domain manager has been removed
|
||||
domain = self.object.domain
|
||||
|
||||
context = {
|
||||
"domain": domain,
|
||||
"removed_by": self.request.user,
|
||||
"manager_removed": self.object.user,
|
||||
"date": date.today(),
|
||||
"changes": "Domain Manager",
|
||||
}
|
||||
self.email_domain_managers(
|
||||
domain,
|
||||
"emails/domain_manager_deleted_notification.txt",
|
||||
"emails/domain_manager_deleted_notification_subject.txt",
|
||||
context,
|
||||
send_domain_manager_removal_emails_to_domain_managers(
|
||||
removed_by_user=self.request.user,
|
||||
manager_removed=self.object.user,
|
||||
manager_removed_email=self.object.user.email,
|
||||
domain=self.object.domain,
|
||||
)
|
||||
|
||||
# Add a success message
|
||||
messages.success(self.request, self.get_success_message())
|
||||
return redirect(self.get_success_url())
|
||||
|
||||
def email_domain_managers(self, domain: Domain, template: str, subject_template: str, context={}):
|
||||
manager_pks = UserDomainRole.objects.filter(domain=domain.pk, role=UserDomainRole.Roles.MANAGER).values_list(
|
||||
"user", flat=True
|
||||
)
|
||||
emails = list(User.objects.filter(pk__in=manager_pks).values_list("email", flat=True))
|
||||
|
||||
for email in emails:
|
||||
try:
|
||||
send_templated_email(
|
||||
template,
|
||||
subject_template,
|
||||
to_address=email,
|
||||
context=context,
|
||||
)
|
||||
except EmailSendingError:
|
||||
logger.warning(
|
||||
"Could not send notification email to %s for domain %s",
|
||||
email,
|
||||
domain.name,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""Custom post implementation to ensure last userdomainrole is not removed and to
|
||||
redirect to home in the event that the user deletes themselves"""
|
||||
|
|
|
@ -227,7 +227,6 @@ class DomainRequestWizard(TemplateView):
|
|||
creator=self.request.user,
|
||||
portfolio=portfolio,
|
||||
)
|
||||
|
||||
# Question for reviewers: we should probably be doing this right?
|
||||
if portfolio and not self._domain_request.generic_org_type:
|
||||
self._domain_request.generic_org_type = portfolio.organization_type
|
||||
|
@ -598,14 +597,55 @@ class RequestingEntity(DomainRequestWizard):
|
|||
"suborganization_state_territory": None,
|
||||
}
|
||||
)
|
||||
|
||||
super().save(forms)
|
||||
|
||||
|
||||
class PortfolioAdditionalDetails(DomainRequestWizard):
|
||||
template_name = "portfolio_domain_request_additional_details.html"
|
||||
|
||||
forms = [forms.PortfolioAnythingElseForm]
|
||||
forms = [
|
||||
feb.WorkingWithEOPYesNoForm,
|
||||
feb.EOPContactForm,
|
||||
feb.FEBAnythingElseYesNoForm,
|
||||
forms.PortfolioAnythingElseForm,
|
||||
]
|
||||
|
||||
def get_context_data(self):
|
||||
context = super().get_context_data()
|
||||
context["requires_feb_questions"] = self.requires_feb_questions()
|
||||
return context
|
||||
|
||||
def is_valid(self, forms: list) -> bool:
|
||||
"""
|
||||
Validates the forms for portfolio additional details.
|
||||
|
||||
Expected order of forms_list:
|
||||
0: WorkingWithEOPYesNoForm
|
||||
1: EOPContactForm
|
||||
2: FEBAnythingElseYesNoForm
|
||||
3: PortfolioAnythingElseForm
|
||||
"""
|
||||
eop_forms_valid = True
|
||||
if not forms[0].is_valid():
|
||||
# If the user isn't working with EOP, don't validate the EOP contact form
|
||||
forms[1].mark_form_for_deletion()
|
||||
eop_forms_valid = False
|
||||
if forms[0].cleaned_data.get("working_with_eop"):
|
||||
eop_forms_valid = forms[1].is_valid()
|
||||
else:
|
||||
forms[1].mark_form_for_deletion()
|
||||
anything_else_forms_valid = True
|
||||
if not forms[2].is_valid():
|
||||
forms[3].mark_form_for_deletion()
|
||||
anything_else_forms_valid = False
|
||||
if forms[2].cleaned_data.get("has_anything_else_text"):
|
||||
forms[3].fields["anything_else"].required = True
|
||||
forms[3].fields["anything_else"].error_messages[
|
||||
"required"
|
||||
] = "Please provide additional details you'd like us to know. \
|
||||
If you have nothing to add, select 'No'."
|
||||
anything_else_forms_valid = forms[3].is_valid()
|
||||
return eop_forms_valid and anything_else_forms_valid
|
||||
|
||||
|
||||
# Non-portfolio pages
|
||||
|
@ -889,6 +929,29 @@ class Requirements(DomainRequestWizard):
|
|||
template_name = "domain_request_requirements.html"
|
||||
forms = [forms.RequirementsForm]
|
||||
|
||||
def get_context_data(self):
|
||||
context = super().get_context_data()
|
||||
context["requires_feb_questions"] = self.requires_feb_questions()
|
||||
return context
|
||||
|
||||
# Override the get_forms method to set the policy acknowledgement label conditionally based on feb status
|
||||
def get_forms(self, step=None, use_post=False, use_db=False, files=None):
|
||||
forms_list = super().get_forms(step, use_post, use_db, files)
|
||||
|
||||
# Pass the is_federal context to the form
|
||||
for form in forms_list:
|
||||
if isinstance(form, forms.RequirementsForm):
|
||||
if self.requires_feb_questions():
|
||||
form.fields["is_policy_acknowledged"].label = (
|
||||
"I read and understand the guidance outlined in the DOTGOV Act for operating a .gov domain." # noqa: E501
|
||||
)
|
||||
else:
|
||||
form.fields["is_policy_acknowledged"].label = (
|
||||
"I read and agree to the requirements for operating a .gov domain." # noqa: E501
|
||||
)
|
||||
|
||||
return forms_list
|
||||
|
||||
|
||||
class Review(DomainRequestWizard):
|
||||
template_name = "domain_request_review.html"
|
||||
|
@ -901,6 +964,7 @@ class Review(DomainRequestWizard):
|
|||
context = super().get_context_data()
|
||||
context["Step"] = self.get_step_enum().__members__
|
||||
context["domain_request"] = self.domain_request
|
||||
context["requires_feb_questions"] = self.requires_feb_questions()
|
||||
return context
|
||||
|
||||
def goto_next_step(self):
|
||||
|
@ -931,11 +995,9 @@ class Finished(DomainRequestWizard):
|
|||
forms = [] # type: ignore
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
context = self.get_context_data()
|
||||
context["domain_request_id"] = self.domain_request.id
|
||||
# clean up this wizard session, because we are done with it
|
||||
del self.storage
|
||||
return render(self.request, self.template_name, context)
|
||||
return render(self.request, self.template_name)
|
||||
|
||||
|
||||
@grant_access(IS_DOMAIN_REQUEST_CREATOR, HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT)
|
||||
|
|
|
@ -29,12 +29,14 @@ from registrar.models.utility.portfolio_helper import UserPortfolioPermissionCho
|
|||
from registrar.utility.email import EmailSendingError
|
||||
from registrar.utility.email_invitations import (
|
||||
send_domain_invitation_email,
|
||||
send_domain_manager_removal_emails_to_domain_managers,
|
||||
send_portfolio_admin_addition_emails,
|
||||
send_portfolio_admin_removal_emails,
|
||||
send_portfolio_invitation_email,
|
||||
send_portfolio_invitation_remove_email,
|
||||
send_portfolio_member_permission_remove_email,
|
||||
send_portfolio_member_permission_update_email,
|
||||
send_portfolio_update_emails_to_portfolio_admins,
|
||||
)
|
||||
from registrar.utility.errors import MissingEmailError
|
||||
from registrar.utility.enums import DefaultUserValues
|
||||
|
@ -193,6 +195,31 @@ class PortfolioMemberDeleteView(View):
|
|||
messages.warning(
|
||||
request, f"Could not send email notification to {portfolio_member_permission.user.email}"
|
||||
)
|
||||
|
||||
# Notify domain managers for domains which the member is being removed from
|
||||
# Get list of portfolio domains that the member is invited to:
|
||||
invited_domains = Domain.objects.filter(
|
||||
invitations__email=portfolio_member_permission.user.email,
|
||||
domain_info__portfolio=portfolio_member_permission.portfolio,
|
||||
invitations__status=DomainInvitation.DomainInvitationStatus.INVITED,
|
||||
).distinct()
|
||||
# Get list of portfolio domains that the member is a manager of
|
||||
domains = Domain.objects.filter(
|
||||
permissions__user=portfolio_member_permission.user,
|
||||
domain_info__portfolio=portfolio_member_permission.portfolio,
|
||||
).distinct()
|
||||
# Combine both querysets while ensuring uniqueness
|
||||
all_domains = domains.union(invited_domains)
|
||||
for domain in all_domains:
|
||||
if not send_domain_manager_removal_emails_to_domain_managers(
|
||||
removed_by_user=request.user,
|
||||
manager_removed=portfolio_member_permission.user,
|
||||
manager_removed_email=portfolio_member_permission.user.email,
|
||||
domain=domain,
|
||||
):
|
||||
messages.warning(
|
||||
request, "Could not send email notification to existing domain managers for %s", domain
|
||||
)
|
||||
except Exception as e:
|
||||
self._handle_exceptions(e)
|
||||
|
||||
|
@ -432,6 +459,20 @@ class PortfolioMemberDomainsEditView(DetailView, View):
|
|||
Processes removed domains by deleting corresponding UserDomainRole instances.
|
||||
"""
|
||||
if removed_domain_ids:
|
||||
# Notify domain managers for domains which the member is being removed from
|
||||
# Fetch Domain objects from removed_domain_ids
|
||||
removed_domains = Domain.objects.filter(id__in=removed_domain_ids)
|
||||
# need to get the domains from removed_domain_ids
|
||||
for domain in removed_domains:
|
||||
if not send_domain_manager_removal_emails_to_domain_managers(
|
||||
removed_by_user=self.request.user,
|
||||
manager_removed=member,
|
||||
manager_removed_email=member.email,
|
||||
domain=domain,
|
||||
):
|
||||
messages.warning(
|
||||
self.request, "Could not send email notification to existing domain managers for %s", domain
|
||||
)
|
||||
# Delete UserDomainRole instances for removed domains
|
||||
UserDomainRole.objects.filter(domain_id__in=removed_domain_ids, user=member).delete()
|
||||
|
||||
|
@ -502,6 +543,31 @@ class PortfolioInvitedMemberDeleteView(View):
|
|||
messages.warning(self.request, "Could not send email notification to existing organization admins.")
|
||||
if not send_portfolio_invitation_remove_email(requestor=request.user, invitation=portfolio_invitation):
|
||||
messages.warning(request, f"Could not send email notification to {portfolio_invitation.email}")
|
||||
|
||||
# Notify domain managers for domains which the invited member is being removed from
|
||||
# Get list of portfolio domains that the invited member is invited to:
|
||||
invited_domains = Domain.objects.filter(
|
||||
invitations__email=portfolio_invitation.email,
|
||||
domain_info__portfolio=portfolio_invitation.portfolio,
|
||||
invitations__status=DomainInvitation.DomainInvitationStatus.INVITED,
|
||||
).distinct()
|
||||
# Get list of portfolio domains that the member is a manager of
|
||||
domains = Domain.objects.filter(
|
||||
permissions__user__email=portfolio_invitation.email,
|
||||
domain_info__portfolio=portfolio_invitation.portfolio,
|
||||
).distinct()
|
||||
# Combine both querysets while ensuring uniqueness
|
||||
all_domains = domains.union(invited_domains)
|
||||
for domain in all_domains:
|
||||
if not send_domain_manager_removal_emails_to_domain_managers(
|
||||
removed_by_user=request.user,
|
||||
manager_removed=None,
|
||||
manager_removed_email=portfolio_invitation.email,
|
||||
domain=domain,
|
||||
):
|
||||
messages.warning(
|
||||
request, "Could not send email notification to existing domain managers for %s", domain
|
||||
)
|
||||
except Exception as e:
|
||||
self._handle_exceptions(e)
|
||||
|
||||
|
@ -740,6 +806,21 @@ class PortfolioInvitedMemberDomainsEditView(DetailView, View):
|
|||
if not removed_domain_ids:
|
||||
return
|
||||
|
||||
# Notify domain managers for domains which the member is being removed from
|
||||
# Fetch Domain objects from removed_domain_ids
|
||||
removed_domains = Domain.objects.filter(id__in=removed_domain_ids)
|
||||
# need to get the domains from removed_domain_ids
|
||||
for domain in removed_domains:
|
||||
if not send_domain_manager_removal_emails_to_domain_managers(
|
||||
removed_by_user=self.request.user,
|
||||
manager_removed=None,
|
||||
manager_removed_email=email,
|
||||
domain=domain,
|
||||
):
|
||||
messages.warning(
|
||||
self.request, "Could not send email notification to existing domain managers for %s", domain
|
||||
)
|
||||
|
||||
# Update invitations from INVITED to CANCELED
|
||||
DomainInvitation.objects.filter(
|
||||
domain_id__in=removed_domain_ids,
|
||||
|
@ -850,6 +931,20 @@ class PortfolioOrganizationView(DetailView, FormMixin):
|
|||
self.object = self.get_object()
|
||||
form = self.get_form()
|
||||
if form.is_valid():
|
||||
user = request.user
|
||||
try:
|
||||
if not send_portfolio_update_emails_to_portfolio_admins(
|
||||
editor=user, portfolio=self.request.session.get("portfolio"), updated_page="Organization"
|
||||
):
|
||||
messages.warning(self.request, "Could not send email notification to all organization admins.")
|
||||
except Exception as e:
|
||||
messages.error(
|
||||
request,
|
||||
f"An unexpected error occurred: {str(e)}. If the issue persists, "
|
||||
f"please contact {DefaultUserValues.HELP_EMAIL}.",
|
||||
)
|
||||
logger.error(f"An unexpected error occurred: {str(e)}.", exc_info=True)
|
||||
return None
|
||||
return self.form_valid(form)
|
||||
else:
|
||||
return self.form_invalid(form)
|
||||
|
@ -902,6 +997,45 @@ class PortfolioSeniorOfficialView(DetailView, FormMixin):
|
|||
form = self.get_form()
|
||||
return self.render_to_response(self.get_context_data(form=form))
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""Handle POST requests to process form submission."""
|
||||
self.object = self.get_object()
|
||||
form = self.get_form()
|
||||
if form.is_valid():
|
||||
user = request.user
|
||||
try:
|
||||
if not send_portfolio_update_emails_to_portfolio_admins(
|
||||
editor=user, portfolio=self.request.session.get("portfolio"), updated_page="Senior Official"
|
||||
):
|
||||
messages.warning(self.request, "Could not send email notification to all organization admins.")
|
||||
except Exception as e:
|
||||
messages.error(
|
||||
request,
|
||||
f"An unexpected error occurred: {str(e)}. If the issue persists, "
|
||||
f"please contact {DefaultUserValues.HELP_EMAIL}.",
|
||||
)
|
||||
logger.error(f"An unexpected error occurred: {str(e)}.", exc_info=True)
|
||||
return None
|
||||
return self.form_valid(form)
|
||||
else:
|
||||
return self.form_invalid(form)
|
||||
|
||||
def form_valid(self, form):
|
||||
"""Handle the case when the form is valid."""
|
||||
self.object = form.save(commit=False)
|
||||
self.object.creator = self.request.user
|
||||
self.object.save()
|
||||
messages.success(self.request, "The senior official information for this portfolio has been updated.")
|
||||
return super().form_valid(form)
|
||||
|
||||
def form_invalid(self, form):
|
||||
"""Handle the case when the form is invalid."""
|
||||
return self.render_to_response(self.get_context_data(form=form))
|
||||
|
||||
def get_success_url(self):
|
||||
"""Redirect to the overview page for the portfolio."""
|
||||
return reverse("senior-official")
|
||||
|
||||
|
||||
@grant_access(HAS_PORTFOLIO_MEMBERS_ANY_PERM)
|
||||
class PortfolioMembersView(View):
|
||||
|
@ -970,7 +1104,7 @@ class PortfolioAddMemberView(DetailView, FormMixin):
|
|||
portfolio = form.cleaned_data["portfolio"]
|
||||
is_admin_invitation = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in form.cleaned_data["roles"]
|
||||
|
||||
requested_user = User.objects.filter(email=requested_email).first()
|
||||
requested_user = User.objects.filter(email__iexact=requested_email).first()
|
||||
permission_exists = UserPortfolioPermission.objects.filter(user=requested_user, portfolio=portfolio).exists()
|
||||
try:
|
||||
if not requested_user or not permission_exists:
|
||||
|
|
|
@ -6,7 +6,7 @@ from django.shortcuts import render
|
|||
from django.contrib import admin
|
||||
from django.db.models import Avg, F
|
||||
|
||||
from registrar.decorators import ALL, HAS_PORTFOLIO_MEMBERS_VIEW, IS_STAFF, grant_access
|
||||
from registrar.decorators import ALL, HAS_PORTFOLIO_MEMBERS_VIEW, IS_CISA_ANALYST, IS_FULL_ACCESS, grant_access
|
||||
from .. import models
|
||||
import datetime
|
||||
from django.utils import timezone
|
||||
|
@ -16,7 +16,7 @@ import logging
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@grant_access(IS_STAFF)
|
||||
@grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS)
|
||||
class AnalyticsView(View):
|
||||
def get(self, request):
|
||||
thirty_days_ago = datetime.datetime.today() - datetime.timedelta(days=30)
|
||||
|
@ -176,7 +176,7 @@ class AnalyticsView(View):
|
|||
return render(request, "admin/analytics.html", context)
|
||||
|
||||
|
||||
@grant_access(IS_STAFF)
|
||||
@grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS)
|
||||
class ExportDataType(View):
|
||||
def get(self, request, *args, **kwargs):
|
||||
# match the CSV example with all the fields
|
||||
|
@ -227,7 +227,7 @@ class ExportMembersPortfolio(View):
|
|||
return response
|
||||
|
||||
|
||||
@grant_access(IS_STAFF)
|
||||
@grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS)
|
||||
class ExportDataFull(View):
|
||||
def get(self, request, *args, **kwargs):
|
||||
# Smaller export based on 1
|
||||
|
@ -237,7 +237,7 @@ class ExportDataFull(View):
|
|||
return response
|
||||
|
||||
|
||||
@grant_access(IS_STAFF)
|
||||
@grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS)
|
||||
class ExportDataFederal(View):
|
||||
def get(self, request, *args, **kwargs):
|
||||
# Federal only
|
||||
|
@ -247,7 +247,7 @@ class ExportDataFederal(View):
|
|||
return response
|
||||
|
||||
|
||||
@grant_access(IS_STAFF)
|
||||
@grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS)
|
||||
class ExportDomainRequestDataFull(View):
|
||||
"""Generates a downloaded report containing all Domain Requests (except started)"""
|
||||
|
||||
|
@ -259,7 +259,7 @@ class ExportDomainRequestDataFull(View):
|
|||
return response
|
||||
|
||||
|
||||
@grant_access(IS_STAFF)
|
||||
@grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS)
|
||||
class ExportDataDomainsGrowth(View):
|
||||
def get(self, request, *args, **kwargs):
|
||||
start_date = request.GET.get("start_date", "")
|
||||
|
@ -272,7 +272,7 @@ class ExportDataDomainsGrowth(View):
|
|||
return response
|
||||
|
||||
|
||||
@grant_access(IS_STAFF)
|
||||
@grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS)
|
||||
class ExportDataRequestsGrowth(View):
|
||||
def get(self, request, *args, **kwargs):
|
||||
start_date = request.GET.get("start_date", "")
|
||||
|
@ -285,7 +285,7 @@ class ExportDataRequestsGrowth(View):
|
|||
return response
|
||||
|
||||
|
||||
@grant_access(IS_STAFF)
|
||||
@grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS)
|
||||
class ExportDataManagedDomains(View):
|
||||
def get(self, request, *args, **kwargs):
|
||||
start_date = request.GET.get("start_date", "")
|
||||
|
@ -297,7 +297,7 @@ class ExportDataManagedDomains(View):
|
|||
return response
|
||||
|
||||
|
||||
@grant_access(IS_STAFF)
|
||||
@grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS)
|
||||
class ExportDataUnmanagedDomains(View):
|
||||
def get(self, request, *args, **kwargs):
|
||||
start_date = request.GET.get("start_date", "")
|
||||
|
|
|
@ -4,7 +4,7 @@ from django.db.models import ForeignKey, OneToOneField, ManyToManyField, ManyToO
|
|||
|
||||
from django.shortcuts import render, get_object_or_404, redirect
|
||||
from django.views import View
|
||||
from registrar.decorators import IS_STAFF, grant_access
|
||||
from registrar.decorators import IS_CISA_ANALYST, IS_FULL_ACCESS, grant_access
|
||||
from registrar.models.domain import Domain
|
||||
from registrar.models.domain_request import DomainRequest
|
||||
from registrar.models.user import User
|
||||
|
@ -19,7 +19,7 @@ from registrar.utility.db_helpers import ignore_unique_violation
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@grant_access(IS_STAFF)
|
||||
@grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS)
|
||||
class TransferUserView(View):
|
||||
"""Transfer user methods that set up the transfer_user template and handle the forms on it."""
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import logging
|
||||
from django.http import JsonResponse
|
||||
from django.forms.models import model_to_dict
|
||||
from registrar.decorators import IS_STAFF, grant_access
|
||||
from registrar.decorators import IS_CISA_ANALYST, IS_FULL_ACCESS, IS_OMB_ANALYST, grant_access
|
||||
from registrar.models import FederalAgency, SeniorOfficial, DomainRequest
|
||||
from registrar.utility.admin_helpers import get_action_needed_reason_default_email, get_rejection_reason_default_email
|
||||
from registrar.models.portfolio import Portfolio
|
||||
|
@ -10,16 +10,10 @@ from registrar.utility.constants import BranchChoices
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@grant_access(IS_STAFF)
|
||||
@grant_access(IS_CISA_ANALYST, IS_OMB_ANALYST, IS_FULL_ACCESS)
|
||||
def get_senior_official_from_federal_agency_json(request):
|
||||
"""Returns federal_agency information as a JSON"""
|
||||
|
||||
# This API is only accessible to admins and analysts
|
||||
superuser_perm = request.user.has_perm("registrar.full_access_permission")
|
||||
analyst_perm = request.user.has_perm("registrar.analyst_access_permission")
|
||||
if not request.user.is_authenticated or not any([analyst_perm, superuser_perm]):
|
||||
return JsonResponse({"error": "You do not have access to this resource"}, status=403)
|
||||
|
||||
agency_name = request.GET.get("agency_name")
|
||||
agency = FederalAgency.objects.filter(agency=agency_name).first()
|
||||
senior_official = SeniorOfficial.objects.filter(federal_agency=agency).first()
|
||||
|
@ -37,16 +31,10 @@ def get_senior_official_from_federal_agency_json(request):
|
|||
return JsonResponse({"error": "Senior Official not found"}, status=404)
|
||||
|
||||
|
||||
@grant_access(IS_STAFF)
|
||||
@grant_access(IS_CISA_ANALYST, IS_OMB_ANALYST, IS_FULL_ACCESS)
|
||||
def get_portfolio_json(request):
|
||||
"""Returns portfolio information as a JSON"""
|
||||
|
||||
# This API is only accessible to admins and analysts
|
||||
superuser_perm = request.user.has_perm("registrar.full_access_permission")
|
||||
analyst_perm = request.user.has_perm("registrar.analyst_access_permission")
|
||||
if not request.user.is_authenticated or not any([analyst_perm, superuser_perm]):
|
||||
return JsonResponse({"error": "You do not have access to this resource"}, status=403)
|
||||
|
||||
portfolio_id = request.GET.get("id")
|
||||
try:
|
||||
portfolio = Portfolio.objects.get(id=portfolio_id)
|
||||
|
@ -93,16 +81,10 @@ def get_portfolio_json(request):
|
|||
return JsonResponse(portfolio_dict)
|
||||
|
||||
|
||||
@grant_access(IS_STAFF)
|
||||
@grant_access(IS_CISA_ANALYST, IS_OMB_ANALYST, IS_FULL_ACCESS)
|
||||
def get_suborganization_list_json(request):
|
||||
"""Returns suborganization list information for a portfolio as a JSON"""
|
||||
|
||||
# This API is only accessible to admins and analysts
|
||||
superuser_perm = request.user.has_perm("registrar.full_access_permission")
|
||||
analyst_perm = request.user.has_perm("registrar.analyst_access_permission")
|
||||
if not request.user.is_authenticated or not any([analyst_perm, superuser_perm]):
|
||||
return JsonResponse({"error": "You do not have access to this resource"}, status=403)
|
||||
|
||||
portfolio_id = request.GET.get("portfolio_id")
|
||||
try:
|
||||
portfolio = Portfolio.objects.get(id=portfolio_id)
|
||||
|
@ -115,17 +97,11 @@ def get_suborganization_list_json(request):
|
|||
return JsonResponse({"results": results, "pagination": {"more": False}})
|
||||
|
||||
|
||||
@grant_access(IS_STAFF)
|
||||
@grant_access(IS_CISA_ANALYST, IS_OMB_ANALYST, IS_FULL_ACCESS)
|
||||
def get_federal_and_portfolio_types_from_federal_agency_json(request):
|
||||
"""Returns specific portfolio information as a JSON. Request must have
|
||||
both agency_name and organization_type."""
|
||||
|
||||
# This API is only accessible to admins and analysts
|
||||
superuser_perm = request.user.has_perm("registrar.full_access_permission")
|
||||
analyst_perm = request.user.has_perm("registrar.analyst_access_permission")
|
||||
if not request.user.is_authenticated or not any([analyst_perm, superuser_perm]):
|
||||
return JsonResponse({"error": "You do not have access to this resource"}, status=403)
|
||||
|
||||
federal_type = None
|
||||
portfolio_type = None
|
||||
|
||||
|
@ -143,16 +119,10 @@ def get_federal_and_portfolio_types_from_federal_agency_json(request):
|
|||
return JsonResponse(response_data)
|
||||
|
||||
|
||||
@grant_access(IS_STAFF)
|
||||
@grant_access(IS_CISA_ANALYST, IS_OMB_ANALYST, IS_FULL_ACCESS)
|
||||
def get_action_needed_email_for_user_json(request):
|
||||
"""Returns a default action needed email for a given user"""
|
||||
|
||||
# This API is only accessible to admins and analysts
|
||||
superuser_perm = request.user.has_perm("registrar.full_access_permission")
|
||||
analyst_perm = request.user.has_perm("registrar.analyst_access_permission")
|
||||
if not request.user.is_authenticated or not any([analyst_perm, superuser_perm]):
|
||||
return JsonResponse({"error": "You do not have access to this resource"}, status=403)
|
||||
|
||||
reason = request.GET.get("reason")
|
||||
domain_request_id = request.GET.get("domain_request_id")
|
||||
if not reason:
|
||||
|
@ -167,16 +137,10 @@ def get_action_needed_email_for_user_json(request):
|
|||
return JsonResponse({"email": email}, status=200)
|
||||
|
||||
|
||||
@grant_access(IS_STAFF)
|
||||
@grant_access(IS_CISA_ANALYST, IS_OMB_ANALYST, IS_FULL_ACCESS)
|
||||
def get_rejection_email_for_user_json(request):
|
||||
"""Returns a default rejection email for a given user"""
|
||||
|
||||
# This API is only accessible to admins and analysts
|
||||
superuser_perm = request.user.has_perm("registrar.full_access_permission")
|
||||
analyst_perm = request.user.has_perm("registrar.analyst_access_permission")
|
||||
if not request.user.is_authenticated or not any([analyst_perm, superuser_perm]):
|
||||
return JsonResponse({"error": "You do not have access to this resource"}, status=403)
|
||||
|
||||
reason = request.GET.get("reason")
|
||||
domain_request_id = request.GET.get("domain_request_id")
|
||||
if not reason:
|
||||
|
|
|
@ -1,68 +1,69 @@
|
|||
-i https://pypi.python.org/simple
|
||||
annotated-types==0.7.0; python_version >= '3.8'
|
||||
asgiref==3.8.1; python_version >= '3.8'
|
||||
boto3==1.35.91; python_version >= '3.8'
|
||||
botocore==1.35.91; python_version >= '3.8'
|
||||
cachetools==5.5.0; python_version >= '3.7'
|
||||
certifi==2024.12.14; python_version >= '3.6'
|
||||
boto3==1.37.18; python_version >= '3.8'
|
||||
botocore==1.37.18; python_version >= '3.8'
|
||||
cachetools==5.5.2; python_version >= '3.7'
|
||||
certifi==2025.1.31; python_version >= '3.6'
|
||||
cfenv==0.5.3
|
||||
cffi==1.17.1; python_version >= '3.8'
|
||||
charset-normalizer==3.4.1; python_version >= '3.7'
|
||||
cryptography==44.0.0; python_version >= '3.7' and python_full_version not in '3.9.0, 3.9.1'
|
||||
cryptography==44.0.2; python_version >= '3.7' and python_full_version not in '3.9.0, 3.9.1'
|
||||
defusedxml==0.7.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
|
||||
diff-match-patch==20241021; python_version >= '3.7'
|
||||
dj-database-url==2.3.0
|
||||
dj-email-url==1.0.6
|
||||
django==4.2.17; python_version >= '3.8'
|
||||
django==4.2.20; python_version >= '3.8'
|
||||
django-admin-multiple-choice-list-filter==0.1.1
|
||||
django-allow-cidr==0.7.1
|
||||
django-auditlog==3.0.0; python_version >= '3.8'
|
||||
django-cache-url==3.4.5
|
||||
django-cors-headers==4.6.0; python_version >= '3.9'
|
||||
django-cors-headers==4.7.0; python_version >= '3.9'
|
||||
django-csp==3.8
|
||||
django-fsm==2.8.1
|
||||
django-import-export==4.3.3; python_version >= '3.9'
|
||||
django-import-export==4.3.7; python_version >= '3.9'
|
||||
django-login-required-middleware==0.9.0
|
||||
django-phonenumber-field[phonenumberslite]==8.0.0; python_version >= '3.8'
|
||||
django-waffle==4.2.0; python_version >= '3.8'
|
||||
django-widget-tweaks==1.5.0; python_version >= '3.8'
|
||||
environs[django]==11.2.1; python_version >= '3.8'
|
||||
faker==33.1.0; python_version >= '3.8'
|
||||
fred-epplib @ git+https://github.com/cisagov/epplib.git@d56d183f1664f34c40ca9716a3a9a345f0ef561c
|
||||
furl==2.1.3
|
||||
environs[django]==14.1.1; python_version >= '3.9'
|
||||
faker==37.0.2; python_version >= '3.9'
|
||||
fred-epplib @ git+https://github.com/cisagov/epplib.git@9f0fd0e69665001767f15a034c9c0c919dab5cdd
|
||||
furl==2.1.4
|
||||
future==1.0.0; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'
|
||||
gevent==24.11.1; python_version >= '3.9'
|
||||
greenlet==3.1.1; python_version >= '3.7'
|
||||
gunicorn==23.0.0; python_version >= '3.7'
|
||||
idna==3.10; python_version >= '3.6'
|
||||
jmespath==1.0.1; python_version >= '3.7'
|
||||
lxml==5.3.0; python_version >= '3.6'
|
||||
mako==1.3.8; python_version >= '3.8'
|
||||
lxml==5.3.1; python_version >= '3.6'
|
||||
mako==1.3.9; python_version >= '3.8'
|
||||
markupsafe==3.0.2; python_version >= '3.9'
|
||||
marshmallow==3.23.2; python_version >= '3.9'
|
||||
marshmallow==3.26.1; python_version >= '3.9'
|
||||
oic==1.7.0; python_version ~= '3.8'
|
||||
orderedmultidict==1.0.1
|
||||
packaging==24.2; python_version >= '3.8'
|
||||
phonenumberslite==8.13.52
|
||||
phonenumberslite==9.0.1
|
||||
psycopg2-binary==2.9.10; python_version >= '3.8'
|
||||
pycparser==2.22; python_version >= '3.8'
|
||||
pycryptodomex==3.21.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'
|
||||
pydantic==2.10.4; python_version >= '3.8'
|
||||
pycryptodomex==3.22.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'
|
||||
pydantic==2.10.6; python_version >= '3.8'
|
||||
pydantic-core==2.27.2; python_version >= '3.8'
|
||||
pydantic-settings==2.7.1; python_version >= '3.8'
|
||||
pydantic-settings==2.8.1; python_version >= '3.8'
|
||||
pyjwkest==1.4.2
|
||||
python-dateutil==2.9.0.post0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'
|
||||
python-dotenv==1.0.1; python_version >= '3.8'
|
||||
pyzipper==0.3.6; python_version >= '3.4'
|
||||
requests==2.32.3; python_version >= '3.8'
|
||||
s3transfer==0.10.4; python_version >= '3.8'
|
||||
setuptools==75.6.0; python_version >= '3.9'
|
||||
s3transfer==0.11.4; python_version >= '3.8'
|
||||
setuptools==77.0.3; python_version >= '3.9'
|
||||
six==1.17.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'
|
||||
sqlparse==0.5.3; python_version >= '3.8'
|
||||
tablib==3.7.0; python_version >= '3.9'
|
||||
tablib==3.8.0; python_version >= '3.9'
|
||||
tblib==3.0.0; python_version >= '3.8'
|
||||
typing-extensions==4.12.2; python_version >= '3.8'
|
||||
tzdata==2025.1; python_version >= '2'
|
||||
urllib3==2.3.0; python_version >= '3.9'
|
||||
whitenoise==6.8.2; python_version >= '3.9'
|
||||
whitenoise==6.9.0; python_version >= '3.9'
|
||||
zope.event==5.0; python_version >= '3.7'
|
||||
zope.interface==7.2; python_version >= '3.8'
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue