mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-28 13:36:30 +02:00
Merge branch 'main' into dk/2524-admin-domain-show-info
This commit is contained in:
commit
6584eb1547
139 changed files with 5838 additions and 1692 deletions
|
@ -38,7 +38,7 @@ jobs:
|
||||||
id: check
|
id: check
|
||||||
uses: victoriadrake/django-security-check@master
|
uses: victoriadrake/django-security-check@master
|
||||||
- name: Upload output
|
- name: Upload output
|
||||||
uses: actions/upload-artifact@v2
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: security-check-output
|
name: security-check-output
|
||||||
path: output.txt
|
path: output.txt
|
||||||
|
|
1
.github/workflows/deploy-manual.yaml
vendored
1
.github/workflows/deploy-manual.yaml
vendored
|
@ -30,6 +30,7 @@ on:
|
||||||
- litterbox
|
- litterbox
|
||||||
- ms
|
- ms
|
||||||
- ad
|
- ad
|
||||||
|
- ag
|
||||||
# GitHub Actions has no "good" way yet to dynamically input branches
|
# GitHub Actions has no "good" way yet to dynamically input branches
|
||||||
branch:
|
branch:
|
||||||
description: 'Branch to deploy'
|
description: 'Branch to deploy'
|
||||||
|
|
2
.github/workflows/security-check.yaml
vendored
2
.github/workflows/security-check.yaml
vendored
|
@ -44,7 +44,7 @@ jobs:
|
||||||
id: check
|
id: check
|
||||||
uses: ./.github/actions/django-security-check
|
uses: ./.github/actions/django-security-check
|
||||||
- name: Upload output
|
- name: Upload output
|
||||||
uses: actions/upload-artifact@v2
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: security-check-output
|
name: security-check-output
|
||||||
path: ./src/output.txt
|
path: ./src/output.txt
|
||||||
|
|
|
@ -42,7 +42,6 @@ class DomainRequest {
|
||||||
creator (User)
|
creator (User)
|
||||||
investigator (User)
|
investigator (User)
|
||||||
senior_official (Contact)
|
senior_official (Contact)
|
||||||
submitter (Contact)
|
|
||||||
other_contacts (Contacts)
|
other_contacts (Contacts)
|
||||||
approved_domain (Domain)
|
approved_domain (Domain)
|
||||||
requested_domain (DraftDomain)
|
requested_domain (DraftDomain)
|
||||||
|
@ -80,7 +79,7 @@ class Contact {
|
||||||
--
|
--
|
||||||
}
|
}
|
||||||
|
|
||||||
DomainRequest *-r-* Contact : senior_official, submitter, other_contacts
|
DomainRequest *-r-* Contact : senior_official, other_contacts
|
||||||
|
|
||||||
class DraftDomain {
|
class DraftDomain {
|
||||||
Requested domain
|
Requested domain
|
||||||
|
|
|
@ -38,7 +38,6 @@ class "registrar.Contact <Registrar>" as registrar.Contact #d6f4e9 {
|
||||||
+ id (BigAutoField)
|
+ id (BigAutoField)
|
||||||
+ created_at (DateTimeField)
|
+ created_at (DateTimeField)
|
||||||
+ updated_at (DateTimeField)
|
+ updated_at (DateTimeField)
|
||||||
~ user (OneToOneField)
|
|
||||||
+ first_name (CharField)
|
+ first_name (CharField)
|
||||||
+ middle_name (CharField)
|
+ middle_name (CharField)
|
||||||
+ last_name (CharField)
|
+ last_name (CharField)
|
||||||
|
@ -47,7 +46,6 @@ class "registrar.Contact <Registrar>" as registrar.Contact #d6f4e9 {
|
||||||
+ phone (PhoneNumberField)
|
+ phone (PhoneNumberField)
|
||||||
--
|
--
|
||||||
}
|
}
|
||||||
registrar.Contact -- registrar.User
|
|
||||||
|
|
||||||
|
|
||||||
class "registrar.Host <Registrar>" as registrar.Host #d6f4e9 {
|
class "registrar.Host <Registrar>" as registrar.Host #d6f4e9 {
|
||||||
|
@ -143,6 +141,8 @@ class "registrar.FederalAgency <Registrar>" as registrar.FederalAgency #d6f4e9 {
|
||||||
+ updated_at (DateTimeField)
|
+ updated_at (DateTimeField)
|
||||||
+ agency (CharField)
|
+ agency (CharField)
|
||||||
+ federal_type (CharField)
|
+ federal_type (CharField)
|
||||||
|
+ initials (CharField)
|
||||||
|
+ is_fceb (BooleanField)
|
||||||
--
|
--
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -159,6 +159,7 @@ class "registrar.DomainRequest <Registrar>" as registrar.DomainRequest #d6f4e9 {
|
||||||
+ action_needed_reason_email (TextField)
|
+ action_needed_reason_email (TextField)
|
||||||
~ federal_agency (ForeignKey)
|
~ federal_agency (ForeignKey)
|
||||||
~ portfolio (ForeignKey)
|
~ portfolio (ForeignKey)
|
||||||
|
~ sub_organization (ForeignKey)
|
||||||
~ creator (ForeignKey)
|
~ creator (ForeignKey)
|
||||||
~ investigator (ForeignKey)
|
~ investigator (ForeignKey)
|
||||||
+ generic_org_type (CharField)
|
+ generic_org_type (CharField)
|
||||||
|
@ -179,7 +180,6 @@ class "registrar.DomainRequest <Registrar>" as registrar.DomainRequest #d6f4e9 {
|
||||||
~ senior_official (ForeignKey)
|
~ senior_official (ForeignKey)
|
||||||
~ approved_domain (OneToOneField)
|
~ approved_domain (OneToOneField)
|
||||||
~ requested_domain (OneToOneField)
|
~ requested_domain (OneToOneField)
|
||||||
~ submitter (ForeignKey)
|
|
||||||
+ purpose (TextField)
|
+ purpose (TextField)
|
||||||
+ no_other_contacts_rationale (TextField)
|
+ no_other_contacts_rationale (TextField)
|
||||||
+ anything_else (TextField)
|
+ anything_else (TextField)
|
||||||
|
@ -198,12 +198,12 @@ class "registrar.DomainRequest <Registrar>" as registrar.DomainRequest #d6f4e9 {
|
||||||
}
|
}
|
||||||
registrar.DomainRequest -- registrar.FederalAgency
|
registrar.DomainRequest -- registrar.FederalAgency
|
||||||
registrar.DomainRequest -- registrar.Portfolio
|
registrar.DomainRequest -- registrar.Portfolio
|
||||||
|
registrar.DomainRequest -- registrar.Suborganization
|
||||||
registrar.DomainRequest -- registrar.User
|
registrar.DomainRequest -- registrar.User
|
||||||
registrar.DomainRequest -- registrar.User
|
registrar.DomainRequest -- registrar.User
|
||||||
registrar.DomainRequest -- registrar.Contact
|
registrar.DomainRequest -- registrar.Contact
|
||||||
registrar.DomainRequest -- registrar.Domain
|
registrar.DomainRequest -- registrar.Domain
|
||||||
registrar.DomainRequest -- registrar.DraftDomain
|
registrar.DomainRequest -- registrar.DraftDomain
|
||||||
registrar.DomainRequest -- registrar.Contact
|
|
||||||
registrar.DomainRequest *--* registrar.Website
|
registrar.DomainRequest *--* registrar.Website
|
||||||
registrar.DomainRequest *--* registrar.Website
|
registrar.DomainRequest *--* registrar.Website
|
||||||
registrar.DomainRequest *--* registrar.Contact
|
registrar.DomainRequest *--* registrar.Contact
|
||||||
|
@ -218,6 +218,7 @@ class "registrar.DomainInformation <Registrar>" as registrar.DomainInformation #
|
||||||
~ federal_agency (ForeignKey)
|
~ federal_agency (ForeignKey)
|
||||||
~ creator (ForeignKey)
|
~ creator (ForeignKey)
|
||||||
~ portfolio (ForeignKey)
|
~ portfolio (ForeignKey)
|
||||||
|
~ sub_organization (ForeignKey)
|
||||||
~ domain_request (OneToOneField)
|
~ domain_request (OneToOneField)
|
||||||
+ generic_org_type (CharField)
|
+ generic_org_type (CharField)
|
||||||
+ organization_type (CharField)
|
+ organization_type (CharField)
|
||||||
|
@ -236,7 +237,6 @@ class "registrar.DomainInformation <Registrar>" as registrar.DomainInformation #
|
||||||
+ about_your_organization (TextField)
|
+ about_your_organization (TextField)
|
||||||
~ senior_official (ForeignKey)
|
~ senior_official (ForeignKey)
|
||||||
~ domain (OneToOneField)
|
~ domain (OneToOneField)
|
||||||
~ submitter (ForeignKey)
|
|
||||||
+ purpose (TextField)
|
+ purpose (TextField)
|
||||||
+ no_other_contacts_rationale (TextField)
|
+ no_other_contacts_rationale (TextField)
|
||||||
+ anything_else (TextField)
|
+ anything_else (TextField)
|
||||||
|
@ -253,10 +253,10 @@ class "registrar.DomainInformation <Registrar>" as registrar.DomainInformation #
|
||||||
registrar.DomainInformation -- registrar.FederalAgency
|
registrar.DomainInformation -- registrar.FederalAgency
|
||||||
registrar.DomainInformation -- registrar.User
|
registrar.DomainInformation -- registrar.User
|
||||||
registrar.DomainInformation -- registrar.Portfolio
|
registrar.DomainInformation -- registrar.Portfolio
|
||||||
|
registrar.DomainInformation -- registrar.Suborganization
|
||||||
registrar.DomainInformation -- registrar.DomainRequest
|
registrar.DomainInformation -- registrar.DomainRequest
|
||||||
registrar.DomainInformation -- registrar.Contact
|
registrar.DomainInformation -- registrar.Contact
|
||||||
registrar.DomainInformation -- registrar.Domain
|
registrar.DomainInformation -- registrar.Domain
|
||||||
registrar.DomainInformation -- registrar.Contact
|
|
||||||
registrar.DomainInformation *--* registrar.Contact
|
registrar.DomainInformation *--* registrar.Contact
|
||||||
|
|
||||||
|
|
||||||
|
@ -285,6 +285,38 @@ class "registrar.DomainInvitation <Registrar>" as registrar.DomainInvitation #d6
|
||||||
registrar.DomainInvitation -- registrar.Domain
|
registrar.DomainInvitation -- registrar.Domain
|
||||||
|
|
||||||
|
|
||||||
|
class "registrar.UserPortfolioPermission <Registrar>" as registrar.UserPortfolioPermission #d6f4e9 {
|
||||||
|
user portfolio permission
|
||||||
|
--
|
||||||
|
+ id (BigAutoField)
|
||||||
|
+ created_at (DateTimeField)
|
||||||
|
+ updated_at (DateTimeField)
|
||||||
|
~ user (ForeignKey)
|
||||||
|
~ portfolio (ForeignKey)
|
||||||
|
+ roles (ArrayField)
|
||||||
|
+ additional_permissions (ArrayField)
|
||||||
|
--
|
||||||
|
}
|
||||||
|
registrar.UserPortfolioPermission -- registrar.User
|
||||||
|
registrar.UserPortfolioPermission -- registrar.Portfolio
|
||||||
|
|
||||||
|
|
||||||
|
class "registrar.PortfolioInvitation <Registrar>" as registrar.PortfolioInvitation #d6f4e9 {
|
||||||
|
portfolio invitation
|
||||||
|
--
|
||||||
|
+ id (BigAutoField)
|
||||||
|
+ created_at (DateTimeField)
|
||||||
|
+ updated_at (DateTimeField)
|
||||||
|
+ email (EmailField)
|
||||||
|
~ portfolio (ForeignKey)
|
||||||
|
+ portfolio_roles (ArrayField)
|
||||||
|
+ portfolio_additional_permissions (ArrayField)
|
||||||
|
+ status (FSMField)
|
||||||
|
--
|
||||||
|
}
|
||||||
|
registrar.PortfolioInvitation -- registrar.Portfolio
|
||||||
|
|
||||||
|
|
||||||
class "registrar.TransitionDomain <Registrar>" as registrar.TransitionDomain #d6f4e9 {
|
class "registrar.TransitionDomain <Registrar>" as registrar.TransitionDomain #d6f4e9 {
|
||||||
transition domain
|
transition domain
|
||||||
--
|
--
|
||||||
|
@ -409,10 +441,11 @@ class "registrar.Portfolio <Registrar>" as registrar.Portfolio #d6f4e9 {
|
||||||
+ created_at (DateTimeField)
|
+ created_at (DateTimeField)
|
||||||
+ updated_at (DateTimeField)
|
+ updated_at (DateTimeField)
|
||||||
~ creator (ForeignKey)
|
~ creator (ForeignKey)
|
||||||
|
+ organization_name (CharField)
|
||||||
|
+ organization_type (CharField)
|
||||||
+ notes (TextField)
|
+ notes (TextField)
|
||||||
~ federal_agency (ForeignKey)
|
~ federal_agency (ForeignKey)
|
||||||
+ organization_type (CharField)
|
~ senior_official (ForeignKey)
|
||||||
+ organization_name (CharField)
|
|
||||||
+ address_line1 (CharField)
|
+ address_line1 (CharField)
|
||||||
+ address_line2 (CharField)
|
+ address_line2 (CharField)
|
||||||
+ city (CharField)
|
+ city (CharField)
|
||||||
|
@ -424,6 +457,7 @@ class "registrar.Portfolio <Registrar>" as registrar.Portfolio #d6f4e9 {
|
||||||
}
|
}
|
||||||
registrar.Portfolio -- registrar.User
|
registrar.Portfolio -- registrar.User
|
||||||
registrar.Portfolio -- registrar.FederalAgency
|
registrar.Portfolio -- registrar.FederalAgency
|
||||||
|
registrar.Portfolio -- registrar.SeniorOfficial
|
||||||
|
|
||||||
|
|
||||||
class "registrar.DomainGroup <Registrar>" as registrar.DomainGroup #d6f4e9 {
|
class "registrar.DomainGroup <Registrar>" as registrar.DomainGroup #d6f4e9 {
|
||||||
|
@ -454,7 +488,21 @@ class "registrar.Suborganization <Registrar>" as registrar.Suborganization #d6f4
|
||||||
registrar.Suborganization -- registrar.Portfolio
|
registrar.Suborganization -- registrar.Portfolio
|
||||||
|
|
||||||
|
|
||||||
@enduml
|
class "registrar.SeniorOfficial <Registrar>" as registrar.SeniorOfficial #d6f4e9 {
|
||||||
```
|
senior official
|
||||||
|
--
|
||||||
|
+ id (BigAutoField)
|
||||||
|
+ created_at (DateTimeField)
|
||||||
|
+ updated_at (DateTimeField)
|
||||||
|
+ first_name (CharField)
|
||||||
|
+ last_name (CharField)
|
||||||
|
+ title (CharField)
|
||||||
|
+ phone (PhoneNumberField)
|
||||||
|
+ email (EmailField)
|
||||||
|
~ federal_agency (ForeignKey)
|
||||||
|
--
|
||||||
|
}
|
||||||
|
registrar.SeniorOfficial -- registrar.FederalAgency
|
||||||
|
|
||||||
</details>
|
|
||||||
|
@enduml
|
||||||
|
|
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 130 KiB |
|
@ -97,6 +97,7 @@ While on production (the sandbox referred to as `stable`), an existing analyst o
|
||||||
"username": "<UUID here>",
|
"username": "<UUID here>",
|
||||||
"first_name": "",
|
"first_name": "",
|
||||||
"last_name": "",
|
"last_name": "",
|
||||||
|
"email": "",
|
||||||
},
|
},
|
||||||
...
|
...
|
||||||
]
|
]
|
||||||
|
@ -121,6 +122,7 @@ Analysts are a variant of the admin role with limited permissions. The process f
|
||||||
"username": "<UUID here>",
|
"username": "<UUID here>",
|
||||||
"first_name": "",
|
"first_name": "",
|
||||||
"last_name": "",
|
"last_name": "",
|
||||||
|
"email": "",
|
||||||
},
|
},
|
||||||
...
|
...
|
||||||
]
|
]
|
||||||
|
@ -131,6 +133,20 @@ Analysts are a variant of the admin role with limited permissions. The process f
|
||||||
|
|
||||||
Do note that if you wish to have both an analyst and admin account, append `-Analyst` to your first and last name, or use a completely different first/last name to avoid confusion. Example: `Bob-Analyst`
|
Do note that if you wish to have both an analyst and admin account, append `-Analyst` to your first and last name, or use a completely different first/last name to avoid confusion. Example: `Bob-Analyst`
|
||||||
|
|
||||||
|
## Adding an email address to the email whitelist (sandboxes only)
|
||||||
|
On all non-production environments, we use an email whitelist table (called `Allowed emails`). This whitelist is not case sensitive, and it provides an inclusion for +1 emails (like example.person+1@igorville.gov). The content after the `+` can be any _digit_. The whitelist checks for the "base" email (example.person) so even if you only have the +1 email defined, an email will still be sent assuming that it follows those conventions.
|
||||||
|
|
||||||
|
To add yourself to this, you can go about it in three ways.
|
||||||
|
|
||||||
|
Permanent (all sandboxes):
|
||||||
|
1. In src/registrar/fixtures_users.py, add the "email" field to your user in either the ADMIN or STAFF table.
|
||||||
|
2. In src/registrar/fixtures_users.py, add the desired email address to the `ADDITIONAL_ALLOWED_EMAILS` list. This route is suggested for product.
|
||||||
|
|
||||||
|
Sandbox specific (wiped when the db is reset):
|
||||||
|
3. Create a new record on the `Allowed emails` table with your email address. This can be done through django admin.
|
||||||
|
|
||||||
|
More detailed instructions regarding #3 can be found [here](https://docs.google.com/document/d/1ebIz4PcUuoiT7LlVy83EAyHAk_nWPEc99neMp4QjzDs).
|
||||||
|
|
||||||
## Adding to CODEOWNERS (optional)
|
## Adding to CODEOWNERS (optional)
|
||||||
|
|
||||||
The CODEOWNERS file sets the tagged individuals as default reviewers on any Pull Request that changes files that they are marked as owners of.
|
The CODEOWNERS file sets the tagged individuals as default reviewers on any Pull Request that changes files that they are marked as owners of.
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
- Starting Location: Home page
|
- Starting Location: Home page
|
||||||
- Workflow: (Domain requests Table) Manage domain
|
- Workflow: (Domain requests Table) Manage domain
|
||||||
- Workflow Step: Click "Manage" -> Click "Withdraw request" -> (confirmation prompt) -> Click "Withdraw request" (inside prompt)
|
- Workflow Step: Click "Manage" -> Click "Withdraw request" -> (confirmation prompt) -> Click "Withdraw request" (inside prompt)
|
||||||
- Notes: You can also do this through Django Admin by switching a domain of status "submitted" to "withdrawn", but you need to be the submitter (email listed on Your Contact Information).
|
- Notes: You can also do this through Django Admin by switching a domain of status "submitted" to "withdrawn", but you need to be the creator.
|
||||||
- [Email Content](https://github.com/cisagov/manage.get.gov/blob/main/src/registrar/templates/emails/domain_request_withdrawn.txt)
|
- [Email Content](https://github.com/cisagov/manage.get.gov/blob/main/src/registrar/templates/emails/domain_request_withdrawn.txt)
|
||||||
|
|
||||||
### Domain Request Withdrawn Subject
|
### Domain Request Withdrawn Subject
|
||||||
|
@ -25,7 +25,7 @@
|
||||||
- Starting Location: Django Admin
|
- Starting Location: Django Admin
|
||||||
- Workflow: Analyst Admin
|
- Workflow: Analyst Admin
|
||||||
- Workflow Step: Click "domain requests" -> Click a domain request in a status of "submitted", "In review", "rejected", or "ineligible" -> Click status dropdown -> (select "approved") -> click "Save"
|
- Workflow Step: Click "domain requests" -> Click a domain request in a status of "submitted", "In review", "rejected", or "ineligible" -> Click status dropdown -> (select "approved") -> click "Save"
|
||||||
- Notes: Note that this will send an email to the submitter (email listed on Your Contact Information). To test this with your own email, you need to create a domain request, then set the status to "approved". This will send you an email.
|
- Notes: Note that this will send an email to the creator. To test this with your own email, you need to create a domain request, then set the status to "approved". This will send you an email.
|
||||||
- [Email Content](https://github.com/cisagov/manage.get.gov/blob/main/src/registrar/templates/emails/status_change_approved.txt)
|
- [Email Content](https://github.com/cisagov/manage.get.gov/blob/main/src/registrar/templates/emails/status_change_approved.txt)
|
||||||
|
|
||||||
### Status Change Approved Subject
|
### Status Change Approved Subject
|
||||||
|
@ -36,7 +36,7 @@
|
||||||
- Starting Location: Django Admin
|
- Starting Location: Django Admin
|
||||||
- Workflow: Analyst Admin
|
- Workflow: Analyst Admin
|
||||||
- Workflow Step: Click "domain requests" -> Click a domain request in a status of "In review", or "approved" -> Click status dropdown -> (select "rejected") -> click "Save"
|
- Workflow Step: Click "domain requests" -> Click a domain request in a status of "In review", or "approved" -> Click status dropdown -> (select "rejected") -> click "Save"
|
||||||
- Notes: Note that this will send an email to the submitter (email listed on Your Contact Information). To test this with your own email, you need to create a domain request, then set the status to "in review" (and click save). Then, go back to the same application and set the status to "rejected". This will send you an email.
|
- Notes: Note that this will send an email to the creator. To test this with your own email, you need to create a domain request, then set the status to "in review" (and click save). Then, go back to the same application and set the status to "rejected". This will send you an email.
|
||||||
- [Email Content](https://github.com/cisagov/manage.get.gov/blob/main/src/registrar/templates/emails/status_change_rejected.txt)
|
- [Email Content](https://github.com/cisagov/manage.get.gov/blob/main/src/registrar/templates/emails/status_change_rejected.txt)
|
||||||
|
|
||||||
### Status Change Rejected Subject
|
### Status Change Rejected Subject
|
||||||
|
@ -47,7 +47,7 @@
|
||||||
- Starting Location: Home Page
|
- Starting Location: Home Page
|
||||||
- Workflow: Start domain request
|
- Workflow: Start domain request
|
||||||
- Workflow Step: Click "Start a new domain request" -> (fill out the form) -> On the last step ("Review and submit your domain request "), click "Submit your domain request"
|
- Workflow Step: Click "Start a new domain request" -> (fill out the form) -> On the last step ("Review and submit your domain request "), click "Submit your domain request"
|
||||||
- Notes: Note that this will send an email to the submitter (email listed on Your Contact Information)
|
- Notes: Note that this will send an email to the creator.
|
||||||
- [Email Content](https://github.com/cisagov/manage.get.gov/blob/main/src/registrar/templates/emails/submission_confirmation.txt)
|
- [Email Content](https://github.com/cisagov/manage.get.gov/blob/main/src/registrar/templates/emails/submission_confirmation.txt)
|
||||||
|
|
||||||
### Submission Confirmation Subject
|
### Submission Confirmation Subject
|
||||||
|
|
|
@ -62,4 +62,5 @@ The class provides the following optional configuration variables:
|
||||||
The class also provides helper methods:
|
The class also provides helper methods:
|
||||||
- `get_class_name`: Returns a display-friendly class name for the terminal prompt
|
- `get_class_name`: Returns a display-friendly class name for the terminal prompt
|
||||||
- `get_failure_message`: Returns the message to display if a record fails to update
|
- `get_failure_message`: Returns the message to display if a record fails to update
|
||||||
- `should_skip_record`: Defines the condition for skipping a record (by default, no records are skipped)
|
- `should_skip_record`: Defines the condition for skipping a record (by default, no records are skipped)
|
||||||
|
- `custom_filter`: Allows for additional filters that cannot be expressed using django queryset field lookups
|
|
@ -816,3 +816,99 @@ Example: `cf ssh getgov-za`
|
||||||
| | Parameter | Description |
|
| | Parameter | Description |
|
||||||
|:-:|:-------------------------- |:-----------------------------------------------------------------------------------|
|
|:-:|:-------------------------- |:-----------------------------------------------------------------------------------|
|
||||||
| 1 | **federal_cio_csv_path** | Specifies where the federal CIO csv is |
|
| 1 | **federal_cio_csv_path** | Specifies where the federal CIO csv is |
|
||||||
|
|
||||||
|
## Update First Ready Values
|
||||||
|
This section outlines how to run the populate_first_ready script
|
||||||
|
|
||||||
|
### Running on sandboxes
|
||||||
|
|
||||||
|
#### Step 1: Login to CloudFoundry
|
||||||
|
```cf login -a api.fr.cloud.gov --sso```
|
||||||
|
|
||||||
|
#### Step 2: SSH into your environment
|
||||||
|
```cf ssh getgov-{space}```
|
||||||
|
|
||||||
|
Example: `cf ssh getgov-za`
|
||||||
|
|
||||||
|
#### Step 3: Create a shell instance
|
||||||
|
```/tmp/lifecycle/shell```
|
||||||
|
|
||||||
|
#### Step 4: Running the script
|
||||||
|
```./manage.py update_first_ready```
|
||||||
|
|
||||||
|
### Running locally
|
||||||
|
```docker-compose exec app ./manage.py update_first_ready```
|
||||||
|
|
||||||
|
## Populate Domain Request Dates
|
||||||
|
This section outlines how to run the populate_domain_request_dates script
|
||||||
|
|
||||||
|
### Running on sandboxes
|
||||||
|
|
||||||
|
#### Step 1: Login to CloudFoundry
|
||||||
|
```cf login -a api.fr.cloud.gov --sso```
|
||||||
|
|
||||||
|
#### Step 2: SSH into your environment
|
||||||
|
```cf ssh getgov-{space}```
|
||||||
|
|
||||||
|
Example: `cf ssh getgov-za`
|
||||||
|
|
||||||
|
#### Step 3: Create a shell instance
|
||||||
|
```/tmp/lifecycle/shell```
|
||||||
|
|
||||||
|
#### Step 4: Running the script
|
||||||
|
```./manage.py populate_domain_request_dates```
|
||||||
|
|
||||||
|
### Running locally
|
||||||
|
```docker-compose exec app ./manage.py populate_domain_request_dates```
|
||||||
|
|
||||||
|
## Create federal portfolio
|
||||||
|
This script takes the name of a `FederalAgency` (like 'AMTRAK') and does the following:
|
||||||
|
1. Creates the portfolio record based off of data on the federal agency object itself.
|
||||||
|
2. Creates suborganizations from existing DomainInformation records.
|
||||||
|
3. Associates the SeniorOfficial record (if it exists).
|
||||||
|
4. Adds this portfolio to DomainInformation / DomainRequests or both.
|
||||||
|
|
||||||
|
Errors:
|
||||||
|
1. ValueError: Federal agency not found in database.
|
||||||
|
2. Logged Warning: No senior official found for portfolio
|
||||||
|
3. Logged Error: No suborganizations found for portfolio.
|
||||||
|
4. Logged Warning: No new suborganizations to add.
|
||||||
|
5. Logged Warning: No valid DomainRequest records to update.
|
||||||
|
6. Logged Warning: No valid DomainInformation records to update.
|
||||||
|
|
||||||
|
### Running on sandboxes
|
||||||
|
|
||||||
|
#### Step 1: Login to CloudFoundry
|
||||||
|
```cf login -a api.fr.cloud.gov --sso```
|
||||||
|
|
||||||
|
#### Step 2: SSH into your environment
|
||||||
|
```cf ssh getgov-{space}```
|
||||||
|
|
||||||
|
Example: `cf ssh getgov-za`
|
||||||
|
|
||||||
|
#### Step 3: Create a shell instance
|
||||||
|
```/tmp/lifecycle/shell```
|
||||||
|
|
||||||
|
#### Step 4: Upload your csv to the desired sandbox
|
||||||
|
[Follow these steps](#use-scp-to-transfer-data-to-sandboxes) to upload the federal_cio csv to a sandbox of your choice.
|
||||||
|
|
||||||
|
#### Step 5: Running the script
|
||||||
|
```./manage.py create_federal_portfolio "{federal_agency_name}" --both```
|
||||||
|
|
||||||
|
Example (only requests): `./manage.py create_federal_portfolio "AMTRAK" --parse_requests`
|
||||||
|
|
||||||
|
### Running locally
|
||||||
|
|
||||||
|
#### Step 1: Running the script
|
||||||
|
```docker-compose exec app ./manage.py create_federal_portfolio "{federal_agency_name}" --both```
|
||||||
|
|
||||||
|
##### Parameters
|
||||||
|
| | Parameter | Description |
|
||||||
|
|:-:|:-------------------------- |:-------------------------------------------------------------------------------------------|
|
||||||
|
| 1 | **federal_agency_name** | Name of the FederalAgency record surrounded by quotes. For instance,"AMTRAK". |
|
||||||
|
| 2 | **both** | If True, runs parse_requests and parse_domains. |
|
||||||
|
| 3 | **parse_requests** | If True, then the created portfolio is added to all related DomainRequests. |
|
||||||
|
| 4 | **parse_domains** | If True, then the created portfolio is added to all related Domains. |
|
||||||
|
|
||||||
|
Note: Regarding parameters #2-#3, you cannot use `--both` while using these. You must specify either `--parse_requests` or `--parse_domains` seperately. While all of these parameters are optional in that you do not need to specify all of them,
|
||||||
|
you must specify at least one to run this script.
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings # type: ignore
|
||||||
|
|
||||||
|
|
||||||
class Cert:
|
class Cert:
|
||||||
|
@ -12,7 +12,7 @@ class Cert:
|
||||||
variable but Python's ssl library requires a file.
|
variable but Python's ssl library requires a file.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, data=settings.SECRET_REGISTRY_CERT) -> None:
|
def __init__(self, data=settings.SECRET_REGISTRY_CERT) -> None: # type: ignore
|
||||||
self.filename = self._write(data)
|
self.filename = self._write(data)
|
||||||
|
|
||||||
def __del__(self):
|
def __del__(self):
|
||||||
|
@ -31,4 +31,4 @@ class Key(Cert):
|
||||||
"""Location of private key as written to disk."""
|
"""Location of private key as written to disk."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__(data=settings.SECRET_REGISTRY_KEY)
|
super().__init__(data=settings.SECRET_REGISTRY_KEY) # type: ignore
|
||||||
|
|
|
@ -7,9 +7,11 @@ from django import forms
|
||||||
from django.db.models import Value, CharField, Q
|
from django.db.models import Value, CharField, Q
|
||||||
from django.db.models.functions import Concat, Coalesce
|
from django.db.models.functions import Concat, Coalesce
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect
|
||||||
|
from django.conf import settings
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django_fsm import get_available_FIELD_transitions, FSMField
|
from django_fsm import get_available_FIELD_transitions, FSMField
|
||||||
from registrar.models.domain_information import DomainInformation
|
from registrar.models.domain_information import DomainInformation
|
||||||
|
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||||
from waffle.decorators import flag_is_active
|
from waffle.decorators import flag_is_active
|
||||||
from django.contrib import admin, messages
|
from django.contrib import admin, messages
|
||||||
|
@ -34,6 +36,7 @@ from django_fsm import TransitionNotAllowed # type: ignore
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
from django.contrib.auth.forms import UserChangeForm, UsernameField
|
from django.contrib.auth.forms import UserChangeForm, UsernameField
|
||||||
|
from django.contrib.admin.views.main import IGNORED_PARAMS
|
||||||
from django_admin_multiple_choice_list_filter.list_filters import MultipleChoiceListFilter
|
from django_admin_multiple_choice_list_filter.list_filters import MultipleChoiceListFilter
|
||||||
from import_export import resources
|
from import_export import resources
|
||||||
from import_export.admin import ImportExportModelAdmin
|
from import_export.admin import ImportExportModelAdmin
|
||||||
|
@ -131,14 +134,6 @@ class MyUserAdminForm(UserChangeForm):
|
||||||
widgets = {
|
widgets = {
|
||||||
"groups": NoAutocompleteFilteredSelectMultiple("groups", False),
|
"groups": NoAutocompleteFilteredSelectMultiple("groups", False),
|
||||||
"user_permissions": NoAutocompleteFilteredSelectMultiple("user_permissions", False),
|
"user_permissions": NoAutocompleteFilteredSelectMultiple("user_permissions", False),
|
||||||
"portfolio_roles": FilteredSelectMultipleArrayWidget(
|
|
||||||
"portfolio_roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices
|
|
||||||
),
|
|
||||||
"portfolio_additional_permissions": FilteredSelectMultipleArrayWidget(
|
|
||||||
"portfolio_additional_permissions",
|
|
||||||
is_stacked=False,
|
|
||||||
choices=UserPortfolioPermissionChoices.choices,
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
@ -170,6 +165,22 @@ class MyUserAdminForm(UserChangeForm):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserPortfolioPermissionsForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = models.UserPortfolioPermission
|
||||||
|
fields = "__all__"
|
||||||
|
widgets = {
|
||||||
|
"roles": FilteredSelectMultipleArrayWidget(
|
||||||
|
"roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices
|
||||||
|
),
|
||||||
|
"additional_permissions": FilteredSelectMultipleArrayWidget(
|
||||||
|
"additional_permissions",
|
||||||
|
is_stacked=False,
|
||||||
|
choices=UserPortfolioPermissionChoices.choices,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class PortfolioInvitationAdminForm(UserChangeForm):
|
class PortfolioInvitationAdminForm(UserChangeForm):
|
||||||
"""This form utilizes the custom widget for its class's ManyToMany UIs."""
|
"""This form utilizes the custom widget for its class's ManyToMany UIs."""
|
||||||
|
|
||||||
|
@ -223,7 +234,7 @@ class DomainRequestAdminForm(forms.ModelForm):
|
||||||
"other_contacts": NoAutocompleteFilteredSelectMultiple("other_contacts", False),
|
"other_contacts": NoAutocompleteFilteredSelectMultiple("other_contacts", False),
|
||||||
}
|
}
|
||||||
labels = {
|
labels = {
|
||||||
"action_needed_reason_email": "Auto-generated email",
|
"action_needed_reason_email": "Email",
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
@ -366,7 +377,9 @@ class DomainRequestAdminForm(forms.ModelForm):
|
||||||
class MultiFieldSortableChangeList(admin.views.main.ChangeList):
|
class MultiFieldSortableChangeList(admin.views.main.ChangeList):
|
||||||
"""
|
"""
|
||||||
This class overrides the behavior of column sorting in django admin tables in order
|
This class overrides the behavior of column sorting in django admin tables in order
|
||||||
to allow for multi field sorting on admin_order_field
|
to allow for multi field sorting on admin_order_field. It also overrides behavior
|
||||||
|
of getting the filter params to allow portfolio filters to be executed without
|
||||||
|
displaying on the right side of the ChangeList view.
|
||||||
|
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
|
@ -428,6 +441,24 @@ class MultiFieldSortableChangeList(admin.views.main.ChangeList):
|
||||||
|
|
||||||
return ordering
|
return ordering
|
||||||
|
|
||||||
|
def get_filters_params(self, params=None):
|
||||||
|
"""
|
||||||
|
Add portfolio to ignored params to allow the portfolio filter while not
|
||||||
|
listing it as a filter option on the right side of Change List on the
|
||||||
|
portfolio list.
|
||||||
|
"""
|
||||||
|
params = params or self.params
|
||||||
|
lookup_params = params.copy() # a dictionary of the query string
|
||||||
|
# Remove all the parameters that are globally and systematically
|
||||||
|
# ignored.
|
||||||
|
# Remove portfolio so that it does not error as an invalid
|
||||||
|
# filter parameter.
|
||||||
|
ignored_params = list(IGNORED_PARAMS) + ["portfolio"]
|
||||||
|
for ignored in ignored_params:
|
||||||
|
if ignored in lookup_params:
|
||||||
|
del lookup_params[ignored]
|
||||||
|
return lookup_params
|
||||||
|
|
||||||
|
|
||||||
class CustomLogEntryAdmin(LogEntryAdmin):
|
class CustomLogEntryAdmin(LogEntryAdmin):
|
||||||
"""Overwrite the generated LogEntry admin class"""
|
"""Overwrite the generated LogEntry admin class"""
|
||||||
|
@ -505,7 +536,6 @@ class AdminSortFields:
|
||||||
sort_mapping = {
|
sort_mapping = {
|
||||||
# == Contact == #
|
# == Contact == #
|
||||||
"other_contacts": (Contact, _name_sort),
|
"other_contacts": (Contact, _name_sort),
|
||||||
"submitter": (Contact, _name_sort),
|
|
||||||
# == Senior Official == #
|
# == Senior Official == #
|
||||||
"senior_official": (SeniorOfficial, _name_sort),
|
"senior_official": (SeniorOfficial, _name_sort),
|
||||||
# == User == #
|
# == User == #
|
||||||
|
@ -644,6 +674,19 @@ class ListHeaderAdmin(AuditedAdmin, OrderableFieldsMixin):
|
||||||
)
|
)
|
||||||
except models.User.DoesNotExist:
|
except models.User.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
elif parameter_name == "portfolio":
|
||||||
|
# Retrieves the corresponding portfolio from Portfolio
|
||||||
|
id_value = request.GET.get(param)
|
||||||
|
try:
|
||||||
|
portfolio = models.Portfolio.objects.get(id=id_value)
|
||||||
|
filters.append(
|
||||||
|
{
|
||||||
|
"parameter_name": "portfolio",
|
||||||
|
"parameter_value": portfolio.organization_name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except models.Portfolio.DoesNotExist:
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
# For other parameter names, append a dictionary with the original
|
# For other parameter names, append a dictionary with the original
|
||||||
# parameter_name and the corresponding parameter_value
|
# parameter_name and the corresponding parameter_value
|
||||||
|
@ -710,19 +753,12 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
|
||||||
"is_superuser",
|
"is_superuser",
|
||||||
"groups",
|
"groups",
|
||||||
"user_permissions",
|
"user_permissions",
|
||||||
"portfolio",
|
|
||||||
"portfolio_roles",
|
|
||||||
"portfolio_additional_permissions",
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
("Important dates", {"fields": ("last_login", "date_joined")}),
|
("Important dates", {"fields": ("last_login", "date_joined")}),
|
||||||
)
|
)
|
||||||
|
|
||||||
autocomplete_fields = [
|
|
||||||
"portfolio",
|
|
||||||
]
|
|
||||||
|
|
||||||
readonly_fields = ("verification_type",)
|
readonly_fields = ("verification_type",)
|
||||||
|
|
||||||
analyst_fieldsets = (
|
analyst_fieldsets = (
|
||||||
|
@ -742,9 +778,6 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
|
||||||
"fields": (
|
"fields": (
|
||||||
"is_active",
|
"is_active",
|
||||||
"groups",
|
"groups",
|
||||||
"portfolio",
|
|
||||||
"portfolio_roles",
|
|
||||||
"portfolio_additional_permissions",
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
@ -799,9 +832,6 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
|
||||||
"Important dates",
|
"Important dates",
|
||||||
"last_login",
|
"last_login",
|
||||||
"date_joined",
|
"date_joined",
|
||||||
"portfolio",
|
|
||||||
"portfolio_roles",
|
|
||||||
"portfolio_additional_permissions",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# TODO: delete after we merge organization feature
|
# TODO: delete after we merge organization feature
|
||||||
|
@ -932,7 +962,9 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
|
||||||
domain_ids = user_domain_roles.values_list("domain_id", flat=True)
|
domain_ids = user_domain_roles.values_list("domain_id", flat=True)
|
||||||
domains = Domain.objects.filter(id__in=domain_ids).exclude(state=Domain.State.DELETED)
|
domains = Domain.objects.filter(id__in=domain_ids).exclude(state=Domain.State.DELETED)
|
||||||
|
|
||||||
extra_context = {"domain_requests": domain_requests, "domains": domains}
|
portfolio_ids = obj.get_portfolios().values_list("portfolio", flat=True)
|
||||||
|
portfolios = models.Portfolio.objects.filter(id__in=portfolio_ids)
|
||||||
|
extra_context = {"domain_requests": domain_requests, "domains": domains, "portfolios": portfolios}
|
||||||
return super().change_view(request, object_id, form_url, extra_context)
|
return super().change_view(request, object_id, form_url, extra_context)
|
||||||
|
|
||||||
|
|
||||||
|
@ -1210,6 +1242,26 @@ class UserDomainRoleResource(resources.ModelResource):
|
||||||
model = models.UserDomainRole
|
model = models.UserDomainRole
|
||||||
|
|
||||||
|
|
||||||
|
class UserPortfolioPermissionAdmin(ListHeaderAdmin):
|
||||||
|
form = UserPortfolioPermissionsForm
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
"""Contains meta information about this class"""
|
||||||
|
|
||||||
|
model = models.UserPortfolioPermission
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
_meta = Meta()
|
||||||
|
|
||||||
|
# Columns
|
||||||
|
list_display = [
|
||||||
|
"user",
|
||||||
|
"portfolio",
|
||||||
|
]
|
||||||
|
|
||||||
|
autocomplete_fields = ["user", "portfolio"]
|
||||||
|
|
||||||
|
|
||||||
class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
"""Custom user domain role admin class."""
|
"""Custom user domain role admin class."""
|
||||||
|
|
||||||
|
@ -1390,13 +1442,9 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
"domain",
|
"domain",
|
||||||
"generic_org_type",
|
"generic_org_type",
|
||||||
"created_at",
|
"created_at",
|
||||||
"submitter",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
orderable_fk_fields = [
|
orderable_fk_fields = [("domain", "name")]
|
||||||
("domain", "name"),
|
|
||||||
("submitter", ["first_name", "last_name"]),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Filters
|
# Filters
|
||||||
list_filter = ["generic_org_type"]
|
list_filter = ["generic_org_type"]
|
||||||
|
@ -1408,7 +1456,7 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
search_help_text = "Search by domain."
|
search_help_text = "Search by domain."
|
||||||
|
|
||||||
fieldsets = [
|
fieldsets = [
|
||||||
(None, {"fields": ["portfolio", "sub_organization", "creator", "submitter", "domain_request", "notes"]}),
|
(None, {"fields": ["portfolio", "sub_organization", "creator", "domain_request", "notes"]}),
|
||||||
(".gov domain", {"fields": ["domain"]}),
|
(".gov domain", {"fields": ["domain"]}),
|
||||||
("Contacts", {"fields": ["senior_official", "other_contacts", "no_other_contacts_rationale"]}),
|
("Contacts", {"fields": ["senior_official", "other_contacts", "no_other_contacts_rationale"]}),
|
||||||
("Background info", {"fields": ["anything_else"]}),
|
("Background info", {"fields": ["anything_else"]}),
|
||||||
|
@ -1472,7 +1520,6 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
"more_organization_information",
|
"more_organization_information",
|
||||||
"domain",
|
"domain",
|
||||||
"domain_request",
|
"domain_request",
|
||||||
"submitter",
|
|
||||||
"no_other_contacts_rationale",
|
"no_other_contacts_rationale",
|
||||||
"anything_else",
|
"anything_else",
|
||||||
"is_policy_acknowledged",
|
"is_policy_acknowledged",
|
||||||
|
@ -1487,7 +1534,6 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
"domain_request",
|
"domain_request",
|
||||||
"senior_official",
|
"senior_official",
|
||||||
"domain",
|
"domain",
|
||||||
"submitter",
|
|
||||||
"portfolio",
|
"portfolio",
|
||||||
"sub_organization",
|
"sub_organization",
|
||||||
]
|
]
|
||||||
|
@ -1649,7 +1695,9 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
# Columns
|
# Columns
|
||||||
list_display = [
|
list_display = [
|
||||||
"requested_domain",
|
"requested_domain",
|
||||||
"submission_date",
|
"first_submitted_date",
|
||||||
|
"last_submitted_date",
|
||||||
|
"last_status_update",
|
||||||
"status",
|
"status",
|
||||||
"generic_org_type",
|
"generic_org_type",
|
||||||
"federal_type",
|
"federal_type",
|
||||||
|
@ -1658,13 +1706,11 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
"custom_election_board",
|
"custom_election_board",
|
||||||
"city",
|
"city",
|
||||||
"state_territory",
|
"state_territory",
|
||||||
"submitter",
|
|
||||||
"investigator",
|
"investigator",
|
||||||
]
|
]
|
||||||
|
|
||||||
orderable_fk_fields = [
|
orderable_fk_fields = [
|
||||||
("requested_domain", "name"),
|
("requested_domain", "name"),
|
||||||
("submitter", ["first_name", "last_name"]),
|
|
||||||
("investigator", ["first_name", "last_name"]),
|
("investigator", ["first_name", "last_name"]),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -1694,11 +1740,11 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
# Search
|
# Search
|
||||||
search_fields = [
|
search_fields = [
|
||||||
"requested_domain__name",
|
"requested_domain__name",
|
||||||
"submitter__email",
|
"creator__email",
|
||||||
"submitter__first_name",
|
"creator__first_name",
|
||||||
"submitter__last_name",
|
"creator__last_name",
|
||||||
]
|
]
|
||||||
search_help_text = "Search by domain or submitter."
|
search_help_text = "Search by domain or creator."
|
||||||
|
|
||||||
fieldsets = [
|
fieldsets = [
|
||||||
(
|
(
|
||||||
|
@ -1714,7 +1760,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
"action_needed_reason_email",
|
"action_needed_reason_email",
|
||||||
"investigator",
|
"investigator",
|
||||||
"creator",
|
"creator",
|
||||||
"submitter",
|
|
||||||
"approved_domain",
|
"approved_domain",
|
||||||
"notes",
|
"notes",
|
||||||
]
|
]
|
||||||
|
@ -1802,7 +1847,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
"approved_domain",
|
"approved_domain",
|
||||||
"alternative_domains",
|
"alternative_domains",
|
||||||
"purpose",
|
"purpose",
|
||||||
"submitter",
|
|
||||||
"no_other_contacts_rationale",
|
"no_other_contacts_rationale",
|
||||||
"anything_else",
|
"anything_else",
|
||||||
"is_policy_acknowledged",
|
"is_policy_acknowledged",
|
||||||
|
@ -1813,7 +1857,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
autocomplete_fields = [
|
autocomplete_fields = [
|
||||||
"approved_domain",
|
"approved_domain",
|
||||||
"requested_domain",
|
"requested_domain",
|
||||||
"submitter",
|
|
||||||
"creator",
|
"creator",
|
||||||
"senior_official",
|
"senior_official",
|
||||||
"investigator",
|
"investigator",
|
||||||
|
@ -1852,7 +1895,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
# Table ordering
|
# Table ordering
|
||||||
# NOTE: This impacts the select2 dropdowns (combobox)
|
# NOTE: This impacts the select2 dropdowns (combobox)
|
||||||
# Currentl, there's only one for requests on DomainInfo
|
# Currentl, there's only one for requests on DomainInfo
|
||||||
ordering = ["-submission_date", "requested_domain__name"]
|
ordering = ["-last_submitted_date", "requested_domain__name"]
|
||||||
|
|
||||||
change_form_template = "django/admin/domain_request_change_form.html"
|
change_form_template = "django/admin/domain_request_change_form.html"
|
||||||
|
|
||||||
|
@ -1914,6 +1957,9 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
else:
|
else:
|
||||||
obj.action_needed_reason_email = default_email
|
obj.action_needed_reason_email = default_email
|
||||||
|
|
||||||
|
if obj.status in DomainRequest.get_statuses_that_send_emails() and not settings.IS_PRODUCTION:
|
||||||
|
self._check_for_valid_email(request, obj)
|
||||||
|
|
||||||
# == Handle status == #
|
# == Handle status == #
|
||||||
if obj.status == original_obj.status:
|
if obj.status == original_obj.status:
|
||||||
# If the status hasn't changed, let the base function take care of it
|
# If the status hasn't changed, let the base function take care of it
|
||||||
|
@ -1926,6 +1972,25 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
if should_save:
|
if should_save:
|
||||||
return super().save_model(request, obj, form, change)
|
return super().save_model(request, obj, form, change)
|
||||||
|
|
||||||
|
def _check_for_valid_email(self, request, obj):
|
||||||
|
"""Certain emails are whitelisted in non-production environments,
|
||||||
|
so we should display that information using this function.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
if hasattr(obj, "creator"):
|
||||||
|
recipient = obj.creator
|
||||||
|
else:
|
||||||
|
recipient = None
|
||||||
|
|
||||||
|
# Displays a warning in admin when an email cannot be sent
|
||||||
|
if recipient and recipient.email:
|
||||||
|
email = recipient.email
|
||||||
|
allowed = models.AllowedEmail.is_allowed_email(email)
|
||||||
|
error_message = f"Could not send email. The email '{email}' does not exist within the whitelist."
|
||||||
|
if not allowed:
|
||||||
|
messages.warning(request, error_message)
|
||||||
|
|
||||||
def _handle_status_change(self, request, obj, original_obj):
|
def _handle_status_change(self, request, obj, original_obj):
|
||||||
"""
|
"""
|
||||||
Checks for various conditions when a status change is triggered.
|
Checks for various conditions when a status change is triggered.
|
||||||
|
@ -2150,10 +2215,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
if not action_needed_reason or action_needed_reason == DomainRequest.ActionNeededReasons.OTHER:
|
if not action_needed_reason or action_needed_reason == DomainRequest.ActionNeededReasons.OTHER:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if flag_is_active(None, "profile_feature"): # type: ignore
|
recipient = domain_request.creator
|
||||||
recipient = domain_request.creator
|
|
||||||
else:
|
|
||||||
recipient = domain_request.submitter
|
|
||||||
|
|
||||||
# Return the context of the rendered views
|
# Return the context of the rendered views
|
||||||
context = {"domain_request": domain_request, "recipient": recipient}
|
context = {"domain_request": domain_request, "recipient": recipient}
|
||||||
|
@ -2236,6 +2298,17 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
use_sort = db_field.name != "senior_official"
|
use_sort = db_field.name != "senior_official"
|
||||||
return super().formfield_for_foreignkey(db_field, request, use_admin_sort_fields=use_sort, **kwargs)
|
return super().formfield_for_foreignkey(db_field, request, use_admin_sort_fields=use_sort, **kwargs)
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
"""Custom get_queryset to filter by portfolio if portfolio is in the
|
||||||
|
request params."""
|
||||||
|
qs = super().get_queryset(request)
|
||||||
|
# Check if a 'portfolio' parameter is passed in the request
|
||||||
|
portfolio_id = request.GET.get("portfolio")
|
||||||
|
if portfolio_id:
|
||||||
|
# Further filter the queryset by the portfolio
|
||||||
|
qs = qs.filter(portfolio=portfolio_id)
|
||||||
|
return qs
|
||||||
|
|
||||||
|
|
||||||
class TransitionDomainAdmin(ListHeaderAdmin):
|
class TransitionDomainAdmin(ListHeaderAdmin):
|
||||||
"""Custom transition domain admin class."""
|
"""Custom transition domain admin class."""
|
||||||
|
@ -2724,6 +2797,17 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
return True
|
return True
|
||||||
return super().has_change_permission(request, obj)
|
return super().has_change_permission(request, obj)
|
||||||
|
|
||||||
|
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 a 'portfolio' parameter is passed in the request
|
||||||
|
portfolio_id = request.GET.get("portfolio")
|
||||||
|
if portfolio_id:
|
||||||
|
# Further filter the queryset by the portfolio
|
||||||
|
qs = qs.filter(domain_info__portfolio=portfolio_id)
|
||||||
|
return qs
|
||||||
|
|
||||||
|
|
||||||
class DraftDomainResource(resources.ModelResource):
|
class DraftDomainResource(resources.ModelResource):
|
||||||
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
|
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
|
||||||
|
@ -2903,12 +2987,8 @@ class PortfolioAdmin(ListHeaderAdmin):
|
||||||
fieldsets = [
|
fieldsets = [
|
||||||
# created_on is the created_at field, and portfolio_type is f"{organization_type} - {federal_type}"
|
# created_on is the created_at field, and portfolio_type is f"{organization_type} - {federal_type}"
|
||||||
(None, {"fields": ["portfolio_type", "organization_name", "creator", "created_on", "notes"]}),
|
(None, {"fields": ["portfolio_type", "organization_name", "creator", "created_on", "notes"]}),
|
||||||
# TODO - uncomment in #2521
|
("Portfolio members", {"fields": ["display_admins", "display_members"]}),
|
||||||
# ("Portfolio members", {
|
("Portfolio domains", {"fields": ["domains", "domain_requests"]}),
|
||||||
# "classes": ("collapse", "closed"),
|
|
||||||
# "fields": ["administrators", "members"]}
|
|
||||||
# ),
|
|
||||||
("Portfolio domains", {"classes": ("collapse", "closed"), "fields": ["domains", "domain_requests"]}),
|
|
||||||
("Type of organization", {"fields": ["organization_type", "federal_type"]}),
|
("Type of organization", {"fields": ["organization_type", "federal_type"]}),
|
||||||
(
|
(
|
||||||
"Organization name and mailing address",
|
"Organization name and mailing address",
|
||||||
|
@ -2955,14 +3035,118 @@ class PortfolioAdmin(ListHeaderAdmin):
|
||||||
readonly_fields = [
|
readonly_fields = [
|
||||||
# This is the created_at field
|
# This is the created_at field
|
||||||
"created_on",
|
"created_on",
|
||||||
# Custom fields such as these must be defined as readonly.
|
# Django admin doesn't allow methods to be directly listed in fieldsets. We can
|
||||||
|
# display the custom methods display_admins amd display_members in the admin form if
|
||||||
|
# they are readonly.
|
||||||
"federal_type",
|
"federal_type",
|
||||||
"domains",
|
"domains",
|
||||||
"domain_requests",
|
"domain_requests",
|
||||||
"suborganizations",
|
"suborganizations",
|
||||||
"portfolio_type",
|
"portfolio_type",
|
||||||
|
"display_admins",
|
||||||
|
"display_members",
|
||||||
|
"creator",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def get_admin_users(self, obj):
|
||||||
|
# Filter UserPortfolioPermission objects related to the portfolio
|
||||||
|
admin_permissions = UserPortfolioPermission.objects.filter(
|
||||||
|
portfolio=obj, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get the user objects associated with these permissions
|
||||||
|
admin_users = User.objects.filter(portfolio_permissions__in=admin_permissions)
|
||||||
|
|
||||||
|
return admin_users
|
||||||
|
|
||||||
|
def get_non_admin_users(self, obj):
|
||||||
|
# Filter UserPortfolioPermission objects related to the portfolio that do NOT have the "Admin" role
|
||||||
|
non_admin_permissions = UserPortfolioPermission.objects.filter(portfolio=obj).exclude(
|
||||||
|
roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get the user objects associated with these permissions
|
||||||
|
non_admin_users = User.objects.filter(portfolio_permissions__in=non_admin_permissions)
|
||||||
|
|
||||||
|
return non_admin_users
|
||||||
|
|
||||||
|
def display_admins(self, obj):
|
||||||
|
"""Get joined users who are Admin, unpack and return an HTML block.
|
||||||
|
|
||||||
|
'DJA readonly can't handle querysets, so we need to unpack and return html here.
|
||||||
|
Alternatively, we could return querysets in context but that would limit where this
|
||||||
|
data would display in a custom change form without extensive template customization.
|
||||||
|
|
||||||
|
Will be used in the field_readonly block"""
|
||||||
|
admins = self.get_admin_users(obj)
|
||||||
|
if not admins:
|
||||||
|
return format_html("<p>No admins found.</p>")
|
||||||
|
|
||||||
|
admin_details = ""
|
||||||
|
for portfolio_admin in admins:
|
||||||
|
change_url = reverse("admin:registrar_user_change", args=[portfolio_admin.pk])
|
||||||
|
admin_details += "<address class='margin-bottom-2 dja-address-contact-list'>"
|
||||||
|
admin_details += f'<a href="{change_url}">{escape(portfolio_admin)}</a><br>'
|
||||||
|
admin_details += f"{escape(portfolio_admin.title)}<br>"
|
||||||
|
admin_details += f"{escape(portfolio_admin.email)}"
|
||||||
|
admin_details += "<div class='admin-icon-group admin-icon-group__clipboard-link'>"
|
||||||
|
admin_details += f"<input aria-hidden='true' class='display-none' value='{escape(portfolio_admin.email)}'>"
|
||||||
|
admin_details += (
|
||||||
|
"<button class='usa-button usa-button--unstyled padding-right-1 usa-button--icon padding-left-05"
|
||||||
|
+ "button--clipboard copy-to-clipboard text-no-underline' type='button'>"
|
||||||
|
)
|
||||||
|
admin_details += "<svg class='usa-icon'>"
|
||||||
|
admin_details += "<use aria-hidden='true' xlink:href='/public/img/sprite.svg#content_copy'></use>"
|
||||||
|
admin_details += "</svg>"
|
||||||
|
admin_details += "Copy"
|
||||||
|
admin_details += "</button>"
|
||||||
|
admin_details += "</div><br>"
|
||||||
|
admin_details += f"{escape(portfolio_admin.phone)}"
|
||||||
|
admin_details += "</address>"
|
||||||
|
return format_html(admin_details)
|
||||||
|
|
||||||
|
display_admins.short_description = "Administrators" # type: ignore
|
||||||
|
|
||||||
|
def display_members(self, obj):
|
||||||
|
"""Get joined users who have roles/perms that are not Admin, unpack and return an HTML block.
|
||||||
|
|
||||||
|
DJA readonly can't handle querysets, so we need to unpack and return html here.
|
||||||
|
Alternatively, we could return querysets in context but that would limit where this
|
||||||
|
data would display in a custom change form without extensive template customization.
|
||||||
|
|
||||||
|
Will be used in the after_help_text block."""
|
||||||
|
members = self.get_non_admin_users(obj)
|
||||||
|
if not members:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
member_details = (
|
||||||
|
"<table><thead><tr><th>Name</th><th>Title</th><th>Email</th>"
|
||||||
|
+ "<th>Phone</th><th>Roles</th></tr></thead><tbody>"
|
||||||
|
)
|
||||||
|
for member in members:
|
||||||
|
full_name = member.get_formatted_name()
|
||||||
|
member_details += "<tr>"
|
||||||
|
member_details += f"<td>{escape(full_name)}</td>"
|
||||||
|
member_details += f"<td>{escape(member.title)}</td>"
|
||||||
|
member_details += f"<td>{escape(member.email)}</td>"
|
||||||
|
member_details += f"<td>{escape(member.phone)}</td>"
|
||||||
|
member_details += "<td>"
|
||||||
|
for role in member.portfolio_role_summary(obj):
|
||||||
|
member_details += f"<span class='usa-tag'>{escape(role)}</span> "
|
||||||
|
member_details += "</td></tr>"
|
||||||
|
member_details += "</tbody></table>"
|
||||||
|
return format_html(member_details)
|
||||||
|
|
||||||
|
display_members.short_description = "Members" # type: ignore
|
||||||
|
|
||||||
|
def display_members_summary(self, obj):
|
||||||
|
"""Will be passed as context and used in the field_readonly block."""
|
||||||
|
members = self.get_non_admin_users(obj)
|
||||||
|
if not members:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
return self.get_field_links_as_list(members, "user", separator=", ")
|
||||||
|
|
||||||
def federal_type(self, obj: models.Portfolio):
|
def federal_type(self, obj: models.Portfolio):
|
||||||
"""Returns the federal_type field"""
|
"""Returns the federal_type field"""
|
||||||
return BranchChoices.get_branch_label(obj.federal_type) if obj.federal_type else "-"
|
return BranchChoices.get_branch_label(obj.federal_type) if obj.federal_type else "-"
|
||||||
|
@ -2990,18 +3174,27 @@ class PortfolioAdmin(ListHeaderAdmin):
|
||||||
suborganizations.short_description = "Suborganizations" # type: ignore
|
suborganizations.short_description = "Suborganizations" # type: ignore
|
||||||
|
|
||||||
def domains(self, obj: models.Portfolio):
|
def domains(self, obj: models.Portfolio):
|
||||||
"""Returns a list of links for each related domain"""
|
"""Returns the count of domains with a link to view them in the admin."""
|
||||||
queryset = obj.get_domains()
|
domain_count = obj.get_domains().count() # Count the related domains
|
||||||
return self.get_field_links_as_list(
|
if domain_count > 0:
|
||||||
queryset, "domaininformation", link_info_attribute="get_state_display_of_domain"
|
# Construct the URL to the admin page, filtered by portfolio
|
||||||
)
|
url = reverse("admin:registrar_domain_changelist") + f"?portfolio={obj.id}"
|
||||||
|
label = "domain" if domain_count == 1 else "domains"
|
||||||
|
# Create a clickable link with the domain count
|
||||||
|
return format_html('<a href="{}">{} {}</a>', url, domain_count, label)
|
||||||
|
return "No domains"
|
||||||
|
|
||||||
domains.short_description = "Domains" # type: ignore
|
domains.short_description = "Domains" # type: ignore
|
||||||
|
|
||||||
def domain_requests(self, obj: models.Portfolio):
|
def domain_requests(self, obj: models.Portfolio):
|
||||||
"""Returns a list of links for each related domain request"""
|
"""Returns the count of domain requests with a link to view them in the admin."""
|
||||||
queryset = obj.get_domain_requests()
|
domain_request_count = obj.get_domain_requests().count() # Count the related domain requests
|
||||||
return self.get_field_links_as_list(queryset, "domainrequest", link_info_attribute="get_status_display")
|
if domain_request_count > 0:
|
||||||
|
# Construct the URL to the admin page, filtered by portfolio
|
||||||
|
url = reverse("admin:registrar_domainrequest_changelist") + f"?portfolio={obj.id}"
|
||||||
|
# Create a clickable link with the domain request count
|
||||||
|
return format_html('<a href="{}">{} domain requests</a>', url, domain_request_count)
|
||||||
|
return "No domain requests"
|
||||||
|
|
||||||
domain_requests.short_description = "Domain requests" # type: ignore
|
domain_requests.short_description = "Domain requests" # type: ignore
|
||||||
|
|
||||||
|
@ -3013,7 +3206,7 @@ class PortfolioAdmin(ListHeaderAdmin):
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_field_links_as_list(
|
def get_field_links_as_list(
|
||||||
self, queryset, model_name, attribute_name=None, link_info_attribute=None, seperator=None
|
self, queryset, model_name, attribute_name=None, link_info_attribute=None, separator=None
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Generate HTML links for items in a queryset, using a specified attribute for link text.
|
Generate HTML links for items in a queryset, using a specified attribute for link text.
|
||||||
|
@ -3045,14 +3238,14 @@ class PortfolioAdmin(ListHeaderAdmin):
|
||||||
if link_info_attribute:
|
if link_info_attribute:
|
||||||
link += f" ({self.value_of_attribute(item, link_info_attribute)})"
|
link += f" ({self.value_of_attribute(item, link_info_attribute)})"
|
||||||
|
|
||||||
if seperator:
|
if separator:
|
||||||
links.append(link)
|
links.append(link)
|
||||||
else:
|
else:
|
||||||
links.append(f"<li>{link}</li>")
|
links.append(f"<li>{link}</li>")
|
||||||
|
|
||||||
# If no seperator is specified, just return an unordered list.
|
# If no separator is specified, just return an unordered list.
|
||||||
if seperator:
|
if separator:
|
||||||
return format_html(seperator.join(links)) if links else "-"
|
return format_html(separator.join(links)) if links else "-"
|
||||||
else:
|
else:
|
||||||
links = "".join(links)
|
links = "".join(links)
|
||||||
return format_html(f'<ul class="add-list-reset">{links}</ul>') if links else "-"
|
return format_html(f'<ul class="add-list-reset">{links}</ul>') if links else "-"
|
||||||
|
@ -3095,8 +3288,12 @@ class PortfolioAdmin(ListHeaderAdmin):
|
||||||
return readonly_fields
|
return readonly_fields
|
||||||
|
|
||||||
def change_view(self, request, object_id, form_url="", extra_context=None):
|
def change_view(self, request, object_id, form_url="", extra_context=None):
|
||||||
"""Add related suborganizations and domain groups"""
|
"""Add related suborganizations and domain groups.
|
||||||
extra_context = {"skip_additional_contact_info": True}
|
Add the summary for the portfolio members field (list of members that link to change_forms)."""
|
||||||
|
obj = self.get_object(request, object_id)
|
||||||
|
extra_context = extra_context or {}
|
||||||
|
extra_context["skip_additional_contact_info"] = True
|
||||||
|
extra_context["display_members_summary"] = self.display_members_summary(obj)
|
||||||
return super().change_view(request, object_id, form_url, extra_context)
|
return super().change_view(request, object_id, form_url, extra_context)
|
||||||
|
|
||||||
def save_model(self, request, obj, form, change):
|
def save_model(self, request, obj, form, change):
|
||||||
|
@ -3205,6 +3402,16 @@ class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
return super().change_view(request, object_id, form_url, extra_context)
|
return super().change_view(request, object_id, form_url, extra_context)
|
||||||
|
|
||||||
|
|
||||||
|
class AllowedEmailAdmin(ListHeaderAdmin):
|
||||||
|
class Meta:
|
||||||
|
model = models.AllowedEmail
|
||||||
|
|
||||||
|
list_display = ["email"]
|
||||||
|
search_fields = ["email"]
|
||||||
|
search_help_text = "Search by email."
|
||||||
|
ordering = ["email"]
|
||||||
|
|
||||||
|
|
||||||
admin.site.unregister(LogEntry) # Unregister the default registration
|
admin.site.unregister(LogEntry) # Unregister the default registration
|
||||||
|
|
||||||
admin.site.register(LogEntry, CustomLogEntryAdmin)
|
admin.site.register(LogEntry, CustomLogEntryAdmin)
|
||||||
|
@ -3232,6 +3439,8 @@ admin.site.register(models.Portfolio, PortfolioAdmin)
|
||||||
admin.site.register(models.DomainGroup, DomainGroupAdmin)
|
admin.site.register(models.DomainGroup, DomainGroupAdmin)
|
||||||
admin.site.register(models.Suborganization, SuborganizationAdmin)
|
admin.site.register(models.Suborganization, SuborganizationAdmin)
|
||||||
admin.site.register(models.SeniorOfficial, SeniorOfficialAdmin)
|
admin.site.register(models.SeniorOfficial, SeniorOfficialAdmin)
|
||||||
|
admin.site.register(models.UserPortfolioPermission, UserPortfolioPermissionAdmin)
|
||||||
|
admin.site.register(models.AllowedEmail, AllowedEmailAdmin)
|
||||||
|
|
||||||
# Register our custom waffle implementations
|
# Register our custom waffle implementations
|
||||||
admin.site.register(models.WaffleFlag, WaffleFlagAdmin)
|
admin.site.register(models.WaffleFlag, WaffleFlagAdmin)
|
||||||
|
|
14
src/registrar/assets/js/get-gov-admin-extra.js
Normal file
14
src/registrar/assets/js/get-gov-admin-extra.js
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
// Use Django's jQuery with Select2 to make the user select on the user transfer view a combobox
|
||||||
|
(function($) {
|
||||||
|
$(document).ready(function() {
|
||||||
|
if ($) {
|
||||||
|
$("#selected_user").select2({
|
||||||
|
width: 'resolve',
|
||||||
|
placeholder: 'Select a user',
|
||||||
|
allowClear: true
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error('jQuery is not available');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})(window.jQuery);
|
|
@ -172,40 +172,39 @@ function addOrRemoveSessionBoolean(name, add){
|
||||||
** To perform data operations on this - we need to use jQuery rather than vanilla js.
|
** To perform data operations on this - we need to use jQuery rather than vanilla js.
|
||||||
*/
|
*/
|
||||||
(function (){
|
(function (){
|
||||||
let selector = django.jQuery("#id_investigator")
|
if (document.getElementById("id_investigator") && django && django.jQuery) {
|
||||||
let assignSelfButton = document.querySelector("#investigator__assign_self");
|
let selector = django.jQuery("#id_investigator")
|
||||||
if (!selector || !assignSelfButton) {
|
let assignSelfButton = document.querySelector("#investigator__assign_self");
|
||||||
return;
|
if (!selector || !assignSelfButton) {
|
||||||
}
|
return;
|
||||||
|
|
||||||
let currentUserId = assignSelfButton.getAttribute("data-user-id");
|
|
||||||
let currentUserName = assignSelfButton.getAttribute("data-user-name");
|
|
||||||
if (!currentUserId || !currentUserName){
|
|
||||||
console.error("Could not assign current user: no values found.")
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hook a click listener to the "Assign to me" button.
|
|
||||||
// Logic borrowed from here: https://select2.org/programmatic-control/add-select-clear-items#create-if-not-exists
|
|
||||||
assignSelfButton.addEventListener("click", function() {
|
|
||||||
if (selector.find(`option[value='${currentUserId}']`).length) {
|
|
||||||
// Select the value that is associated with the current user.
|
|
||||||
selector.val(currentUserId).trigger("change");
|
|
||||||
} else {
|
|
||||||
// Create a DOM Option that matches the desired user. Then append it and select it.
|
|
||||||
let userOption = new Option(currentUserName, currentUserId, true, true);
|
|
||||||
selector.append(userOption).trigger("change");
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Listen to any change events, and hide the parent container if investigator has a value.
|
let currentUserId = assignSelfButton.getAttribute("data-user-id");
|
||||||
selector.on('change', function() {
|
let currentUserName = assignSelfButton.getAttribute("data-user-name");
|
||||||
// The parent container has display type flex.
|
if (!currentUserId || !currentUserName){
|
||||||
assignSelfButton.parentElement.style.display = this.value === currentUserId ? "none" : "flex";
|
console.error("Could not assign current user: no values found.")
|
||||||
});
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Hook a click listener to the "Assign to me" button.
|
||||||
|
// Logic borrowed from here: https://select2.org/programmatic-control/add-select-clear-items#create-if-not-exists
|
||||||
|
assignSelfButton.addEventListener("click", function() {
|
||||||
|
if (selector.find(`option[value='${currentUserId}']`).length) {
|
||||||
|
// Select the value that is associated with the current user.
|
||||||
|
selector.val(currentUserId).trigger("change");
|
||||||
|
} else {
|
||||||
|
// Create a DOM Option that matches the desired user. Then append it and select it.
|
||||||
|
let userOption = new Option(currentUserName, currentUserId, true, true);
|
||||||
|
selector.append(userOption).trigger("change");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen to any change events, and hide the parent container if investigator has a value.
|
||||||
|
selector.on('change', function() {
|
||||||
|
// The parent container has display type flex.
|
||||||
|
assignSelfButton.parentElement.style.display = this.value === currentUserId ? "none" : "flex";
|
||||||
|
});
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
/** An IIFE for pages in DjangoAdmin that use a clipboard button
|
/** An IIFE for pages in DjangoAdmin that use a clipboard button
|
||||||
|
@ -215,7 +214,6 @@ function addOrRemoveSessionBoolean(name, add){
|
||||||
function copyToClipboardAndChangeIcon(button) {
|
function copyToClipboardAndChangeIcon(button) {
|
||||||
// Assuming the input is the previous sibling of the button
|
// Assuming the input is the previous sibling of the button
|
||||||
let input = button.previousElementSibling;
|
let input = button.previousElementSibling;
|
||||||
let userId = input.getAttribute("user-id")
|
|
||||||
// Copy input value to clipboard
|
// Copy input value to clipboard
|
||||||
if (input) {
|
if (input) {
|
||||||
navigator.clipboard.writeText(input.value).then(function() {
|
navigator.clipboard.writeText(input.value).then(function() {
|
||||||
|
@ -353,7 +351,7 @@ function initializeWidgetOnList(list, parentId) {
|
||||||
let rejectionReasonFormGroup = document.querySelector('.field-rejection_reason')
|
let rejectionReasonFormGroup = document.querySelector('.field-rejection_reason')
|
||||||
// This is the "action needed reason" field
|
// This is the "action needed reason" field
|
||||||
let actionNeededReasonFormGroup = document.querySelector('.field-action_needed_reason');
|
let actionNeededReasonFormGroup = document.querySelector('.field-action_needed_reason');
|
||||||
// This is the "auto-generated email" field
|
// This is the "Email" field
|
||||||
let actionNeededReasonEmailFormGroup = document.querySelector('.field-action_needed_reason_email')
|
let actionNeededReasonEmailFormGroup = document.querySelector('.field-action_needed_reason_email')
|
||||||
|
|
||||||
if (rejectionReasonFormGroup && actionNeededReasonFormGroup && actionNeededReasonEmailFormGroup) {
|
if (rejectionReasonFormGroup && actionNeededReasonFormGroup && actionNeededReasonEmailFormGroup) {
|
||||||
|
@ -509,22 +507,37 @@ function initializeWidgetOnList(list, parentId) {
|
||||||
(function () {
|
(function () {
|
||||||
// Since this is an iife, these vars will be removed from memory afterwards
|
// Since this is an iife, these vars will be removed from memory afterwards
|
||||||
var actionNeededReasonDropdown = document.querySelector("#id_action_needed_reason");
|
var actionNeededReasonDropdown = document.querySelector("#id_action_needed_reason");
|
||||||
var actionNeededEmail = document.querySelector("#id_action_needed_reason_email");
|
|
||||||
var readonlyView = document.querySelector("#action-needed-reason-email-readonly");
|
// Placeholder text (for certain "action needed" reasons that do not involve e=mails)
|
||||||
|
var placeholderText = document.querySelector("#action-needed-reason-email-placeholder-text")
|
||||||
|
|
||||||
|
// E-mail divs and textarea components
|
||||||
|
var actionNeededEmail = document.querySelector("#id_action_needed_reason_email")
|
||||||
|
var actionNeededEmailReadonly = document.querySelector("#action-needed-reason-email-readonly")
|
||||||
|
var actionNeededEmailReadonlyTextarea = document.querySelector("#action-needed-reason-email-readonly-textarea")
|
||||||
|
|
||||||
|
// Edit e-mail modal (and its confirmation button)
|
||||||
|
var confirmEditEmailButton = document.querySelector("#email-already-sent-modal_continue-editing-button")
|
||||||
|
|
||||||
|
// Headers and footers (which change depending on if the e-mail was sent or not)
|
||||||
|
var actionNeededEmailHeader = document.querySelector("#action-needed-email-header")
|
||||||
|
var actionNeededEmailHeaderOnSave = document.querySelector("#action-needed-email-header-email-sent")
|
||||||
|
var actionNeededEmailFooter = document.querySelector("#action-needed-email-footer")
|
||||||
|
|
||||||
let emailWasSent = document.getElementById("action-needed-email-sent");
|
let emailWasSent = document.getElementById("action-needed-email-sent");
|
||||||
|
let lastSentEmailText = document.getElementById("action-needed-email-last-sent-text");
|
||||||
|
|
||||||
|
// Get the list of e-mails associated with each action-needed dropdown value
|
||||||
let emailData = document.getElementById('action-needed-emails-data');
|
let emailData = document.getElementById('action-needed-emails-data');
|
||||||
if (!emailData) {
|
if (!emailData) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let actionNeededEmailData = emailData.textContent;
|
let actionNeededEmailData = emailData.textContent;
|
||||||
if(!actionNeededEmailData) {
|
if(!actionNeededEmailData) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let actionNeededEmailsJson = JSON.parse(actionNeededEmailData);
|
let actionNeededEmailsJson = JSON.parse(actionNeededEmailData);
|
||||||
|
|
||||||
const domainRequestId = actionNeededReasonDropdown ? document.querySelector("#domain_request_id").value : null
|
const domainRequestId = actionNeededReasonDropdown ? document.querySelector("#domain_request_id").value : null
|
||||||
const emailSentSessionVariableName = `actionNeededEmailSent-${domainRequestId}`;
|
const emailSentSessionVariableName = `actionNeededEmailSent-${domainRequestId}`;
|
||||||
const oldDropdownValue = actionNeededReasonDropdown ? actionNeededReasonDropdown.value : null;
|
const oldDropdownValue = actionNeededReasonDropdown ? actionNeededReasonDropdown.value : null;
|
||||||
|
@ -540,58 +553,117 @@ function initializeWidgetOnList(list, parentId) {
|
||||||
// An email was sent out - store that information in a session variable
|
// An email was sent out - store that information in a session variable
|
||||||
addOrRemoveSessionBoolean(emailSentSessionVariableName, add=true);
|
addOrRemoveSessionBoolean(emailSentSessionVariableName, add=true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show an editable email field or a readonly one
|
// Show an editable email field or a readonly one
|
||||||
updateActionNeededEmailDisplay(reason)
|
updateActionNeededEmailDisplay(reason)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// editEmailButton.addEventListener("click", function() {
|
||||||
|
// if (!checkEmailAlreadySent()) {
|
||||||
|
// showEmail(canEdit=true)
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
confirmEditEmailButton.addEventListener("click", function() {
|
||||||
|
// Show editable view
|
||||||
|
showEmail(canEdit=true)
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
// Add a change listener to the action needed reason dropdown
|
// Add a change listener to the action needed reason dropdown
|
||||||
actionNeededReasonDropdown.addEventListener("change", function() {
|
actionNeededReasonDropdown.addEventListener("change", function() {
|
||||||
let reason = actionNeededReasonDropdown.value;
|
let reason = actionNeededReasonDropdown.value;
|
||||||
let emailBody = reason in actionNeededEmailsJson ? actionNeededEmailsJson[reason] : null;
|
let emailBody = reason in actionNeededEmailsJson ? actionNeededEmailsJson[reason] : null;
|
||||||
|
|
||||||
if (reason && emailBody) {
|
if (reason && emailBody) {
|
||||||
// Replace the email content
|
|
||||||
actionNeededEmail.value = emailBody;
|
|
||||||
|
|
||||||
// Reset the session object on change since change refreshes the email content.
|
// Reset the session object on change since change refreshes the email content.
|
||||||
if (oldDropdownValue !== actionNeededReasonDropdown.value || oldEmailValue !== actionNeededEmail.value) {
|
if (oldDropdownValue !== actionNeededReasonDropdown.value || oldEmailValue !== actionNeededEmail.value) {
|
||||||
let emailSent = sessionStorage.getItem(emailSentSessionVariableName)
|
// Replace the email content
|
||||||
if (emailSent !== null){
|
actionNeededEmail.value = emailBody;
|
||||||
addOrRemoveSessionBoolean(emailSentSessionVariableName, add=false)
|
actionNeededEmailReadonlyTextarea.value = emailBody;
|
||||||
}
|
hideEmailAlreadySentView();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show an editable email field or a readonly one
|
// Show either a preview of the email or some text describing no email will be sent
|
||||||
updateActionNeededEmailDisplay(reason)
|
updateActionNeededEmailDisplay(reason)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shows an editable email field or a readonly one.
|
function checkEmailAlreadySent()
|
||||||
|
{
|
||||||
|
lastEmailSent = lastSentEmailText.value.replace(/\s+/g, '')
|
||||||
|
currentEmailInTextArea = actionNeededEmail.value.replace(/\s+/g, '')
|
||||||
|
return lastEmailSent === currentEmailInTextArea
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shows a readonly preview of the email with updated messaging to indicate this email was sent
|
||||||
|
function showEmailAlreadySentView()
|
||||||
|
{
|
||||||
|
hideElement(actionNeededEmailHeader)
|
||||||
|
showElement(actionNeededEmailHeaderOnSave)
|
||||||
|
actionNeededEmailFooter.innerHTML = "This email has been sent to the creator of this request";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shows a readonly preview of the email with updated messaging to indicate this email was sent
|
||||||
|
function hideEmailAlreadySentView()
|
||||||
|
{
|
||||||
|
showElement(actionNeededEmailHeader)
|
||||||
|
hideElement(actionNeededEmailHeaderOnSave)
|
||||||
|
actionNeededEmailFooter.innerHTML = "This email will be sent to the creator of this request after saving";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shows either a preview of the email or some text describing no email will be sent.
|
||||||
// If the email doesn't exist or if we're of reason "other", display that no email was sent.
|
// If the email doesn't exist or if we're of reason "other", display that no email was sent.
|
||||||
// Likewise, if we've sent this email before, we should just display the content.
|
|
||||||
function updateActionNeededEmailDisplay(reason) {
|
function updateActionNeededEmailDisplay(reason) {
|
||||||
let emailHasBeenSentBefore = sessionStorage.getItem(emailSentSessionVariableName) !== null;
|
hideElement(actionNeededEmail.parentElement)
|
||||||
let collapseableDiv = readonlyView.querySelector(".collapse--dgsimple");
|
|
||||||
let showMoreButton = document.querySelector("#action_needed_reason_email__show_details");
|
if (reason) {
|
||||||
if ((reason && reason != "other") && !emailHasBeenSentBefore) {
|
if (reason === "other") {
|
||||||
showElement(actionNeededEmail.parentElement)
|
// Hide email preview and show this text instead
|
||||||
hideElement(readonlyView)
|
showPlaceholderText("No email will be sent");
|
||||||
hideElement(showMoreButton)
|
|
||||||
} else {
|
|
||||||
if (!reason || reason === "other") {
|
|
||||||
collapseableDiv.innerHTML = reason ? "No email will be sent." : "-";
|
|
||||||
hideElement(showMoreButton)
|
|
||||||
if (collapseableDiv.classList.contains("collapsed")) {
|
|
||||||
showMoreButton.click()
|
|
||||||
}
|
|
||||||
}else {
|
|
||||||
showElement(showMoreButton)
|
|
||||||
}
|
}
|
||||||
hideElement(actionNeededEmail.parentElement)
|
else {
|
||||||
showElement(readonlyView)
|
// Always show readonly view of email to start
|
||||||
|
showEmail(canEdit=false)
|
||||||
|
if(checkEmailAlreadySent())
|
||||||
|
{
|
||||||
|
showEmailAlreadySentView();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Hide email preview and show this text instead
|
||||||
|
showPlaceholderText("Select an action needed reason to see email");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shows either a readonly view (canEdit=false) or editable view (canEdit=true) of the action needed email
|
||||||
|
function showEmail(canEdit)
|
||||||
|
{
|
||||||
|
if(!canEdit)
|
||||||
|
{
|
||||||
|
showElement(actionNeededEmailReadonly)
|
||||||
|
hideElement(actionNeededEmail.parentElement)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
hideElement(actionNeededEmailReadonly)
|
||||||
|
showElement(actionNeededEmail.parentElement)
|
||||||
|
}
|
||||||
|
showElement(actionNeededEmailFooter) // this is the same for both views, so it was separated out
|
||||||
|
hideElement(placeholderText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hides preview of action needed email and instead displays the given text (innerHTML)
|
||||||
|
function showPlaceholderText(innerHTML)
|
||||||
|
{
|
||||||
|
hideElement(actionNeededEmail.parentElement)
|
||||||
|
hideElement(actionNeededEmailReadonly)
|
||||||
|
hideElement(actionNeededEmailFooter)
|
||||||
|
|
||||||
|
placeholderText.innerHTML = innerHTML;
|
||||||
|
showElement(placeholderText)
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
||||||
|
@ -676,7 +748,10 @@ function initializeWidgetOnList(list, parentId) {
|
||||||
|
|
||||||
//------ Requested Domains
|
//------ Requested Domains
|
||||||
const requestedDomainElement = document.getElementById('id_requested_domain');
|
const requestedDomainElement = document.getElementById('id_requested_domain');
|
||||||
const requestedDomain = requestedDomainElement.options[requestedDomainElement.selectedIndex].text;
|
// We have to account for different superuser and analyst markups
|
||||||
|
const requestedDomain = requestedDomainElement.options
|
||||||
|
? requestedDomainElement.options[requestedDomainElement.selectedIndex].text
|
||||||
|
: requestedDomainElement.text;
|
||||||
|
|
||||||
//------ Submitter
|
//------ Submitter
|
||||||
// Function to extract text by ID and handle missing elements
|
// Function to extract text by ID and handle missing elements
|
||||||
|
@ -690,7 +765,10 @@ function initializeWidgetOnList(list, parentId) {
|
||||||
// Extract the submitter name, title, email, and phone number
|
// Extract the submitter name, title, email, and phone number
|
||||||
const submitterDiv = document.querySelector('.form-row.field-submitter');
|
const submitterDiv = document.querySelector('.form-row.field-submitter');
|
||||||
const submitterNameElement = document.getElementById('id_submitter');
|
const submitterNameElement = document.getElementById('id_submitter');
|
||||||
const submitterName = submitterNameElement.options[submitterNameElement.selectedIndex].text;
|
// We have to account for different superuser and analyst markups
|
||||||
|
const submitterName = submitterNameElement
|
||||||
|
? submitterNameElement.options[submitterNameElement.selectedIndex].text
|
||||||
|
: submitterDiv.querySelector('a').text;
|
||||||
const submitterTitle = extractTextById('contact_info_title', submitterDiv);
|
const submitterTitle = extractTextById('contact_info_title', submitterDiv);
|
||||||
const submitterEmail = extractTextById('contact_info_email', submitterDiv);
|
const submitterEmail = extractTextById('contact_info_email', submitterDiv);
|
||||||
const submitterPhone = extractTextById('contact_info_phone', submitterDiv);
|
const submitterPhone = extractTextById('contact_info_phone', submitterDiv);
|
||||||
|
@ -833,10 +911,28 @@ function initializeWidgetOnList(list, parentId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine if any changes are necessary to the display of portfolio type or federal type
|
||||||
|
// based on changes to the Federal Agency
|
||||||
|
let federalPortfolioApi = document.getElementById("federal_and_portfolio_types_from_agency_json_url").value;
|
||||||
|
fetch(`${federalPortfolioApi}?organization_type=${organizationType.value}&agency_name=${selectedText}`)
|
||||||
|
.then(response => {
|
||||||
|
const statusCode = response.status;
|
||||||
|
return response.json().then(data => ({ statusCode, data }));
|
||||||
|
})
|
||||||
|
.then(({ statusCode, data }) => {
|
||||||
|
if (data.error) {
|
||||||
|
console.error("Error in AJAX call: " + data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateReadOnly(data.federal_type, '.field-federal_type');
|
||||||
|
updateReadOnly(data.portfolio_type, '.field-portfolio_type');
|
||||||
|
})
|
||||||
|
.catch(error => console.error("Error fetching federal and portfolio types: ", error));
|
||||||
|
|
||||||
// Hide the contactList initially.
|
// Hide the contactList initially.
|
||||||
// If we can update the contact information, it'll be shown again.
|
// If we can update the contact information, it'll be shown again.
|
||||||
hideElement(contactList.parentElement);
|
hideElement(contactList.parentElement);
|
||||||
|
|
||||||
let seniorOfficialApi = document.getElementById("senior_official_from_agency_json_url").value;
|
let seniorOfficialApi = document.getElementById("senior_official_from_agency_json_url").value;
|
||||||
fetch(`${seniorOfficialApi}?agency_name=${selectedText}`)
|
fetch(`${seniorOfficialApi}?agency_name=${selectedText}`)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
|
@ -879,6 +975,7 @@ function initializeWidgetOnList(list, parentId) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => console.error("Error fetching senior official: ", error));
|
.catch(error => console.error("Error fetching senior official: ", error));
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleStateTerritoryChange(stateTerritory, urbanizationField) {
|
function handleStateTerritoryChange(stateTerritory, urbanizationField) {
|
||||||
|
@ -890,6 +987,26 @@ function initializeWidgetOnList(list, parentId) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility that selects a div from the DOM using selectorString,
|
||||||
|
* and updates a div within that div which has class of 'readonly'
|
||||||
|
* so that the text of the div is updated to updateText
|
||||||
|
* @param {*} updateText
|
||||||
|
* @param {*} selectorString
|
||||||
|
*/
|
||||||
|
function updateReadOnly(updateText, selectorString) {
|
||||||
|
// find the div by selectorString
|
||||||
|
const selectedDiv = document.querySelector(selectorString);
|
||||||
|
if (selectedDiv) {
|
||||||
|
// find the nested div with class 'readonly' inside the selectorString div
|
||||||
|
const readonlyDiv = selectedDiv.querySelector('.readonly');
|
||||||
|
if (readonlyDiv) {
|
||||||
|
// Update the text content of the readonly div
|
||||||
|
readonlyDiv.textContent = updateText !== null ? updateText : '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateContactInfo(data) {
|
function updateContactInfo(data) {
|
||||||
if (!contactList) return;
|
if (!contactList) return;
|
||||||
|
|
||||||
|
|
|
@ -1168,7 +1168,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
const statusCheckboxes = document.querySelectorAll('input[name="filter-status"]');
|
const statusCheckboxes = document.querySelectorAll('input[name="filter-status"]');
|
||||||
const statusIndicator = document.querySelector('.domain__filter-indicator');
|
const statusIndicator = document.querySelector('.domain__filter-indicator');
|
||||||
const statusToggle = document.querySelector('.usa-button--filter');
|
const statusToggle = document.querySelector('.usa-button--filter');
|
||||||
const noPortfolioFlag = document.getElementById('no-portfolio-js-flag');
|
|
||||||
const portfolioElement = document.getElementById('portfolio-js-value');
|
const portfolioElement = document.getElementById('portfolio-js-value');
|
||||||
const portfolioValue = portfolioElement ? portfolioElement.getAttribute('data-portfolio') : null;
|
const portfolioValue = portfolioElement ? portfolioElement.getAttribute('data-portfolio') : null;
|
||||||
|
|
||||||
|
@ -1220,16 +1219,16 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
const expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : '';
|
const expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : '';
|
||||||
const expirationDateSortValue = expirationDate ? expirationDate.getTime() : '';
|
const expirationDateSortValue = expirationDate ? expirationDate.getTime() : '';
|
||||||
const actionUrl = domain.action_url;
|
const actionUrl = domain.action_url;
|
||||||
const suborganization = domain.suborganization ? domain.suborganization : '';
|
const suborganization = domain.domain_info__sub_organization ? domain.domain_info__sub_organization : '⎯';
|
||||||
|
|
||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
|
|
||||||
let markupForSuborganizationRow = '';
|
let markupForSuborganizationRow = '';
|
||||||
|
|
||||||
if (!noPortfolioFlag) {
|
if (portfolioValue) {
|
||||||
markupForSuborganizationRow = `
|
markupForSuborganizationRow = `
|
||||||
<td>
|
<td>
|
||||||
<span class="${suborganization ? 'ellipsis ellipsis--30 vertical-align-middle' : ''}" aria-label="${suborganization}" title="${suborganization}">${suborganization}</span>
|
<span class="text-wrap" aria-label="${domain.suborganization ? suborganization : 'No suborganization'}">${suborganization}</span>
|
||||||
</td>
|
</td>
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
@ -1427,9 +1426,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
// NOTE: We may need to evolve this as we add more filters.
|
// NOTE: We may need to evolve this as we add more filters.
|
||||||
document.addEventListener('focusin', function(event) {
|
document.addEventListener('focusin', function(event) {
|
||||||
const accordion = document.querySelector('.usa-accordion--select');
|
const accordion = document.querySelector('.usa-accordion--select');
|
||||||
const accordionIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]');
|
const accordionThatIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]');
|
||||||
|
|
||||||
if (accordionIsOpen && !accordion.contains(event.target)) {
|
if (accordionThatIsOpen && !accordion.contains(event.target)) {
|
||||||
closeFilters();
|
closeFilters();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -1438,9 +1437,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
// NOTE: We may need to evolve this as we add more filters.
|
// NOTE: We may need to evolve this as we add more filters.
|
||||||
document.addEventListener('click', function(event) {
|
document.addEventListener('click', function(event) {
|
||||||
const accordion = document.querySelector('.usa-accordion--select');
|
const accordion = document.querySelector('.usa-accordion--select');
|
||||||
const accordionIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]');
|
const accordionThatIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]');
|
||||||
|
|
||||||
if (accordionIsOpen && !accordion.contains(event.target)) {
|
if (accordionThatIsOpen && !accordion.contains(event.target)) {
|
||||||
closeFilters();
|
closeFilters();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -1485,6 +1484,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
const tableHeaders = document.querySelectorAll('.domain-requests__table th[data-sortable]');
|
const tableHeaders = document.querySelectorAll('.domain-requests__table th[data-sortable]');
|
||||||
const tableAnnouncementRegion = document.querySelector('.domain-requests__table-wrapper .usa-table__announcement-region');
|
const tableAnnouncementRegion = document.querySelector('.domain-requests__table-wrapper .usa-table__announcement-region');
|
||||||
const resetSearchButton = document.querySelector('.domain-requests__reset-search');
|
const resetSearchButton = document.querySelector('.domain-requests__reset-search');
|
||||||
|
const portfolioElement = document.getElementById('portfolio-js-value');
|
||||||
|
const portfolioValue = portfolioElement ? portfolioElement.getAttribute('data-portfolio') : null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete is actually a POST API that requires a csrf token. The token will be waiting for us in the template as a hidden input.
|
* Delete is actually a POST API that requires a csrf token. The token will be waiting for us in the template as a hidden input.
|
||||||
|
@ -1533,7 +1534,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
* @param {*} scroll - control for the scrollToElement functionality
|
* @param {*} scroll - control for the scrollToElement functionality
|
||||||
* @param {*} searchTerm - the search term
|
* @param {*} searchTerm - the search term
|
||||||
*/
|
*/
|
||||||
function loadDomainRequests(page, sortBy = currentSortBy, order = currentOrder, scroll = scrollToTable, searchTerm = currentSearchTerm) {
|
function loadDomainRequests(page, sortBy = currentSortBy, order = currentOrder, scroll = scrollToTable, searchTerm = currentSearchTerm, portfolio = portfolioValue) {
|
||||||
// fetch json of page of domain requests, given params
|
// fetch json of page of domain requests, given params
|
||||||
let baseUrl = document.getElementById("get_domain_requests_json_url");
|
let baseUrl = document.getElementById("get_domain_requests_json_url");
|
||||||
if (!baseUrl) {
|
if (!baseUrl) {
|
||||||
|
@ -1545,7 +1546,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch(`${baseUrlValue}?page=${page}&sort_by=${sortBy}&order=${order}&search_term=${searchTerm}`)
|
// fetch json of page of requests, given params
|
||||||
|
let url = `${baseUrlValue}?page=${page}&sort_by=${sortBy}&order=${order}&search_term=${searchTerm}`
|
||||||
|
if (portfolio)
|
||||||
|
url += `&portfolio=${portfolio}`
|
||||||
|
|
||||||
|
fetch(url)
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
|
@ -1599,12 +1605,23 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
const domainName = request.requested_domain ? request.requested_domain : `New domain request <br><span class="text-base font-body-xs">(${utcDateString(request.created_at)})</span>`;
|
const domainName = request.requested_domain ? request.requested_domain : `New domain request <br><span class="text-base font-body-xs">(${utcDateString(request.created_at)})</span>`;
|
||||||
const actionUrl = request.action_url;
|
const actionUrl = request.action_url;
|
||||||
const actionLabel = request.action_label;
|
const actionLabel = request.action_label;
|
||||||
const submissionDate = request.submission_date ? new Date(request.submission_date).toLocaleDateString('en-US', options) : `<span class="text-base">Not submitted</span>`;
|
const submissionDate = request.last_submitted_date ? new Date(request.last_submitted_date).toLocaleDateString('en-US', options) : `<span class="text-base">Not submitted</span>`;
|
||||||
|
|
||||||
// Even if the request is not deletable, we may need this empty string for the td if the deletable column is displayed
|
// The markup for the delete function either be a simple trigger or a 3 dots menu with a hidden trigger (in the case of portfolio requests page)
|
||||||
|
// Even if the request is not deletable, we may need these empty strings for the td if the deletable column is displayed
|
||||||
let modalTrigger = '';
|
let modalTrigger = '';
|
||||||
|
|
||||||
// If the request is deletable, create modal body and insert it
|
let markupCreatorRow = '';
|
||||||
|
|
||||||
|
if (portfolioValue) {
|
||||||
|
markupCreatorRow = `
|
||||||
|
<td>
|
||||||
|
<span class="text-wrap break-word">${request.creator ? request.creator : ''}</span>
|
||||||
|
</td>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the request is deletable, create modal body and insert it. This is true for both requests and portfolio requests pages
|
||||||
if (request.is_deletable) {
|
if (request.is_deletable) {
|
||||||
let modalHeading = '';
|
let modalHeading = '';
|
||||||
let modalDescription = '';
|
let modalDescription = '';
|
||||||
|
@ -1627,7 +1644,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
role="button"
|
role="button"
|
||||||
id="button-toggle-delete-domain-alert-${request.id}"
|
id="button-toggle-delete-domain-alert-${request.id}"
|
||||||
href="#toggle-delete-domain-alert-${request.id}"
|
href="#toggle-delete-domain-alert-${request.id}"
|
||||||
class="usa-button--unstyled text-no-underline late-loading-modal-trigger"
|
class="usa-button text-secondary usa-button--unstyled text-no-underline late-loading-modal-trigger line-height-sans-5"
|
||||||
aria-controls="toggle-delete-domain-alert-${request.id}"
|
aria-controls="toggle-delete-domain-alert-${request.id}"
|
||||||
data-open-modal
|
data-open-modal
|
||||||
>
|
>
|
||||||
|
@ -1692,16 +1709,66 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
`
|
`
|
||||||
|
|
||||||
domainRequestsSectionWrapper.appendChild(modal);
|
domainRequestsSectionWrapper.appendChild(modal);
|
||||||
|
|
||||||
|
// Request is deletable, modal and modalTrigger are built. Now check if we are on the portfolio requests page (by seeing if there is a portfolio value) and enhance the modalTrigger accordingly
|
||||||
|
if (portfolioValue) {
|
||||||
|
modalTrigger = `
|
||||||
|
<a
|
||||||
|
role="button"
|
||||||
|
id="button-toggle-delete-domain-alert-${request.id}"
|
||||||
|
href="#toggle-delete-domain-alert-${request.id}"
|
||||||
|
class="usa-button text-secondary usa-button--unstyled text-no-underline late-loading-modal-trigger margin-top-2 visible-mobile-flex line-height-sans-5"
|
||||||
|
aria-controls="toggle-delete-domain-alert-${request.id}"
|
||||||
|
data-open-modal
|
||||||
|
>
|
||||||
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||||
|
<use xlink:href="/public/img/sprite.svg#delete"></use>
|
||||||
|
</svg> Delete <span class="usa-sr-only">${domainName}</span>
|
||||||
|
</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"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="more-actions-${request.id}"
|
||||||
|
>
|
||||||
|
<svg class="usa-icon top-2px" 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-${request.id}" class="usa-accordion__content usa-prose shadow-1 left-auto right-0" hidden>
|
||||||
|
<h2>More options</h2>
|
||||||
|
<a
|
||||||
|
role="button"
|
||||||
|
id="button-toggle-delete-domain-alert-${request.id}"
|
||||||
|
href="#toggle-delete-domain-alert-${request.id}"
|
||||||
|
class="usa-button text-secondary usa-button--unstyled text-no-underline late-loading-modal-trigger margin-top-2 line-height-sans-5"
|
||||||
|
aria-controls="toggle-delete-domain-alert-${request.id}"
|
||||||
|
data-open-modal
|
||||||
|
>
|
||||||
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||||
|
<use xlink:href="/public/img/sprite.svg#delete"></use>
|
||||||
|
</svg> Delete <span class="usa-sr-only">${domainName}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<th scope="row" role="rowheader" data-label="Domain name">
|
<th scope="row" role="rowheader" data-label="Domain name">
|
||||||
${domainName}
|
${domainName}
|
||||||
</th>
|
</th>
|
||||||
<td data-sort-value="${new Date(request.submission_date).getTime()}" data-label="Date submitted">
|
<td data-sort-value="${new Date(request.last_submitted_date).getTime()}" data-label="Date submitted">
|
||||||
${submissionDate}
|
${submissionDate}
|
||||||
</td>
|
</td>
|
||||||
|
${markupCreatorRow}
|
||||||
<td data-label="Status">
|
<td data-label="Status">
|
||||||
${request.status}
|
${request.status}
|
||||||
</td>
|
</td>
|
||||||
|
@ -1817,6 +1884,32 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeMoreActionMenu(accordionThatIsOpen) {
|
||||||
|
if (accordionThatIsOpen.getAttribute("aria-expanded") === "true") {
|
||||||
|
accordionThatIsOpen.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('focusin', function(event) {
|
||||||
|
closeOpenAccordions(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', function(event) {
|
||||||
|
closeOpenAccordions(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
function closeOpenAccordions(event) {
|
||||||
|
const openAccordions = document.querySelectorAll('.usa-button--more-actions[aria-expanded="true"]');
|
||||||
|
openAccordions.forEach((openAccordionButton) => {
|
||||||
|
// Find the corresponding accordion
|
||||||
|
const accordion = openAccordionButton.closest('.usa-accordion--more-actions');
|
||||||
|
if (accordion && !accordion.contains(event.target)) {
|
||||||
|
// Close the accordion if the click is outside
|
||||||
|
closeMoreActionMenu(openAccordionButton);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Initial load
|
// Initial load
|
||||||
loadDomainRequests(1);
|
loadDomainRequests(1);
|
||||||
}
|
}
|
||||||
|
@ -1910,7 +2003,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
|
||||||
let editableFormGroup = button.parentElement.parentElement.parentElement;
|
let editableFormGroup = button.parentElement.parentElement.parentElement;
|
||||||
if (editableFormGroup){
|
if (editableFormGroup){
|
||||||
let readonlyField = editableFormGroup.querySelector(".input-with-edit-button__readonly-field")
|
let readonlyField = editableFormGroup.querySelector(".toggleable_input__readonly-field")
|
||||||
let inputField = document.getElementById(`id_${fieldName}`);
|
let inputField = document.getElementById(`id_${fieldName}`);
|
||||||
if (!inputField || !readonlyField) {
|
if (!inputField || !readonlyField) {
|
||||||
return;
|
return;
|
||||||
|
@ -1936,8 +2029,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
// Keep the path before '#' and replace the part after '#' with 'invalid'
|
// Keep the path before '#' and replace the part after '#' with 'invalid'
|
||||||
const newHref = parts[0] + '#error';
|
const newHref = parts[0] + '#error';
|
||||||
svg.setAttribute('xlink:href', newHref);
|
svg.setAttribute('xlink:href', newHref);
|
||||||
fullNameField.classList.add("input-with-edit-button__error")
|
fullNameField.classList.add("toggleable_input__error")
|
||||||
label = fullNameField.querySelector(".input-with-edit-button__readonly-field")
|
label = fullNameField.querySelector(".toggleable_input__readonly-field")
|
||||||
label.innerHTML = "Unknown";
|
label.innerHTML = "Unknown";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2043,11 +2136,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
// Due to the nature of how uswds works, this is slightly hacky.
|
// Due to the nature of how uswds works, this is slightly hacky.
|
||||||
|
|
||||||
// Use a MutationObserver to watch for changes in the dropdown list
|
// Use a MutationObserver to watch for changes in the dropdown list
|
||||||
const dropdownList = document.querySelector(`#${input.id}--list`);
|
const dropdownList = comboBox.querySelector(`#${input.id}--list`);
|
||||||
const observer = new MutationObserver(function(mutations) {
|
const observer = new MutationObserver(function(mutations) {
|
||||||
mutations.forEach(function(mutation) {
|
mutations.forEach(function(mutation) {
|
||||||
if (mutation.type === "childList") {
|
if (mutation.type === "childList") {
|
||||||
addBlankOption(clearInputButton, dropdownList, initialValue);
|
addBlankOption(clearInputButton, dropdownList, initialValue);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -2111,7 +2204,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
if (!initialValue){
|
if (!initialValue){
|
||||||
blankOption.classList.add("usa-combo-box__list-option--selected")
|
blankOption.classList.add("usa-combo-box__list-option--selected")
|
||||||
}
|
}
|
||||||
blankOption.textContent = "---------";
|
blankOption.textContent = "⎯";
|
||||||
|
|
||||||
dropdownList.insertBefore(blankOption, dropdownList.firstChild);
|
dropdownList.insertBefore(blankOption, dropdownList.firstChild);
|
||||||
blankOption.addEventListener("click", (e) => {
|
blankOption.addEventListener("click", (e) => {
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
@use "uswds-core" as *;
|
@use "uswds-core" as *;
|
||||||
|
|
||||||
.usa-accordion--select {
|
.usa-accordion--select,
|
||||||
|
.usa-accordion--more-actions {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: auto;
|
width: auto;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
@ -14,7 +15,6 @@
|
||||||
// Note, width is determined by a custom width class on one of the children
|
// Note, width is determined by a custom width class on one of the children
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
top: 33.88px;
|
|
||||||
left: 0;
|
left: 0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: solid 1px color('base-lighter');
|
border: solid 1px color('base-lighter');
|
||||||
|
@ -31,3 +31,17 @@
|
||||||
margin-top: 0 !important;
|
margin-top: 0 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.usa-accordion--select .usa-accordion__content {
|
||||||
|
top: 33.88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usa-accordion--more-actions .usa-accordion__content {
|
||||||
|
top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child .usa-accordion--more-actions .usa-accordion__content {
|
||||||
|
top: auto;
|
||||||
|
bottom: -10px;
|
||||||
|
right: 30px;
|
||||||
|
}
|
||||||
|
|
|
@ -66,6 +66,9 @@ html[data-theme="light"] {
|
||||||
// --object-tools-fg: var(--button-fg);
|
// --object-tools-fg: var(--button-fg);
|
||||||
// --object-tools-bg: var(--close-button-bg);
|
// --object-tools-bg: var(--close-button-bg);
|
||||||
// --object-tools-hover-bg: var(--close-button-hover-bg);
|
// --object-tools-hover-bg: var(--close-button-hover-bg);
|
||||||
|
|
||||||
|
--summary-box-bg: #f1f1f1;
|
||||||
|
--summary-box-border: #d1d2d2;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fold dark theme settings into our main CSS
|
// Fold dark theme settings into our main CSS
|
||||||
|
@ -104,6 +107,9 @@ html[data-theme="light"] {
|
||||||
|
|
||||||
--close-button-bg: #333333;
|
--close-button-bg: #333333;
|
||||||
--close-button-hover-bg: #666666;
|
--close-button-hover-bg: #666666;
|
||||||
|
|
||||||
|
--summary-box-bg: #121212;
|
||||||
|
--summary-box-border: #666666;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dark mode django (bug due to scss cascade) and USWDS tables
|
// Dark mode django (bug due to scss cascade) and USWDS tables
|
||||||
|
@ -120,7 +126,7 @@ html[data-theme="light"] {
|
||||||
body.dashboard,
|
body.dashboard,
|
||||||
body.change-list,
|
body.change-list,
|
||||||
body.change-form,
|
body.change-form,
|
||||||
.analytics {
|
.custom-admin-template, dt {
|
||||||
color: var(--body-fg);
|
color: var(--body-fg);
|
||||||
}
|
}
|
||||||
.usa-table td {
|
.usa-table td {
|
||||||
|
@ -149,7 +155,7 @@ html[data-theme="dark"] {
|
||||||
body.dashboard,
|
body.dashboard,
|
||||||
body.change-list,
|
body.change-list,
|
||||||
body.change-form,
|
body.change-form,
|
||||||
.analytics {
|
.custom-admin-template, dt {
|
||||||
color: var(--body-fg);
|
color: var(--body-fg);
|
||||||
}
|
}
|
||||||
.usa-table td {
|
.usa-table td {
|
||||||
|
@ -160,7 +166,7 @@ html[data-theme="dark"] {
|
||||||
// Remove when dark mode successfully applies to Django delete page.
|
// Remove when dark mode successfully applies to Django delete page.
|
||||||
.delete-confirmation .content a:not(.button) {
|
.delete-confirmation .content a:not(.button) {
|
||||||
color: color('primary');
|
color: color('primary');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -364,14 +370,60 @@ input.admin-confirm-button {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
line-height: normal;
|
line-height: normal;
|
||||||
}
|
}
|
||||||
.button {
|
}
|
||||||
display: inline-block;
|
|
||||||
padding: 10px 8px;
|
// This block resolves some of the issues we're seeing on buttons due to css
|
||||||
line-height: normal;
|
// conflicts between DJ and USWDS
|
||||||
}
|
a.button,
|
||||||
a.button:active, a.button:focus {
|
.usa-button--dja {
|
||||||
text-decoration: none;
|
display: inline-block;
|
||||||
}
|
padding: 10px 15px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 16.1px;
|
||||||
|
font-kerning: auto;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
.button svg,
|
||||||
|
.button span,
|
||||||
|
.usa-button--dja svg,
|
||||||
|
.usa-button--dja span {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.usa-button--dja:not(.usa-button--unstyled, .usa-button--outline, .usa-modal__close, .usa-button--secondary) {
|
||||||
|
background: var(--button-bg);
|
||||||
|
}
|
||||||
|
.usa-button--dja span {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.usa-button--dja:not(.usa-button--unstyled, .usa-button--outline, .usa-modal__close, .usa-button--secondary):hover {
|
||||||
|
background: var(--button-hover-bg);
|
||||||
|
}
|
||||||
|
a.button:active, a.button:focus {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.usa-modal {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
input[type=submit].button--dja-toolbar {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
padding: 4px 8px;
|
||||||
|
margin: 0;
|
||||||
|
vertical-align: middle;
|
||||||
|
background: var(--body-bg);
|
||||||
|
box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--body-fg);
|
||||||
|
}
|
||||||
|
input[type=submit].button--dja-toolbar:focus, input[type=submit].button--dja-toolbar:hover {
|
||||||
|
border-color: var(--body-quiet-color);
|
||||||
|
}
|
||||||
|
// Targets the DJA buttom with a nested icon
|
||||||
|
button .usa-icon,
|
||||||
|
.button .usa-icon,
|
||||||
|
.button--clipboard .usa-icon {
|
||||||
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.module--custom {
|
.module--custom {
|
||||||
|
@ -465,13 +517,6 @@ address.dja-address-contact-list {
|
||||||
color: var(--link-fg);
|
color: var(--link-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Targets the DJA buttom with a nested icon
|
|
||||||
button .usa-icon,
|
|
||||||
.button .usa-icon,
|
|
||||||
.button--clipboard .usa-icon {
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.errors span.select2-selection {
|
.errors span.select2-selection {
|
||||||
border: 1px solid var(--error-fg) !important;
|
border: 1px solid var(--error-fg) !important;
|
||||||
}
|
}
|
||||||
|
@ -540,7 +585,7 @@ button .usa-icon,
|
||||||
#submitRowToggle {
|
#submitRowToggle {
|
||||||
color: var(--body-fg);
|
color: var(--body-fg);
|
||||||
}
|
}
|
||||||
.requested-domain-sticky {
|
.submit-row-sticky {
|
||||||
max-width: 325px;
|
max-width: 325px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
@ -732,7 +777,7 @@ div.dja__model-description{
|
||||||
|
|
||||||
li {
|
li {
|
||||||
list-style-type: disc;
|
list-style-type: disc;
|
||||||
font-family: Source Sans Pro Web,Helvetica Neue,Helvetica,Roboto,Arial,sans-serif;
|
font-family: family('sans');
|
||||||
}
|
}
|
||||||
|
|
||||||
a, a:link, a:visited {
|
a, a:link, a:visited {
|
||||||
|
@ -848,7 +893,40 @@ div.dja__model-description{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vertical-separator {
|
||||||
|
min-height: 20px;
|
||||||
|
height: 100%;
|
||||||
|
width: 1px;
|
||||||
|
background-color: #d1d2d2;
|
||||||
|
vertical-align: middle
|
||||||
|
}
|
||||||
|
|
||||||
|
.usa-summary-box_admin {
|
||||||
|
color: var(--body-fg);
|
||||||
|
border-color: var(--summary-box-border);
|
||||||
|
background-color: var(--summary-box-bg);
|
||||||
|
min-width: fit-content;
|
||||||
|
padding: .5rem;
|
||||||
|
border-radius: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-faded {
|
||||||
|
color: #{$dhs-gray-60};
|
||||||
|
}
|
||||||
ul.add-list-reset {
|
ul.add-list-reset {
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fix the combobox when deployed outside admin (eg user transfer)
|
||||||
|
.submit-row .select2,
|
||||||
|
.submit-row .select2 span {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.transfer-user-selector .select2-selection__placeholder {
|
||||||
|
color: #3d4551!important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-dja dt {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
|
@ -33,16 +33,19 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
#wrapper.dashboard--portfolio {
|
#wrapper.dashboard--portfolio {
|
||||||
background-color: color('gray-1');
|
|
||||||
padding-top: units(4)!important;
|
padding-top: units(4)!important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#wrapper.dashboard--grey-1 {
|
||||||
|
background-color: color('gray-1');
|
||||||
|
}
|
||||||
|
|
||||||
.section--outlined {
|
|
||||||
|
.section-outlined {
|
||||||
background-color: color('white');
|
background-color: color('white');
|
||||||
border: 1px solid color('base-lighter');
|
border: 1px solid color('base-lighter');
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 0 units(2) units(3);
|
padding: 0 units(4) units(3) units(2);
|
||||||
margin-top: units(3);
|
margin-top: units(3);
|
||||||
|
|
||||||
&.margin-top-0 {
|
&.margin-top-0 {
|
||||||
|
@ -72,9 +75,13 @@ body {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.section--outlined__header--no-portfolio {
|
.section-outlined--border-base-light {
|
||||||
.section--outlined__search,
|
border: 1px solid color('base-light');
|
||||||
.section--outlined__utility-button {
|
}
|
||||||
|
|
||||||
|
.section-outlined__header--no-portfolio {
|
||||||
|
.section-outlined__search,
|
||||||
|
.section-outlined__utility-button {
|
||||||
margin-top: units(2);
|
margin-top: units(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -82,11 +89,11 @@ body {
|
||||||
display: flex;
|
display: flex;
|
||||||
column-gap: units(3);
|
column-gap: units(3);
|
||||||
|
|
||||||
.section--outlined__search,
|
.section-outlined__search,
|
||||||
.section--outlined__utility-button {
|
.section-outlined__utility-button {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
.section--outlined__search {
|
.section-outlined__search {
|
||||||
flex-grow: 4;
|
flex-grow: 4;
|
||||||
// Align right
|
// Align right
|
||||||
max-width: 383px;
|
max-width: 383px;
|
||||||
|
@ -152,6 +159,23 @@ abbr[title] {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hidden-mobile-flex {
|
||||||
|
display: none!important;
|
||||||
|
}
|
||||||
|
.visible-mobile-flex {
|
||||||
|
display: flex!important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include at-media(tablet) {
|
||||||
|
.hidden-mobile-flex {
|
||||||
|
display: flex!important;
|
||||||
|
}
|
||||||
|
.visible-mobile-flex {
|
||||||
|
display: none!important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.flex-end {
|
.flex-end {
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
|
@ -192,3 +216,12 @@ abbr[title] {
|
||||||
max-width: 50ch;
|
max-width: 50ch;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Boost this USWDS utility class for the accordions in the portfolio requests table
|
||||||
|
.left-auto {
|
||||||
|
left: auto!important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.break-word {
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
|
@ -124,10 +124,6 @@ a.withdraw:active {
|
||||||
background-color: color('error-darker');
|
background-color: color('error-darker');
|
||||||
}
|
}
|
||||||
|
|
||||||
.usa-button--unstyled .usa-icon {
|
|
||||||
vertical-align: bottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.usa-button--unstyled:visited {
|
a.usa-button--unstyled:visited {
|
||||||
color: color('primary');
|
color: color('primary');
|
||||||
}
|
}
|
||||||
|
@ -162,14 +158,14 @@ a.usa-button--unstyled:visited {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-with-edit-button {
|
.toggleable_input {
|
||||||
svg.usa-icon {
|
svg.usa-icon {
|
||||||
width: 1.5em !important;
|
width: 1.5em !important;
|
||||||
height: 1.5em !important;
|
height: 1.5em !important;
|
||||||
color: #{$dhs-green};
|
color: #{$dhs-green};
|
||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
&.input-with-edit-button__error {
|
&.toggleable_input__error {
|
||||||
svg.usa-icon {
|
svg.usa-icon {
|
||||||
color: #{$dhs-red};
|
color: #{$dhs-red};
|
||||||
}
|
}
|
||||||
|
@ -205,12 +201,31 @@ a.usa-button--unstyled:visited {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dotgov-table a,
|
||||||
|
.usa-link--icon,
|
||||||
|
.usa-button--with-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
color: color('primary');
|
||||||
|
column-gap: units(.5);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dotgov-table a
|
||||||
|
a .usa-icon,
|
||||||
|
.usa-button--with-icon .usa-icon {
|
||||||
|
height: 1.3em;
|
||||||
|
width: 1.3em;
|
||||||
|
}
|
||||||
|
|
||||||
.usa-icon.usa-icon--big {
|
.usa-icon.usa-icon--big {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
height: 1.5em;
|
height: 1.5em;
|
||||||
width: 1.5em;
|
width: 1.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.margin-right-neg-4px {
|
button.text-secondary,
|
||||||
margin-right: -4px;
|
button.text-secondary:hover,
|
||||||
}
|
.dotgov-table a.text-secondary {
|
||||||
|
color: $theme-color-error;
|
||||||
|
}
|
||||||
|
|
|
@ -89,16 +89,24 @@
|
||||||
.usa-nav__primary {
|
.usa-nav__primary {
|
||||||
.usa-nav-link,
|
.usa-nav-link,
|
||||||
.usa-nav-link:hover,
|
.usa-nav-link:hover,
|
||||||
.usa-nav-link:active {
|
.usa-nav-link:active,
|
||||||
|
button {
|
||||||
color: color('primary');
|
color: color('primary');
|
||||||
font-weight: font-weight('normal');
|
font-weight: font-weight('normal');
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
.usa-current,
|
.usa-current,
|
||||||
.usa-current:hover,
|
.usa-current:hover,
|
||||||
.usa-current:active {
|
.usa-current:active,
|
||||||
|
button.usa-current {
|
||||||
font-weight: font-weight('bold');
|
font-weight: font-weight('bold');
|
||||||
}
|
}
|
||||||
|
button[aria-expanded="true"] {
|
||||||
|
color: color('white');
|
||||||
|
}
|
||||||
|
button:not(.usa-current):hover::after {
|
||||||
|
display: none!important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.usa-nav__secondary {
|
.usa-nav__secondary {
|
||||||
// I don't know why USWDS has this at 2 rem, which puts it out of alignment
|
// I don't know why USWDS has this at 2 rem, which puts it out of alignment
|
||||||
|
|
|
@ -1,18 +0,0 @@
|
||||||
@use "uswds-core" as *;
|
|
||||||
|
|
||||||
.dotgov-table a,
|
|
||||||
.usa-link--icon {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
color: color('primary');
|
|
||||||
|
|
||||||
&:visited {
|
|
||||||
color: color('primary');
|
|
||||||
}
|
|
||||||
.usa-icon {
|
|
||||||
// align icon with x height
|
|
||||||
margin-top: units(0.5);
|
|
||||||
margin-right: units(0.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,5 +1,10 @@
|
||||||
@use "uswds-core" as *;
|
@use "uswds-core" as *;
|
||||||
|
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
.dotgov-table--stacked {
|
.dotgov-table--stacked {
|
||||||
td, th {
|
td, th {
|
||||||
padding: units(1) units(2) units(2px) 0;
|
padding: units(1) units(2) units(2px) 0;
|
||||||
|
@ -12,7 +17,7 @@
|
||||||
|
|
||||||
tr {
|
tr {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
border-top: 2px solid color('base-light');
|
border-top: 2px solid color('base-lighter');
|
||||||
margin-top: units(2);
|
margin-top: units(2);
|
||||||
|
|
||||||
&:first-child {
|
&:first-child {
|
||||||
|
@ -39,10 +44,6 @@
|
||||||
.dotgov-table {
|
.dotgov-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
th[data-sortable]:not([aria-sort]) .usa-table__header__button {
|
|
||||||
right: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody th {
|
tbody th {
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
@ -56,7 +57,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
td, th {
|
td, th {
|
||||||
border-bottom: 1px solid color('base-light');
|
border-bottom: 1px solid color('base-lighter');
|
||||||
}
|
}
|
||||||
|
|
||||||
thead th {
|
thead th {
|
||||||
|
@ -72,11 +73,17 @@
|
||||||
|
|
||||||
td, th,
|
td, th,
|
||||||
.usa-tabel th{
|
.usa-tabel th{
|
||||||
padding: units(2) units(2) units(2) 0;
|
padding: units(2) units(4) units(2) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
thead tr:first-child th:first-child {
|
thead tr:first-child th:first-child {
|
||||||
border-top: none;
|
border-top: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@include at-media(tablet-lg) {
|
||||||
|
th[data-sortable]:not([aria-sort]) .usa-table__header__button {
|
||||||
|
right: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,6 @@
|
||||||
--- Custom Styles ---------------------------------*/
|
--- Custom Styles ---------------------------------*/
|
||||||
@forward "base";
|
@forward "base";
|
||||||
@forward "typography";
|
@forward "typography";
|
||||||
@forward "links";
|
|
||||||
@forward "lists";
|
@forward "lists";
|
||||||
@forward "accordions";
|
@forward "accordions";
|
||||||
@forward "buttons";
|
@forward "buttons";
|
||||||
|
|
|
@ -23,6 +23,9 @@ from cfenv import AppEnv # type: ignore
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Final
|
from typing import Final
|
||||||
from botocore.config import Config
|
from botocore.config import Config
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from django.utils.log import ServerFormatter
|
||||||
|
|
||||||
# # # ###
|
# # # ###
|
||||||
# Setup code goes here #
|
# Setup code goes here #
|
||||||
|
@ -57,7 +60,7 @@ env_db_url = env.dj_db_url("DATABASE_URL")
|
||||||
env_debug = env.bool("DJANGO_DEBUG", default=False)
|
env_debug = env.bool("DJANGO_DEBUG", default=False)
|
||||||
env_is_production = env.bool("IS_PRODUCTION", default=False)
|
env_is_production = env.bool("IS_PRODUCTION", default=False)
|
||||||
env_log_level = env.str("DJANGO_LOG_LEVEL", "DEBUG")
|
env_log_level = env.str("DJANGO_LOG_LEVEL", "DEBUG")
|
||||||
env_base_url = env.str("DJANGO_BASE_URL")
|
env_base_url: str = env.str("DJANGO_BASE_URL")
|
||||||
env_getgov_public_site_url = env.str("GETGOV_PUBLIC_SITE_URL", "")
|
env_getgov_public_site_url = env.str("GETGOV_PUBLIC_SITE_URL", "")
|
||||||
env_oidc_active_provider = env.str("OIDC_ACTIVE_PROVIDER", "identity sandbox")
|
env_oidc_active_provider = env.str("OIDC_ACTIVE_PROVIDER", "identity sandbox")
|
||||||
|
|
||||||
|
@ -192,7 +195,7 @@ MIDDLEWARE = [
|
||||||
"registrar.registrar_middleware.CheckPortfolioMiddleware",
|
"registrar.registrar_middleware.CheckPortfolioMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
# application object used by Django’s built-in servers (e.g. `runserver`)
|
# application object used by Django's built-in servers (e.g. `runserver`)
|
||||||
WSGI_APPLICATION = "registrar.config.wsgi.application"
|
WSGI_APPLICATION = "registrar.config.wsgi.application"
|
||||||
|
|
||||||
# endregion
|
# endregion
|
||||||
|
@ -357,13 +360,18 @@ CSP_FORM_ACTION = allowed_sources
|
||||||
# and inline with a nonce, as well as allowing connections back to their domain.
|
# and inline with a nonce, as well as allowing connections back to their domain.
|
||||||
# Note: If needed, we can embed chart.js instead of using the CDN
|
# Note: If needed, we can embed chart.js instead of using the CDN
|
||||||
CSP_DEFAULT_SRC = ("'self'",)
|
CSP_DEFAULT_SRC = ("'self'",)
|
||||||
CSP_STYLE_SRC = ["'self'", "https://www.ssa.gov/accessibility/andi/andi.css"]
|
CSP_STYLE_SRC = [
|
||||||
|
"'self'",
|
||||||
|
"https://www.ssa.gov/accessibility/andi/andi.css",
|
||||||
|
"https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css",
|
||||||
|
]
|
||||||
CSP_SCRIPT_SRC_ELEM = [
|
CSP_SCRIPT_SRC_ELEM = [
|
||||||
"'self'",
|
"'self'",
|
||||||
"https://www.googletagmanager.com/",
|
"https://www.googletagmanager.com/",
|
||||||
"https://cdn.jsdelivr.net/npm/chart.js",
|
"https://cdn.jsdelivr.net/npm/chart.js",
|
||||||
"https://www.ssa.gov",
|
"https://www.ssa.gov",
|
||||||
"https://ajax.googleapis.com",
|
"https://ajax.googleapis.com",
|
||||||
|
"https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js",
|
||||||
]
|
]
|
||||||
CSP_CONNECT_SRC = ["'self'", "https://www.google-analytics.com/", "https://www.ssa.gov/accessibility/andi/andi.js"]
|
CSP_CONNECT_SRC = ["'self'", "https://www.google-analytics.com/", "https://www.ssa.gov/accessibility/andi/andi.js"]
|
||||||
CSP_INCLUDE_NONCE_IN = ["script-src-elem", "style-src"]
|
CSP_INCLUDE_NONCE_IN = ["script-src-elem", "style-src"]
|
||||||
|
@ -410,7 +418,7 @@ LANGUAGE_COOKIE_SECURE = True
|
||||||
# and to interpret datetimes entered in forms
|
# and to interpret datetimes entered in forms
|
||||||
TIME_ZONE = "UTC"
|
TIME_ZONE = "UTC"
|
||||||
|
|
||||||
# enable Django’s translation system
|
# enable Django's translation system
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
|
|
||||||
# enable localized formatting of numbers and dates
|
# enable localized formatting of numbers and dates
|
||||||
|
@ -445,6 +453,40 @@ PHONENUMBER_DEFAULT_REGION = "US"
|
||||||
# logger.error("Can't do this important task. Something is very wrong.")
|
# logger.error("Can't do this important task. Something is very wrong.")
|
||||||
# logger.critical("Going to crash now.")
|
# logger.critical("Going to crash now.")
|
||||||
|
|
||||||
|
|
||||||
|
class JsonFormatter(logging.Formatter):
|
||||||
|
"""Formats logs into JSON for better parsing"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(datefmt="%d/%b/%Y %H:%M:%S")
|
||||||
|
|
||||||
|
def format(self, record):
|
||||||
|
log_record = {
|
||||||
|
"timestamp": self.formatTime(record, self.datefmt),
|
||||||
|
"level": record.levelname,
|
||||||
|
"name": record.name,
|
||||||
|
"lineno": record.lineno,
|
||||||
|
"message": record.getMessage(),
|
||||||
|
}
|
||||||
|
return json.dumps(log_record)
|
||||||
|
|
||||||
|
|
||||||
|
class JsonServerFormatter(ServerFormatter):
|
||||||
|
"""Formats server logs into JSON for better parsing"""
|
||||||
|
|
||||||
|
def format(self, record):
|
||||||
|
formatted_record = super().format(record)
|
||||||
|
log_entry = {"server_time": record.server_time, "level": record.levelname, "message": formatted_record}
|
||||||
|
return json.dumps(log_entry)
|
||||||
|
|
||||||
|
|
||||||
|
# default to json formatted logs
|
||||||
|
server_formatter, console_formatter = "json.server", "json"
|
||||||
|
|
||||||
|
# don't use json format locally, it makes logs hard to read in console
|
||||||
|
if "localhost" in env_base_url:
|
||||||
|
server_formatter, console_formatter = "django.server", "verbose"
|
||||||
|
|
||||||
LOGGING = {
|
LOGGING = {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
# Don't import Django's existing loggers
|
# Don't import Django's existing loggers
|
||||||
|
@ -464,6 +506,12 @@ LOGGING = {
|
||||||
"format": "[{server_time}] {message}",
|
"format": "[{server_time}] {message}",
|
||||||
"style": "{",
|
"style": "{",
|
||||||
},
|
},
|
||||||
|
"json.server": {
|
||||||
|
"()": JsonServerFormatter,
|
||||||
|
},
|
||||||
|
"json": {
|
||||||
|
"()": JsonFormatter,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
# define where log messages will be sent;
|
# define where log messages will be sent;
|
||||||
# each logger can have one or more handlers
|
# each logger can have one or more handlers
|
||||||
|
@ -471,12 +519,12 @@ LOGGING = {
|
||||||
"console": {
|
"console": {
|
||||||
"level": env_log_level,
|
"level": env_log_level,
|
||||||
"class": "logging.StreamHandler",
|
"class": "logging.StreamHandler",
|
||||||
"formatter": "verbose",
|
"formatter": console_formatter,
|
||||||
},
|
},
|
||||||
"django.server": {
|
"django.server": {
|
||||||
"level": "INFO",
|
"level": "INFO",
|
||||||
"class": "logging.StreamHandler",
|
"class": "logging.StreamHandler",
|
||||||
"formatter": "django.server",
|
"formatter": server_formatter,
|
||||||
},
|
},
|
||||||
# No file logger is configured,
|
# No file logger is configured,
|
||||||
# because containerized apps
|
# because containerized apps
|
||||||
|
|
|
@ -24,7 +24,11 @@ from registrar.views.report_views import (
|
||||||
|
|
||||||
from registrar.views.domain_request import Step
|
from registrar.views.domain_request import Step
|
||||||
from registrar.views.domain_requests_json import get_domain_requests_json
|
from registrar.views.domain_requests_json import get_domain_requests_json
|
||||||
from registrar.views.utility.api_views import get_senior_official_from_federal_agency_json
|
from registrar.views.transfer_user import TransferUserView
|
||||||
|
from registrar.views.utility.api_views import (
|
||||||
|
get_senior_official_from_federal_agency_json,
|
||||||
|
get_federal_and_portfolio_types_from_federal_agency_json,
|
||||||
|
)
|
||||||
from registrar.views.domains_json import get_domains_json
|
from registrar.views.domains_json import get_domains_json
|
||||||
from registrar.views.utility import always_404
|
from registrar.views.utility import always_404
|
||||||
from api.views import available, get_current_federal, get_current_full
|
from api.views import available, get_current_federal, get_current_full
|
||||||
|
@ -49,7 +53,6 @@ for step, view in [
|
||||||
(Step.CURRENT_SITES, views.CurrentSites),
|
(Step.CURRENT_SITES, views.CurrentSites),
|
||||||
(Step.DOTGOV_DOMAIN, views.DotgovDomain),
|
(Step.DOTGOV_DOMAIN, views.DotgovDomain),
|
||||||
(Step.PURPOSE, views.Purpose),
|
(Step.PURPOSE, views.Purpose),
|
||||||
(Step.YOUR_CONTACT, views.YourContact),
|
|
||||||
(Step.OTHER_CONTACTS, views.OtherContacts),
|
(Step.OTHER_CONTACTS, views.OtherContacts),
|
||||||
(Step.ADDITIONAL_DETAILS, views.AdditionalDetails),
|
(Step.ADDITIONAL_DETAILS, views.AdditionalDetails),
|
||||||
(Step.REQUIREMENTS, views.Requirements),
|
(Step.REQUIREMENTS, views.Requirements),
|
||||||
|
@ -75,6 +78,11 @@ urlpatterns = [
|
||||||
views.PortfolioDomainRequestsView.as_view(),
|
views.PortfolioDomainRequestsView.as_view(),
|
||||||
name="domain-requests",
|
name="domain-requests",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"no-organization-requests/",
|
||||||
|
views.PortfolioNoDomainRequestsView.as_view(),
|
||||||
|
name="no-portfolio-requests",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"organization/",
|
"organization/",
|
||||||
views.PortfolioOrganizationView.as_view(),
|
views.PortfolioOrganizationView.as_view(),
|
||||||
|
@ -134,11 +142,17 @@ urlpatterns = [
|
||||||
AnalyticsView.as_view(),
|
AnalyticsView.as_view(),
|
||||||
name="analytics",
|
name="analytics",
|
||||||
),
|
),
|
||||||
|
path("admin/registrar/user/<int:user_id>/transfer/", TransferUserView.as_view(), name="transfer_user"),
|
||||||
path(
|
path(
|
||||||
"admin/api/get-senior-official-from-federal-agency-json/",
|
"admin/api/get-senior-official-from-federal-agency-json/",
|
||||||
get_senior_official_from_federal_agency_json,
|
get_senior_official_from_federal_agency_json,
|
||||||
name="get-senior-official-from-federal-agency-json",
|
name="get-senior-official-from-federal-agency-json",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"admin/api/get-federal-and-portfolio-types-from-federal-agency-json/",
|
||||||
|
get_federal_and_portfolio_types_from_federal_agency_json,
|
||||||
|
name="get-federal-and-portfolio-types-from-federal-agency-json",
|
||||||
|
),
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path(
|
path(
|
||||||
"reports/export_data_type_user/",
|
"reports/export_data_type_user/",
|
||||||
|
@ -198,11 +212,6 @@ urlpatterns = [
|
||||||
views.DomainDsDataView.as_view(),
|
views.DomainDsDataView.as_view(),
|
||||||
name="domain-dns-dnssec-dsdata",
|
name="domain-dns-dnssec-dsdata",
|
||||||
),
|
),
|
||||||
path(
|
|
||||||
"domain/<int:pk>/your-contact-information",
|
|
||||||
views.DomainYourContactInformationView.as_view(),
|
|
||||||
name="domain-your-contact-information",
|
|
||||||
),
|
|
||||||
path(
|
path(
|
||||||
"domain/<int:pk>/org-name-address",
|
"domain/<int:pk>/org-name-address",
|
||||||
views.DomainOrgNameAddressView.as_view(),
|
views.DomainOrgNameAddressView.as_view(),
|
||||||
|
|
|
@ -60,28 +60,42 @@ def add_has_profile_feature_flag_to_context(request):
|
||||||
|
|
||||||
def portfolio_permissions(request):
|
def portfolio_permissions(request):
|
||||||
"""Make portfolio permissions for the request user available in global context"""
|
"""Make portfolio permissions for the request user available in global context"""
|
||||||
|
portfolio_context = {
|
||||||
|
"has_base_portfolio_permission": False,
|
||||||
|
"has_any_domains_portfolio_permission": False,
|
||||||
|
"has_any_requests_portfolio_permission": False,
|
||||||
|
"has_edit_request_portfolio_permission": False,
|
||||||
|
"has_view_suborganization_portfolio_permission": False,
|
||||||
|
"has_edit_suborganization_portfolio_permission": False,
|
||||||
|
"has_view_members_portfolio_permission": False,
|
||||||
|
"has_edit_members_portfolio_permission": False,
|
||||||
|
"portfolio": None,
|
||||||
|
"has_organization_feature_flag": False,
|
||||||
|
"has_organization_requests_flag": False,
|
||||||
|
"has_organization_members_flag": False,
|
||||||
|
}
|
||||||
try:
|
try:
|
||||||
if not request.user or not request.user.is_authenticated or not flag_is_active(request, "organization_feature"):
|
portfolio = request.session.get("portfolio")
|
||||||
|
# Linting: line too long
|
||||||
|
view_suborg = request.user.has_view_suborganization_portfolio_permission(portfolio)
|
||||||
|
edit_suborg = request.user.has_edit_suborganization_portfolio_permission(portfolio)
|
||||||
|
if portfolio:
|
||||||
return {
|
return {
|
||||||
"has_base_portfolio_permission": False,
|
"has_base_portfolio_permission": request.user.has_base_portfolio_permission(portfolio),
|
||||||
"has_domains_portfolio_permission": False,
|
"has_edit_request_portfolio_permission": request.user.has_edit_request_portfolio_permission(portfolio),
|
||||||
"has_domain_requests_portfolio_permission": False,
|
"has_view_suborganization_portfolio_permission": view_suborg,
|
||||||
"portfolio": None,
|
"has_edit_suborganization_portfolio_permission": edit_suborg,
|
||||||
"has_organization_feature_flag": False,
|
"has_any_domains_portfolio_permission": request.user.has_any_domains_portfolio_permission(portfolio),
|
||||||
|
"has_any_requests_portfolio_permission": request.user.has_any_requests_portfolio_permission(portfolio),
|
||||||
|
"has_view_members_portfolio_permission": request.user.has_view_members_portfolio_permission(portfolio),
|
||||||
|
"has_edit_members_portfolio_permission": request.user.has_edit_members_portfolio_permission(portfolio),
|
||||||
|
"portfolio": portfolio,
|
||||||
|
"has_organization_feature_flag": True,
|
||||||
|
"has_organization_requests_flag": request.user.has_organization_requests_flag(),
|
||||||
|
"has_organization_members_flag": request.user.has_organization_members_flag(),
|
||||||
}
|
}
|
||||||
return {
|
return portfolio_context
|
||||||
"has_base_portfolio_permission": request.user.has_base_portfolio_permission(),
|
|
||||||
"has_domains_portfolio_permission": request.user.has_domains_portfolio_permission(),
|
|
||||||
"has_domain_requests_portfolio_permission": request.user.has_domain_requests_portfolio_permission(),
|
|
||||||
"portfolio": request.user.portfolio,
|
|
||||||
"has_organization_feature_flag": True,
|
|
||||||
}
|
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
# Handles cases where request.user might not exist
|
# Handles cases where request.user might not exist
|
||||||
return {
|
return portfolio_context
|
||||||
"has_base_portfolio_permission": False,
|
|
||||||
"has_domains_portfolio_permission": False,
|
|
||||||
"has_domain_requests_portfolio_permission": False,
|
|
||||||
"portfolio": None,
|
|
||||||
"has_organization_feature_flag": False,
|
|
||||||
}
|
|
||||||
|
|
|
@ -37,7 +37,6 @@ class DomainRequestFixture:
|
||||||
# "anything_else": None,
|
# "anything_else": None,
|
||||||
# "is_policy_acknowledged": None,
|
# "is_policy_acknowledged": None,
|
||||||
# "senior_official": None,
|
# "senior_official": None,
|
||||||
# "submitter": None,
|
|
||||||
# "other_contacts": [],
|
# "other_contacts": [],
|
||||||
# "current_websites": [],
|
# "current_websites": [],
|
||||||
# "alternative_domains": [],
|
# "alternative_domains": [],
|
||||||
|
@ -95,7 +94,7 @@ class DomainRequestFixture:
|
||||||
|
|
||||||
# TODO for a future ticket: Allow for more than just "federal" here
|
# TODO for a future ticket: Allow for more than just "federal" here
|
||||||
da.generic_org_type = app["generic_org_type"] if "generic_org_type" in app else "federal"
|
da.generic_org_type = app["generic_org_type"] if "generic_org_type" in app else "federal"
|
||||||
da.submission_date = fake.date()
|
da.last_submitted_date = fake.date()
|
||||||
da.federal_type = (
|
da.federal_type = (
|
||||||
app["federal_type"]
|
app["federal_type"]
|
||||||
if "federal_type" in app
|
if "federal_type" in app
|
||||||
|
@ -123,12 +122,6 @@ class DomainRequestFixture:
|
||||||
else:
|
else:
|
||||||
da.senior_official = Contact.objects.create(**cls.fake_contact())
|
da.senior_official = Contact.objects.create(**cls.fake_contact())
|
||||||
|
|
||||||
if not da.submitter:
|
|
||||||
if "submitter" in app and app["submitter"] is not None:
|
|
||||||
da.submitter, _ = Contact.objects.get_or_create(**app["submitter"])
|
|
||||||
else:
|
|
||||||
da.submitter = Contact.objects.create(**cls.fake_contact())
|
|
||||||
|
|
||||||
if not da.requested_domain:
|
if not da.requested_domain:
|
||||||
if "requested_domain" in app and app["requested_domain"] is not None:
|
if "requested_domain" in app and app["requested_domain"] is not None:
|
||||||
da.requested_domain, _ = DraftDomain.objects.get_or_create(name=app["requested_domain"])
|
da.requested_domain, _ = DraftDomain.objects.get_or_create(name=app["requested_domain"])
|
||||||
|
|
|
@ -6,6 +6,7 @@ from registrar.models import (
|
||||||
User,
|
User,
|
||||||
UserGroup,
|
UserGroup,
|
||||||
)
|
)
|
||||||
|
from registrar.models.allowed_email import AllowedEmail
|
||||||
|
|
||||||
|
|
||||||
fake = Faker()
|
fake = Faker()
|
||||||
|
@ -32,6 +33,7 @@ class UserFixture:
|
||||||
"username": "aad084c3-66cc-4632-80eb-41cdf5c5bcbf",
|
"username": "aad084c3-66cc-4632-80eb-41cdf5c5bcbf",
|
||||||
"first_name": "Aditi",
|
"first_name": "Aditi",
|
||||||
"last_name": "Green",
|
"last_name": "Green",
|
||||||
|
"email": "aditidevelops+01@gmail.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "be17c826-e200-4999-9389-2ded48c43691",
|
"username": "be17c826-e200-4999-9389-2ded48c43691",
|
||||||
|
@ -42,16 +44,19 @@ class UserFixture:
|
||||||
"username": "5f283494-31bd-49b5-b024-a7e7cae00848",
|
"username": "5f283494-31bd-49b5-b024-a7e7cae00848",
|
||||||
"first_name": "Rachid",
|
"first_name": "Rachid",
|
||||||
"last_name": "Mrad",
|
"last_name": "Mrad",
|
||||||
|
"email": "rachid.mrad@associates.cisa.dhs.gov",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "eb2214cd-fc0c-48c0-9dbd-bc4cd6820c74",
|
"username": "eb2214cd-fc0c-48c0-9dbd-bc4cd6820c74",
|
||||||
"first_name": "Alysia",
|
"first_name": "Alysia",
|
||||||
"last_name": "Broddrick",
|
"last_name": "Broddrick",
|
||||||
|
"email": "abroddrick@truss.works",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "8f8e7293-17f7-4716-889b-1990241cbd39",
|
"username": "8f8e7293-17f7-4716-889b-1990241cbd39",
|
||||||
"first_name": "Katherine",
|
"first_name": "Katherine",
|
||||||
"last_name": "Osos",
|
"last_name": "Osos",
|
||||||
|
"email": "kosos@truss.works",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "70488e0a-e937-4894-a28c-16f5949effd4",
|
"username": "70488e0a-e937-4894-a28c-16f5949effd4",
|
||||||
|
@ -63,6 +68,7 @@ class UserFixture:
|
||||||
"username": "83c2b6dd-20a2-4cac-bb40-e22a72d2955c",
|
"username": "83c2b6dd-20a2-4cac-bb40-e22a72d2955c",
|
||||||
"first_name": "Cameron",
|
"first_name": "Cameron",
|
||||||
"last_name": "Dixon",
|
"last_name": "Dixon",
|
||||||
|
"email": "cameron.dixon@cisa.dhs.gov",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "0353607a-cbba-47d2-98d7-e83dcd5b90ea",
|
"username": "0353607a-cbba-47d2-98d7-e83dcd5b90ea",
|
||||||
|
@ -83,16 +89,19 @@ class UserFixture:
|
||||||
"username": "2a88a97b-be96-4aad-b99e-0b605b492c78",
|
"username": "2a88a97b-be96-4aad-b99e-0b605b492c78",
|
||||||
"first_name": "Rebecca",
|
"first_name": "Rebecca",
|
||||||
"last_name": "Hsieh",
|
"last_name": "Hsieh",
|
||||||
|
"email": "rebecca.hsieh@truss.works",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "fa69c8e8-da83-4798-a4f2-263c9ce93f52",
|
"username": "fa69c8e8-da83-4798-a4f2-263c9ce93f52",
|
||||||
"first_name": "David",
|
"first_name": "David",
|
||||||
"last_name": "Kennedy",
|
"last_name": "Kennedy",
|
||||||
|
"email": "david.kennedy@ecstech.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "f14433d8-f0e9-41bf-9c72-b99b110e665d",
|
"username": "f14433d8-f0e9-41bf-9c72-b99b110e665d",
|
||||||
"first_name": "Nicolle",
|
"first_name": "Nicolle",
|
||||||
"last_name": "LeClair",
|
"last_name": "LeClair",
|
||||||
|
"email": "nicolle.leclair@ecstech.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "24840450-bf47-4d89-8aa9-c612fe68f9da",
|
"username": "24840450-bf47-4d89-8aa9-c612fe68f9da",
|
||||||
|
@ -141,6 +150,7 @@ class UserFixture:
|
||||||
"username": "ffec5987-aa84-411b-a05a-a7ee5cbcde54",
|
"username": "ffec5987-aa84-411b-a05a-a7ee5cbcde54",
|
||||||
"first_name": "Aditi-Analyst",
|
"first_name": "Aditi-Analyst",
|
||||||
"last_name": "Green-Analyst",
|
"last_name": "Green-Analyst",
|
||||||
|
"email": "aditidevelops+02@gmail.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "d6bf296b-fac5-47ff-9c12-f88ccc5c1b99",
|
"username": "d6bf296b-fac5-47ff-9c12-f88ccc5c1b99",
|
||||||
|
@ -162,7 +172,7 @@ class UserFixture:
|
||||||
"username": "91a9b97c-bd0a-458d-9823-babfde7ebf44",
|
"username": "91a9b97c-bd0a-458d-9823-babfde7ebf44",
|
||||||
"first_name": "Katherine-Analyst",
|
"first_name": "Katherine-Analyst",
|
||||||
"last_name": "Osos-Analyst",
|
"last_name": "Osos-Analyst",
|
||||||
"email": "kosos@truss.works",
|
"email": "kosos+1@truss.works",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "2cc0cde8-8313-4a50-99d8-5882e71443e8",
|
"username": "2cc0cde8-8313-4a50-99d8-5882e71443e8",
|
||||||
|
@ -183,6 +193,7 @@ class UserFixture:
|
||||||
"username": "5dc6c9a6-61d9-42b4-ba54-4beff28bac3c",
|
"username": "5dc6c9a6-61d9-42b4-ba54-4beff28bac3c",
|
||||||
"first_name": "David-Analyst",
|
"first_name": "David-Analyst",
|
||||||
"last_name": "Kennedy-Analyst",
|
"last_name": "Kennedy-Analyst",
|
||||||
|
"email": "david.kennedy@associates.cisa.dhs.gov",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "0eb6f326-a3d4-410f-a521-aa4c1fad4e47",
|
"username": "0eb6f326-a3d4-410f-a521-aa4c1fad4e47",
|
||||||
|
@ -194,7 +205,7 @@ class UserFixture:
|
||||||
"username": "cfe7c2fc-e24a-480e-8b78-28645a1459b3",
|
"username": "cfe7c2fc-e24a-480e-8b78-28645a1459b3",
|
||||||
"first_name": "Nicolle-Analyst",
|
"first_name": "Nicolle-Analyst",
|
||||||
"last_name": "LeClair-Analyst",
|
"last_name": "LeClair-Analyst",
|
||||||
"email": "nicolle.leclair@ecstech.com",
|
"email": "nicolle.leclair@gmail.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "378d0bc4-d5a7-461b-bd84-3ae6f6864af9",
|
"username": "378d0bc4-d5a7-461b-bd84-3ae6f6864af9",
|
||||||
|
@ -240,6 +251,9 @@ class UserFixture:
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Additional emails to add to the AllowedEmail whitelist.
|
||||||
|
ADDITIONAL_ALLOWED_EMAILS: list[str] = ["davekenn4242@gmail.com", "rachid_mrad@hotmail.com"]
|
||||||
|
|
||||||
def load_users(cls, users, group_name, are_superusers=False):
|
def load_users(cls, users, group_name, are_superusers=False):
|
||||||
logger.info(f"Going to load {len(users)} users in group {group_name}")
|
logger.info(f"Going to load {len(users)} users in group {group_name}")
|
||||||
for user_data in users:
|
for user_data in users:
|
||||||
|
@ -264,6 +278,32 @@ class UserFixture:
|
||||||
logger.warning(e)
|
logger.warning(e)
|
||||||
logger.info(f"All users in group {group_name} loaded.")
|
logger.info(f"All users in group {group_name} loaded.")
|
||||||
|
|
||||||
|
def load_allowed_emails(cls, users, additional_emails):
|
||||||
|
"""Populates a whitelist of allowed emails (as defined in this list)"""
|
||||||
|
logger.info(f"Going to load allowed emails for {len(users)} users")
|
||||||
|
if additional_emails:
|
||||||
|
logger.info(f"Going to load {len(additional_emails)} additional allowed emails")
|
||||||
|
|
||||||
|
# Load user emails
|
||||||
|
allowed_emails = []
|
||||||
|
for user_data in users:
|
||||||
|
user_email = user_data.get("email")
|
||||||
|
if user_email and user_email not in allowed_emails:
|
||||||
|
allowed_emails.append(AllowedEmail(email=user_email))
|
||||||
|
else:
|
||||||
|
first_name = user_data.get("first_name")
|
||||||
|
last_name = user_data.get("last_name")
|
||||||
|
logger.warning(f"Could not add email to whitelist for {first_name} {last_name}.")
|
||||||
|
|
||||||
|
# Load additional emails
|
||||||
|
allowed_emails.extend([AllowedEmail(email=email) for email in additional_emails])
|
||||||
|
|
||||||
|
if allowed_emails:
|
||||||
|
AllowedEmail.objects.bulk_create(allowed_emails)
|
||||||
|
logger.info(f"Loaded {len(allowed_emails)} allowed emails")
|
||||||
|
else:
|
||||||
|
logger.info("No allowed emails to load")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load(cls):
|
def load(cls):
|
||||||
# Lumped under .atomic to ensure we don't make redundant DB calls.
|
# Lumped under .atomic to ensure we don't make redundant DB calls.
|
||||||
|
@ -275,3 +315,7 @@ class UserFixture:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
cls.load_users(cls, cls.ADMINS, "full_access_group", are_superusers=True)
|
cls.load_users(cls, cls.ADMINS, "full_access_group", are_superusers=True)
|
||||||
cls.load_users(cls, cls.STAFF, "cisa_analysts_group")
|
cls.load_users(cls, cls.STAFF, "cisa_analysts_group")
|
||||||
|
|
||||||
|
# Combine ADMINS and STAFF lists
|
||||||
|
all_users = cls.ADMINS + cls.STAFF
|
||||||
|
cls.load_allowed_emails(cls, all_users, additional_emails=cls.ADDITIONAL_ALLOWED_EMAILS)
|
||||||
|
|
|
@ -417,7 +417,7 @@ class SeniorOfficialContactForm(ContactForm):
|
||||||
# This action should be blocked by the UI, as the text fields are readonly.
|
# This action should be blocked by the UI, as the text fields are readonly.
|
||||||
# If they get past this point, we forbid it this way.
|
# If they get past this point, we forbid it this way.
|
||||||
# This could be malicious, so lets reserve information for the backend only.
|
# This could be malicious, so lets reserve information for the backend only.
|
||||||
raise ValueError("Senior Official cannot be modified for federal or tribal domains.")
|
raise ValueError("Senior official cannot be modified for federal or tribal domains.")
|
||||||
elif db_so.has_more_than_one_join("information_senior_official"):
|
elif db_so.has_more_than_one_join("information_senior_official"):
|
||||||
# Handle the case where the domain information object is available and the SO Contact
|
# Handle the case where the domain information object is available and the SO Contact
|
||||||
# has more than one joined object.
|
# has more than one joined object.
|
||||||
|
|
|
@ -386,64 +386,6 @@ class PurposeForm(RegistrarForm):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class YourContactForm(RegistrarForm):
|
|
||||||
JOIN = "submitter"
|
|
||||||
|
|
||||||
def to_database(self, obj):
|
|
||||||
if not self.is_valid():
|
|
||||||
return
|
|
||||||
contact = getattr(obj, "submitter", None)
|
|
||||||
if contact is not None and not contact.has_more_than_one_join("submitted_domain_requests"):
|
|
||||||
# if contact exists in the database and is not joined to other entities
|
|
||||||
super().to_database(contact)
|
|
||||||
else:
|
|
||||||
# no contact exists OR contact exists which is joined also to other entities;
|
|
||||||
# in either case, create a new contact and update it
|
|
||||||
contact = Contact()
|
|
||||||
super().to_database(contact)
|
|
||||||
obj.submitter = contact
|
|
||||||
obj.save()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_database(cls, obj):
|
|
||||||
contact = getattr(obj, "submitter", None)
|
|
||||||
return super().from_database(contact)
|
|
||||||
|
|
||||||
first_name = forms.CharField(
|
|
||||||
label="First name / given name",
|
|
||||||
error_messages={"required": "Enter your first name / given name."},
|
|
||||||
)
|
|
||||||
middle_name = forms.CharField(
|
|
||||||
required=False,
|
|
||||||
label="Middle name (optional)",
|
|
||||||
)
|
|
||||||
last_name = forms.CharField(
|
|
||||||
label="Last name / family name",
|
|
||||||
error_messages={"required": "Enter your last name / family name."},
|
|
||||||
)
|
|
||||||
title = forms.CharField(
|
|
||||||
label="Title or role in your organization",
|
|
||||||
error_messages={
|
|
||||||
"required": ("Enter your title or role in your organization (e.g., Chief Information Officer).")
|
|
||||||
},
|
|
||||||
)
|
|
||||||
email = forms.EmailField(
|
|
||||||
label="Email",
|
|
||||||
max_length=None,
|
|
||||||
error_messages={"invalid": ("Enter your email address in the required format, like name@example.com.")},
|
|
||||||
validators=[
|
|
||||||
MaxLengthValidator(
|
|
||||||
320,
|
|
||||||
message="Response must be less than 320 characters.",
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
phone = PhoneNumberField(
|
|
||||||
label="Phone",
|
|
||||||
error_messages={"invalid": "Enter a valid 10-digit phone number.", "required": "Enter your phone number."},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class OtherContactsYesNoForm(BaseYesNoForm):
|
class OtherContactsYesNoForm(BaseYesNoForm):
|
||||||
"""The yes/no field for the OtherContacts form."""
|
"""The yes/no field for the OtherContacts form."""
|
||||||
|
|
||||||
|
|
|
@ -21,7 +21,7 @@ class Command(BaseCommand):
|
||||||
|
|
||||||
TerminalHelper.prompt_for_execution(
|
TerminalHelper.prompt_for_execution(
|
||||||
system_exit_on_terminate=True,
|
system_exit_on_terminate=True,
|
||||||
info_to_inspect="""
|
prompt_message="""
|
||||||
This script will delete all rows from the following tables:
|
This script will delete all rows from the following tables:
|
||||||
* Contact
|
* Contact
|
||||||
* Domain
|
* Domain
|
||||||
|
|
255
src/registrar/management/commands/create_federal_portfolio.py
Normal file
255
src/registrar/management/commands/create_federal_portfolio.py
Normal file
|
@ -0,0 +1,255 @@
|
||||||
|
"""Loads files from /tmp into our sandboxes"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
from django.core.management import BaseCommand, CommandError
|
||||||
|
from registrar.management.commands.utility.terminal_helper import TerminalColors, TerminalHelper
|
||||||
|
from registrar.models import DomainInformation, DomainRequest, FederalAgency, Suborganization, Portfolio, User
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Creates a federal portfolio given a FederalAgency name"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
"""Add three arguments:
|
||||||
|
1. agency_name => the value of FederalAgency.agency
|
||||||
|
2. --parse_requests => if true, adds the given portfolio to each related DomainRequest
|
||||||
|
3. --parse_domains => if true, adds the given portfolio to each related DomainInformation
|
||||||
|
"""
|
||||||
|
parser.add_argument(
|
||||||
|
"agency_name",
|
||||||
|
help="The name of the FederalAgency to add",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--parse_requests",
|
||||||
|
action=argparse.BooleanOptionalAction,
|
||||||
|
help="Adds portfolio to DomainRequests",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--parse_domains",
|
||||||
|
action=argparse.BooleanOptionalAction,
|
||||||
|
help="Adds portfolio to DomainInformation",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--both",
|
||||||
|
action=argparse.BooleanOptionalAction,
|
||||||
|
help="Adds portfolio to both requests and domains",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, agency_name, **options):
|
||||||
|
parse_requests = options.get("parse_requests")
|
||||||
|
parse_domains = options.get("parse_domains")
|
||||||
|
both = options.get("both")
|
||||||
|
|
||||||
|
if not both:
|
||||||
|
if not parse_requests and not parse_domains:
|
||||||
|
raise CommandError("You must specify at least one of --parse_requests or --parse_domains.")
|
||||||
|
else:
|
||||||
|
if parse_requests or parse_domains:
|
||||||
|
raise CommandError("You cannot pass --parse_requests or --parse_domains when passing --both.")
|
||||||
|
|
||||||
|
federal_agency = FederalAgency.objects.filter(agency__iexact=agency_name).first()
|
||||||
|
if not federal_agency:
|
||||||
|
raise ValueError(
|
||||||
|
f"Cannot find the federal agency '{agency_name}' in our database. "
|
||||||
|
"The value you enter for `agency_name` must be "
|
||||||
|
"prepopulated in the FederalAgency table before proceeding."
|
||||||
|
)
|
||||||
|
|
||||||
|
portfolio = self.create_or_modify_portfolio(federal_agency)
|
||||||
|
self.create_suborganizations(portfolio, federal_agency)
|
||||||
|
|
||||||
|
if parse_requests or both:
|
||||||
|
self.handle_portfolio_requests(portfolio, federal_agency)
|
||||||
|
|
||||||
|
if parse_domains or both:
|
||||||
|
self.handle_portfolio_domains(portfolio, federal_agency)
|
||||||
|
|
||||||
|
def create_or_modify_portfolio(self, federal_agency):
|
||||||
|
"""Creates or modifies a portfolio record based on a federal agency."""
|
||||||
|
portfolio_args = {
|
||||||
|
"federal_agency": federal_agency,
|
||||||
|
"organization_name": federal_agency.agency,
|
||||||
|
"organization_type": DomainRequest.OrganizationChoices.FEDERAL,
|
||||||
|
"creator": User.get_default_user(),
|
||||||
|
"notes": "Auto-generated record",
|
||||||
|
}
|
||||||
|
|
||||||
|
if federal_agency.so_federal_agency.exists():
|
||||||
|
portfolio_args["senior_official"] = federal_agency.so_federal_agency.first()
|
||||||
|
|
||||||
|
portfolio, created = Portfolio.objects.get_or_create(
|
||||||
|
organization_name=portfolio_args.get("organization_name"), defaults=portfolio_args
|
||||||
|
)
|
||||||
|
|
||||||
|
if created:
|
||||||
|
message = f"Created portfolio '{portfolio}'"
|
||||||
|
TerminalHelper.colorful_logger(logger.info, TerminalColors.OKGREEN, message)
|
||||||
|
|
||||||
|
if portfolio_args.get("senior_official"):
|
||||||
|
message = f"Added senior official '{portfolio_args['senior_official']}'"
|
||||||
|
TerminalHelper.colorful_logger(logger.info, TerminalColors.OKGREEN, message)
|
||||||
|
else:
|
||||||
|
message = (
|
||||||
|
f"No senior official added to portfolio '{portfolio}'. "
|
||||||
|
"None was returned for the reverse relation `FederalAgency.so_federal_agency.first()`"
|
||||||
|
)
|
||||||
|
TerminalHelper.colorful_logger(logger.info, TerminalColors.YELLOW, message)
|
||||||
|
else:
|
||||||
|
proceed = TerminalHelper.prompt_for_execution(
|
||||||
|
system_exit_on_terminate=False,
|
||||||
|
prompt_message=f"""
|
||||||
|
The given portfolio '{federal_agency.agency}' already exists in our DB.
|
||||||
|
If you cancel, the rest of the script will still execute but this record will not update.
|
||||||
|
""",
|
||||||
|
prompt_title="Do you wish to modify this record?",
|
||||||
|
)
|
||||||
|
if proceed:
|
||||||
|
|
||||||
|
# Don't override the creator and notes fields
|
||||||
|
if portfolio.creator:
|
||||||
|
portfolio_args.pop("creator")
|
||||||
|
|
||||||
|
if portfolio.notes:
|
||||||
|
portfolio_args.pop("notes")
|
||||||
|
|
||||||
|
# Update everything else
|
||||||
|
for key, value in portfolio_args.items():
|
||||||
|
setattr(portfolio, key, value)
|
||||||
|
|
||||||
|
portfolio.save()
|
||||||
|
message = f"Modified portfolio '{portfolio}'"
|
||||||
|
TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message)
|
||||||
|
|
||||||
|
if portfolio_args.get("senior_official"):
|
||||||
|
message = f"Added/modified senior official '{portfolio_args['senior_official']}'"
|
||||||
|
TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message)
|
||||||
|
|
||||||
|
return portfolio
|
||||||
|
|
||||||
|
def create_suborganizations(self, portfolio: Portfolio, federal_agency: FederalAgency):
|
||||||
|
"""Create Suborganizations tied to the given portfolio based on DomainInformation objects"""
|
||||||
|
valid_agencies = DomainInformation.objects.filter(
|
||||||
|
federal_agency=federal_agency, organization_name__isnull=False
|
||||||
|
)
|
||||||
|
org_names = set(valid_agencies.values_list("organization_name", flat=True))
|
||||||
|
|
||||||
|
if not org_names:
|
||||||
|
message = (
|
||||||
|
"Could not add any suborganizations."
|
||||||
|
f"\nNo suborganizations were found for '{federal_agency}' when filtering on this name, "
|
||||||
|
"and excluding null organization_name records."
|
||||||
|
)
|
||||||
|
TerminalHelper.colorful_logger(logger.warning, TerminalColors.FAIL, message)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if we need to update any existing suborgs first. This step is optional.
|
||||||
|
existing_suborgs = Suborganization.objects.filter(name__in=org_names)
|
||||||
|
if existing_suborgs.exists():
|
||||||
|
self._update_existing_suborganizations(portfolio, existing_suborgs)
|
||||||
|
|
||||||
|
# Create new suborgs, as long as they don't exist in the db already
|
||||||
|
new_suborgs = []
|
||||||
|
for name in org_names - set(existing_suborgs.values_list("name", flat=True)):
|
||||||
|
# Stored in variables due to linter wanting type information here.
|
||||||
|
portfolio_name: str = portfolio.organization_name if portfolio.organization_name is not None else ""
|
||||||
|
if name is not None and name.lower() == portfolio_name.lower():
|
||||||
|
# You can use this to populate location information, when this occurs.
|
||||||
|
# However, this isn't needed for now so we can skip it.
|
||||||
|
message = (
|
||||||
|
f"Skipping suborganization create on record '{name}'. "
|
||||||
|
"The federal agency name is the same as the portfolio name."
|
||||||
|
)
|
||||||
|
TerminalHelper.colorful_logger(logger.warning, TerminalColors.YELLOW, message)
|
||||||
|
else:
|
||||||
|
new_suborgs.append(Suborganization(name=name, portfolio=portfolio)) # type: ignore
|
||||||
|
|
||||||
|
if new_suborgs:
|
||||||
|
Suborganization.objects.bulk_create(new_suborgs)
|
||||||
|
TerminalHelper.colorful_logger(
|
||||||
|
logger.info, TerminalColors.OKGREEN, f"Added {len(new_suborgs)} suborganizations"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
TerminalHelper.colorful_logger(logger.warning, TerminalColors.YELLOW, "No suborganizations added")
|
||||||
|
|
||||||
|
def _update_existing_suborganizations(self, portfolio, orgs_to_update):
|
||||||
|
"""
|
||||||
|
Update existing suborganizations with new portfolio.
|
||||||
|
Prompts for user confirmation before proceeding.
|
||||||
|
"""
|
||||||
|
proceed = TerminalHelper.prompt_for_execution(
|
||||||
|
system_exit_on_terminate=False,
|
||||||
|
prompt_message=f"""Some suborganizations already exist in our DB.
|
||||||
|
If you cancel, the rest of the script will still execute but these records will not update.
|
||||||
|
|
||||||
|
==Proposed Changes==
|
||||||
|
The following suborgs will be updated: {[org.name for org in orgs_to_update]}
|
||||||
|
""",
|
||||||
|
prompt_title="Do you wish to modify existing suborganizations?",
|
||||||
|
)
|
||||||
|
if proceed:
|
||||||
|
for org in orgs_to_update:
|
||||||
|
org.portfolio = portfolio
|
||||||
|
|
||||||
|
Suborganization.objects.bulk_update(orgs_to_update, ["portfolio"])
|
||||||
|
message = f"Updated {len(orgs_to_update)} suborganizations."
|
||||||
|
TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message)
|
||||||
|
|
||||||
|
def handle_portfolio_requests(self, portfolio: Portfolio, federal_agency: FederalAgency):
|
||||||
|
"""
|
||||||
|
Associate portfolio with domain requests for a federal agency.
|
||||||
|
Updates all relevant domain request records.
|
||||||
|
"""
|
||||||
|
invalid_states = [
|
||||||
|
DomainRequest.DomainRequestStatus.STARTED,
|
||||||
|
DomainRequest.DomainRequestStatus.INELIGIBLE,
|
||||||
|
DomainRequest.DomainRequestStatus.REJECTED,
|
||||||
|
]
|
||||||
|
domain_requests = DomainRequest.objects.filter(federal_agency=federal_agency).exclude(status__in=invalid_states)
|
||||||
|
if not domain_requests.exists():
|
||||||
|
message = f"""
|
||||||
|
Portfolios not added to domain requests: no valid records found.
|
||||||
|
This means that a filter on DomainInformation for the federal_agency '{federal_agency}' returned no results.
|
||||||
|
Excluded statuses: STARTED, INELIGIBLE, REJECTED.
|
||||||
|
"""
|
||||||
|
TerminalHelper.colorful_logger(logger.info, TerminalColors.YELLOW, message)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get all suborg information and store it in a dict to avoid doing a db call
|
||||||
|
suborgs = Suborganization.objects.filter(portfolio=portfolio).in_bulk(field_name="name")
|
||||||
|
for domain_request in domain_requests:
|
||||||
|
domain_request.portfolio = portfolio
|
||||||
|
if domain_request.organization_name in suborgs:
|
||||||
|
domain_request.sub_organization = suborgs.get(domain_request.organization_name)
|
||||||
|
|
||||||
|
DomainRequest.objects.bulk_update(domain_requests, ["portfolio", "sub_organization"])
|
||||||
|
message = f"Added portfolio '{portfolio}' to {len(domain_requests)} domain requests."
|
||||||
|
TerminalHelper.colorful_logger(logger.info, TerminalColors.OKGREEN, message)
|
||||||
|
|
||||||
|
def handle_portfolio_domains(self, portfolio: Portfolio, federal_agency: FederalAgency):
|
||||||
|
"""
|
||||||
|
Associate portfolio with domains for a federal agency.
|
||||||
|
Updates all relevant domain information records.
|
||||||
|
"""
|
||||||
|
domain_infos = DomainInformation.objects.filter(federal_agency=federal_agency)
|
||||||
|
if not domain_infos.exists():
|
||||||
|
message = f"""
|
||||||
|
Portfolios not added to domains: no valid records found.
|
||||||
|
This means that a filter on DomainInformation for the federal_agency '{federal_agency}' returned no results.
|
||||||
|
"""
|
||||||
|
TerminalHelper.colorful_logger(logger.info, TerminalColors.YELLOW, message)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get all suborg information and store it in a dict to avoid doing a db call
|
||||||
|
suborgs = Suborganization.objects.filter(portfolio=portfolio).in_bulk(field_name="name")
|
||||||
|
for domain_info in domain_infos:
|
||||||
|
domain_info.portfolio = portfolio
|
||||||
|
if domain_info.organization_name in suborgs:
|
||||||
|
domain_info.sub_organization = suborgs.get(domain_info.organization_name)
|
||||||
|
|
||||||
|
DomainInformation.objects.bulk_update(domain_infos, ["portfolio", "sub_organization"])
|
||||||
|
message = f"Added portfolio '{portfolio}' to {len(domain_infos)} domains"
|
||||||
|
TerminalHelper.colorful_logger(logger.info, TerminalColors.OKGREEN, message)
|
|
@ -130,7 +130,7 @@ class Command(BaseCommand):
|
||||||
"""Asks if the user wants to proceed with this action"""
|
"""Asks if the user wants to proceed with this action"""
|
||||||
TerminalHelper.prompt_for_execution(
|
TerminalHelper.prompt_for_execution(
|
||||||
system_exit_on_terminate=True,
|
system_exit_on_terminate=True,
|
||||||
info_to_inspect=f"""
|
prompt_message=f"""
|
||||||
==Extension Amount==
|
==Extension Amount==
|
||||||
Period: {extension_amount} year(s)
|
Period: {extension_amount} year(s)
|
||||||
|
|
||||||
|
|
|
@ -64,7 +64,7 @@ class Command(BaseCommand):
|
||||||
# Will sys.exit() when prompt is "n"
|
# Will sys.exit() when prompt is "n"
|
||||||
TerminalHelper.prompt_for_execution(
|
TerminalHelper.prompt_for_execution(
|
||||||
system_exit_on_terminate=True,
|
system_exit_on_terminate=True,
|
||||||
info_to_inspect=f"""
|
prompt_message=f"""
|
||||||
==Master data file==
|
==Master data file==
|
||||||
domain_additional_filename: {org_args.domain_additional_filename}
|
domain_additional_filename: {org_args.domain_additional_filename}
|
||||||
|
|
||||||
|
@ -84,7 +84,7 @@ class Command(BaseCommand):
|
||||||
# Will sys.exit() when prompt is "n"
|
# Will sys.exit() when prompt is "n"
|
||||||
TerminalHelper.prompt_for_execution(
|
TerminalHelper.prompt_for_execution(
|
||||||
system_exit_on_terminate=True,
|
system_exit_on_terminate=True,
|
||||||
info_to_inspect=f"""
|
prompt_message=f"""
|
||||||
==Master data file==
|
==Master data file==
|
||||||
domain_additional_filename: {org_args.domain_additional_filename}
|
domain_additional_filename: {org_args.domain_additional_filename}
|
||||||
|
|
||||||
|
|
|
@ -27,7 +27,7 @@ class Command(BaseCommand):
|
||||||
|
|
||||||
TerminalHelper.prompt_for_execution(
|
TerminalHelper.prompt_for_execution(
|
||||||
system_exit_on_terminate=True,
|
system_exit_on_terminate=True,
|
||||||
info_to_inspect=f"""
|
prompt_message=f"""
|
||||||
==Proposed Changes==
|
==Proposed Changes==
|
||||||
CSV: {federal_cio_csv_path}
|
CSV: {federal_cio_csv_path}
|
||||||
|
|
||||||
|
|
|
@ -651,7 +651,7 @@ class Command(BaseCommand):
|
||||||
title = "Do you wish to load additional data for TransitionDomains?"
|
title = "Do you wish to load additional data for TransitionDomains?"
|
||||||
proceed = TerminalHelper.prompt_for_execution(
|
proceed = TerminalHelper.prompt_for_execution(
|
||||||
system_exit_on_terminate=True,
|
system_exit_on_terminate=True,
|
||||||
info_to_inspect=f"""
|
prompt_message=f"""
|
||||||
!!! ENSURE THAT ALL FILENAMES ARE CORRECT BEFORE PROCEEDING
|
!!! ENSURE THAT ALL FILENAMES ARE CORRECT BEFORE PROCEEDING
|
||||||
==Master data file==
|
==Master data file==
|
||||||
domain_additional_filename: {domain_additional_filename}
|
domain_additional_filename: {domain_additional_filename}
|
||||||
|
|
|
@ -91,7 +91,7 @@ class Command(BaseCommand):
|
||||||
# Code execution will stop here if the user prompts "N"
|
# Code execution will stop here if the user prompts "N"
|
||||||
TerminalHelper.prompt_for_execution(
|
TerminalHelper.prompt_for_execution(
|
||||||
system_exit_on_terminate=True,
|
system_exit_on_terminate=True,
|
||||||
info_to_inspect=f"""
|
prompt_message=f"""
|
||||||
==Proposed Changes==
|
==Proposed Changes==
|
||||||
Number of DomainInformation objects to change: {len(human_readable_domain_names)}
|
Number of DomainInformation objects to change: {len(human_readable_domain_names)}
|
||||||
The following DomainInformation objects will be modified: {human_readable_domain_names}
|
The following DomainInformation objects will be modified: {human_readable_domain_names}
|
||||||
|
@ -148,7 +148,7 @@ class Command(BaseCommand):
|
||||||
# Code execution will stop here if the user prompts "N"
|
# Code execution will stop here if the user prompts "N"
|
||||||
TerminalHelper.prompt_for_execution(
|
TerminalHelper.prompt_for_execution(
|
||||||
system_exit_on_terminate=True,
|
system_exit_on_terminate=True,
|
||||||
info_to_inspect=f"""
|
prompt_message=f"""
|
||||||
==File location==
|
==File location==
|
||||||
current-full.csv filepath: {file_path}
|
current-full.csv filepath: {file_path}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
import logging
|
||||||
|
from django.core.management import BaseCommand
|
||||||
|
from registrar.management.commands.utility.terminal_helper import PopulateScriptTemplate, TerminalColors
|
||||||
|
from registrar.models import DomainRequest
|
||||||
|
from auditlog.models import LogEntry
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand, PopulateScriptTemplate):
|
||||||
|
help = "Loops through each domain request object and populates the last_status_update and first_submitted_date"
|
||||||
|
|
||||||
|
def handle(self, **kwargs):
|
||||||
|
"""Loops through each DomainRequest object and populates
|
||||||
|
its last_status_update and first_submitted_date values"""
|
||||||
|
self.mass_update_records(DomainRequest, None, ["last_status_update", "first_submitted_date"])
|
||||||
|
|
||||||
|
def update_record(self, record: DomainRequest):
|
||||||
|
"""Defines how we update the first_submitted_date and last_status_update fields"""
|
||||||
|
|
||||||
|
# Retrieve and order audit log entries by timestamp in descending order
|
||||||
|
audit_log_entries = LogEntry.objects.filter(object_pk=record.pk).order_by("-timestamp")
|
||||||
|
# Loop through logs in descending order to find most recent status change
|
||||||
|
for log_entry in audit_log_entries:
|
||||||
|
if "status" in log_entry.changes_dict:
|
||||||
|
record.last_status_update = log_entry.timestamp.date()
|
||||||
|
break
|
||||||
|
|
||||||
|
# Loop through logs in ascending order to find first submission
|
||||||
|
for log_entry in audit_log_entries.reverse():
|
||||||
|
status = log_entry.changes_dict.get("status")
|
||||||
|
if status and status[1] == "submitted":
|
||||||
|
record.first_submitted_date = log_entry.timestamp.date()
|
||||||
|
break
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"""{TerminalColors.OKCYAN}Updating {record} =>
|
||||||
|
first submitted date: {record.first_submitted_date},
|
||||||
|
last status update: {record.last_status_update}{TerminalColors.ENDC}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def should_skip_record(self, record) -> bool:
|
||||||
|
# make sure the record had some kind of history
|
||||||
|
return not LogEntry.objects.filter(object_pk=record.pk).exists()
|
|
@ -31,7 +31,7 @@ class Command(BaseCommand):
|
||||||
# Code execution will stop here if the user prompts "N"
|
# Code execution will stop here if the user prompts "N"
|
||||||
TerminalHelper.prompt_for_execution(
|
TerminalHelper.prompt_for_execution(
|
||||||
system_exit_on_terminate=True,
|
system_exit_on_terminate=True,
|
||||||
info_to_inspect=f"""
|
prompt_message=f"""
|
||||||
==Proposed Changes==
|
==Proposed Changes==
|
||||||
Number of Domain objects to change: {len(domains)}
|
Number of Domain objects to change: {len(domains)}
|
||||||
""",
|
""",
|
||||||
|
|
|
@ -54,7 +54,7 @@ class Command(BaseCommand):
|
||||||
# Code execution will stop here if the user prompts "N"
|
# Code execution will stop here if the user prompts "N"
|
||||||
TerminalHelper.prompt_for_execution(
|
TerminalHelper.prompt_for_execution(
|
||||||
system_exit_on_terminate=True,
|
system_exit_on_terminate=True,
|
||||||
info_to_inspect=f"""
|
prompt_message=f"""
|
||||||
==Proposed Changes==
|
==Proposed Changes==
|
||||||
Number of DomainRequest objects to change: {len(domain_requests)}
|
Number of DomainRequest objects to change: {len(domain_requests)}
|
||||||
|
|
||||||
|
@ -72,7 +72,7 @@ class Command(BaseCommand):
|
||||||
# Code execution will stop here if the user prompts "N"
|
# Code execution will stop here if the user prompts "N"
|
||||||
TerminalHelper.prompt_for_execution(
|
TerminalHelper.prompt_for_execution(
|
||||||
system_exit_on_terminate=True,
|
system_exit_on_terminate=True,
|
||||||
info_to_inspect=f"""
|
prompt_message=f"""
|
||||||
==Proposed Changes==
|
==Proposed Changes==
|
||||||
Number of DomainInformation objects to change: {len(domain_infos)}
|
Number of DomainInformation objects to change: {len(domain_infos)}
|
||||||
|
|
||||||
|
|
|
@ -423,7 +423,7 @@ class Command(BaseCommand):
|
||||||
valid_fed_type = fed_type in fed_choices
|
valid_fed_type = fed_type in fed_choices
|
||||||
valid_fed_agency = fed_agency in agency_choices
|
valid_fed_agency = fed_agency in agency_choices
|
||||||
|
|
||||||
default_creator, _ = User.objects.get_or_create(username="System")
|
default_creator = User.get_default_user()
|
||||||
|
|
||||||
new_domain_info_data = {
|
new_domain_info_data = {
|
||||||
"domain": domain,
|
"domain": domain,
|
||||||
|
|
38
src/registrar/management/commands/update_first_ready.py
Normal file
38
src/registrar/management/commands/update_first_ready.py
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import logging
|
||||||
|
from django.core.management import BaseCommand
|
||||||
|
from registrar.management.commands.utility.terminal_helper import PopulateScriptTemplate, TerminalColors
|
||||||
|
from registrar.models import Domain, TransitionDomain
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand, PopulateScriptTemplate):
|
||||||
|
help = "Loops through each domain object and populates the last_status_update and first_submitted_date"
|
||||||
|
|
||||||
|
def handle(self, **kwargs):
|
||||||
|
"""Loops through each valid Domain object and updates it's first_ready value if it is out of sync"""
|
||||||
|
filter_conditions = {"state__in": [Domain.State.READY, Domain.State.ON_HOLD, Domain.State.DELETED]}
|
||||||
|
self.mass_update_records(Domain, filter_conditions, ["first_ready"], verbose=True)
|
||||||
|
|
||||||
|
def update_record(self, record: Domain):
|
||||||
|
"""Defines how we update the first_ready field"""
|
||||||
|
# update the first_ready value based on the creation date.
|
||||||
|
record.first_ready = record.created_at.date()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"{TerminalColors.OKCYAN}Updating {record} => first_ready: " f"{record.first_ready}{TerminalColors.ENDC}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# check if a transition domain object for this domain name exists,
|
||||||
|
# or if so whether its first_ready value matches its created_at date
|
||||||
|
def custom_filter(self, records):
|
||||||
|
to_include_pks = []
|
||||||
|
for record in records:
|
||||||
|
if (
|
||||||
|
TransitionDomain.objects.filter(domain_name=record.name).exists()
|
||||||
|
and record.first_ready != record.created_at.date()
|
||||||
|
): # noqa
|
||||||
|
to_include_pks.append(record.pk)
|
||||||
|
|
||||||
|
return records.filter(pk__in=to_include_pks)
|
|
@ -2,9 +2,12 @@ import logging
|
||||||
import sys
|
import sys
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
|
from django.db.models import Model
|
||||||
|
from django.db.models.manager import BaseManager
|
||||||
from typing import List
|
from typing import List
|
||||||
from registrar.utility.enums import LogCode
|
from registrar.utility.enums import LogCode
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@ -76,27 +79,60 @@ class PopulateScriptTemplate(ABC):
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def update_record(self, record):
|
def update_record(self, record):
|
||||||
"""Defines how we update each field. Must be defined before using mass_update_records."""
|
"""Defines how we update each field.
|
||||||
|
|
||||||
|
raises:
|
||||||
|
NotImplementedError: If not defined before calling mass_update_records.
|
||||||
|
"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def mass_update_records(self, object_class, filter_conditions, fields_to_update, debug=True):
|
def mass_update_records(self, object_class, filter_conditions, fields_to_update, debug=True, verbose=False):
|
||||||
"""Loops through each valid "object_class" object - specified by filter_conditions - and
|
"""Loops through each valid "object_class" object - specified by filter_conditions - and
|
||||||
updates fields defined by fields_to_update using update_record.
|
updates fields defined by fields_to_update using update_record.
|
||||||
|
|
||||||
You must define update_record before you can use this function.
|
Parameters:
|
||||||
|
object_class: The Django model class that you want to perform the bulk update on.
|
||||||
|
This should be the actual class, not a string of the class name.
|
||||||
|
|
||||||
|
filter_conditions: dictionary of valid Django Queryset filter conditions
|
||||||
|
(e.g. {'verification_type__isnull'=True}).
|
||||||
|
|
||||||
|
fields_to_update: List of strings specifying which fields to update.
|
||||||
|
(e.g. ["first_ready_date", "last_submitted_date"])
|
||||||
|
|
||||||
|
debug: Whether to log script run summary in debug mode.
|
||||||
|
Default: True.
|
||||||
|
|
||||||
|
verbose: Whether to print a detailed run summary *before* run confirmation.
|
||||||
|
Default: False.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NotImplementedError: If you do not define update_record before using this function.
|
||||||
|
TypeError: If custom_filter is not Callable.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
records = object_class.objects.filter(**filter_conditions)
|
records = object_class.objects.filter(**filter_conditions) if filter_conditions else object_class.objects.all()
|
||||||
|
|
||||||
|
# apply custom filter
|
||||||
|
records = self.custom_filter(records)
|
||||||
|
|
||||||
readable_class_name = self.get_class_name(object_class)
|
readable_class_name = self.get_class_name(object_class)
|
||||||
|
|
||||||
|
# for use in the execution prompt.
|
||||||
|
proposed_changes = f"""==Proposed Changes==
|
||||||
|
Number of {readable_class_name} objects to change: {len(records)}
|
||||||
|
These fields will be updated on each record: {fields_to_update}
|
||||||
|
"""
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
proposed_changes = f"""{proposed_changes}
|
||||||
|
These records will be updated: {list(records.all())}
|
||||||
|
"""
|
||||||
|
|
||||||
# Code execution will stop here if the user prompts "N"
|
# Code execution will stop here if the user prompts "N"
|
||||||
TerminalHelper.prompt_for_execution(
|
TerminalHelper.prompt_for_execution(
|
||||||
system_exit_on_terminate=True,
|
system_exit_on_terminate=True,
|
||||||
info_to_inspect=f"""
|
prompt_message=proposed_changes,
|
||||||
==Proposed Changes==
|
|
||||||
Number of {readable_class_name} objects to change: {len(records)}
|
|
||||||
These fields will be updated on each record: {fields_to_update}
|
|
||||||
""",
|
|
||||||
prompt_title=self.prompt_title,
|
prompt_title=self.prompt_title,
|
||||||
)
|
)
|
||||||
logger.info("Updating...")
|
logger.info("Updating...")
|
||||||
|
@ -141,10 +177,17 @@ class PopulateScriptTemplate(ABC):
|
||||||
return f"{TerminalColors.FAIL}" f"Failed to update {record}" f"{TerminalColors.ENDC}"
|
return f"{TerminalColors.FAIL}" f"Failed to update {record}" f"{TerminalColors.ENDC}"
|
||||||
|
|
||||||
def should_skip_record(self, record) -> bool: # noqa
|
def should_skip_record(self, record) -> bool: # noqa
|
||||||
"""Defines the condition in which we should skip updating a record. Override as needed."""
|
"""Defines the condition in which we should skip updating a record. Override as needed.
|
||||||
|
The difference between this and custom_filter is that records matching these conditions
|
||||||
|
*will* be included in the run but will be skipped (and logged as such)."""
|
||||||
# By default - don't skip
|
# By default - don't skip
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def custom_filter(self, records: BaseManager[Model]) -> BaseManager[Model]:
|
||||||
|
"""Override to define filters that can't be represented by django queryset field lookups.
|
||||||
|
Applied to individual records *after* filter_conditions. True means"""
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
class TerminalHelper:
|
class TerminalHelper:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@ -220,6 +263,9 @@ class TerminalHelper:
|
||||||
an answer is required of the user).
|
an answer is required of the user).
|
||||||
|
|
||||||
The "answer" return value is True for "yes" or False for "no".
|
The "answer" return value is True for "yes" or False for "no".
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: When "default" is not "yes", "no", or None.
|
||||||
"""
|
"""
|
||||||
valid = {"yes": True, "y": True, "ye": True, "no": False, "n": False}
|
valid = {"yes": True, "y": True, "ye": True, "no": False, "n": False}
|
||||||
if default is None:
|
if default is None:
|
||||||
|
@ -244,6 +290,7 @@ class TerminalHelper:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def query_yes_no_exit(question: str, default="yes"):
|
def query_yes_no_exit(question: str, default="yes"):
|
||||||
"""Ask a yes/no question via raw_input() and return their answer.
|
"""Ask a yes/no question via raw_input() and return their answer.
|
||||||
|
Allows for answer "e" to exit.
|
||||||
|
|
||||||
"question" is a string that is presented to the user.
|
"question" is a string that is presented to the user.
|
||||||
"default" is the presumed answer if the user just hits <Enter>.
|
"default" is the presumed answer if the user just hits <Enter>.
|
||||||
|
@ -251,6 +298,9 @@ class TerminalHelper:
|
||||||
an answer is required of the user).
|
an answer is required of the user).
|
||||||
|
|
||||||
The "answer" return value is True for "yes" or False for "no".
|
The "answer" return value is True for "yes" or False for "no".
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: When "default" is not "yes", "no", or None.
|
||||||
"""
|
"""
|
||||||
valid = {
|
valid = {
|
||||||
"yes": True,
|
"yes": True,
|
||||||
|
@ -317,9 +367,8 @@ class TerminalHelper:
|
||||||
case _:
|
case _:
|
||||||
logger.info(print_statement)
|
logger.info(print_statement)
|
||||||
|
|
||||||
# TODO - "info_to_inspect" should be refactored to "prompt_message"
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def prompt_for_execution(system_exit_on_terminate: bool, info_to_inspect: str, prompt_title: str) -> bool:
|
def prompt_for_execution(system_exit_on_terminate: bool, prompt_message: str, prompt_title: str) -> bool:
|
||||||
"""Create to reduce code complexity.
|
"""Create to reduce code complexity.
|
||||||
Prompts the user to inspect the given string
|
Prompts the user to inspect the given string
|
||||||
and asks if they wish to proceed.
|
and asks if they wish to proceed.
|
||||||
|
@ -340,7 +389,7 @@ class TerminalHelper:
|
||||||
=====================================================
|
=====================================================
|
||||||
*** IMPORTANT: VERIFY THE FOLLOWING LOOKS CORRECT ***
|
*** IMPORTANT: VERIFY THE FOLLOWING LOOKS CORRECT ***
|
||||||
|
|
||||||
{info_to_inspect}
|
{prompt_message}
|
||||||
{TerminalColors.FAIL}
|
{TerminalColors.FAIL}
|
||||||
Proceed? (Y = proceed, N = {action_description_for_selecting_no})
|
Proceed? (Y = proceed, N = {action_description_for_selecting_no})
|
||||||
{TerminalColors.ENDC}"""
|
{TerminalColors.ENDC}"""
|
||||||
|
|
|
@ -0,0 +1,97 @@
|
||||||
|
# Generated by Django 4.2.10 on 2024-08-19 20:24
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
import django.contrib.postgres.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("registrar", "0118_alter_portfolio_options_alter_portfolio_creator_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="user",
|
||||||
|
name="portfolio",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="user",
|
||||||
|
name="portfolio_additional_permissions",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="user",
|
||||||
|
name="portfolio_roles",
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="UserPortfolioPermission",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"roles",
|
||||||
|
django.contrib.postgres.fields.ArrayField(
|
||||||
|
base_field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("organization_admin", "Admin"),
|
||||||
|
("organization_admin_read_only", "Admin read only"),
|
||||||
|
("organization_member", "Member"),
|
||||||
|
],
|
||||||
|
max_length=50,
|
||||||
|
),
|
||||||
|
blank=True,
|
||||||
|
help_text="Select one or more roles.",
|
||||||
|
null=True,
|
||||||
|
size=None,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"additional_permissions",
|
||||||
|
django.contrib.postgres.fields.ArrayField(
|
||||||
|
base_field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("view_all_domains", "View all domains and domain reports"),
|
||||||
|
("view_managed_domains", "View managed domains"),
|
||||||
|
("view_member", "View members"),
|
||||||
|
("edit_member", "Create and edit members"),
|
||||||
|
("view_all_requests", "View all requests"),
|
||||||
|
("view_created_requests", "View created requests"),
|
||||||
|
("edit_requests", "Create and edit requests"),
|
||||||
|
("view_portfolio", "View organization"),
|
||||||
|
("edit_portfolio", "Edit organization"),
|
||||||
|
("view_suborganization", "View suborganization"),
|
||||||
|
("edit_suborganization", "Edit suborganization"),
|
||||||
|
],
|
||||||
|
max_length=50,
|
||||||
|
),
|
||||||
|
blank=True,
|
||||||
|
help_text="Select one or more additional permissions.",
|
||||||
|
null=True,
|
||||||
|
size=None,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"portfolio",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="portfolio_users",
|
||||||
|
to="registrar.portfolio",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="portfolio_permissions",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"unique_together": {("user", "portfolio")},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,47 @@
|
||||||
|
# Generated by Django 4.2.10 on 2024-08-16 15:28
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("registrar", "0119_remove_user_portfolio_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="domainrequest",
|
||||||
|
old_name="submission_date",
|
||||||
|
new_name="last_submitted_date",
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="domainrequest",
|
||||||
|
name="last_submitted_date",
|
||||||
|
field=models.DateField(
|
||||||
|
blank=True, default=None, help_text="Date last submitted", null=True, verbose_name="last submitted on"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="domainrequest",
|
||||||
|
name="first_submitted_date",
|
||||||
|
field=models.DateField(
|
||||||
|
blank=True,
|
||||||
|
default=None,
|
||||||
|
help_text="Date initially submitted",
|
||||||
|
null=True,
|
||||||
|
verbose_name="first submitted on",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="domainrequest",
|
||||||
|
name="last_status_update",
|
||||||
|
field=models.DateField(
|
||||||
|
blank=True,
|
||||||
|
default=None,
|
||||||
|
help_text="Date of the last status update",
|
||||||
|
null=True,
|
||||||
|
verbose_name="last updated on",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
25
src/registrar/migrations/0121_allowedemail.py
Normal file
25
src/registrar/migrations/0121_allowedemail.py
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
# Generated by Django 4.2.10 on 2024-08-29 18:16
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("registrar", "0120_add_domainrequest_submission_dates"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="AllowedEmail",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("email", models.EmailField(max_length=320, unique=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
37
src/registrar/migrations/0122_create_groups_v16.py
Normal file
37
src/registrar/migrations/0122_create_groups_v16.py
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
# 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_full_access_group(apps, schema_editor)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("registrar", "0121_allowedemail"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(
|
||||||
|
create_groups,
|
||||||
|
reverse_code=migrations.RunPython.noop,
|
||||||
|
atomic=True,
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,66 @@
|
||||||
|
# Generated by Django 4.2.10 on 2024-09-04 21:29
|
||||||
|
|
||||||
|
import django.contrib.postgres.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("registrar", "0122_create_groups_v16"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="portfolioinvitation",
|
||||||
|
name="portfolio_additional_permissions",
|
||||||
|
field=django.contrib.postgres.fields.ArrayField(
|
||||||
|
base_field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("view_all_domains", "View all domains and domain reports"),
|
||||||
|
("view_managed_domains", "View managed domains"),
|
||||||
|
("view_members", "View members"),
|
||||||
|
("edit_members", "Create and edit members"),
|
||||||
|
("view_all_requests", "View all requests"),
|
||||||
|
("view_created_requests", "View created requests"),
|
||||||
|
("edit_requests", "Create and edit requests"),
|
||||||
|
("view_portfolio", "View organization"),
|
||||||
|
("edit_portfolio", "Edit organization"),
|
||||||
|
("view_suborganization", "View suborganization"),
|
||||||
|
("edit_suborganization", "Edit suborganization"),
|
||||||
|
],
|
||||||
|
max_length=50,
|
||||||
|
),
|
||||||
|
blank=True,
|
||||||
|
help_text="Select one or more additional permissions.",
|
||||||
|
null=True,
|
||||||
|
size=None,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userportfoliopermission",
|
||||||
|
name="additional_permissions",
|
||||||
|
field=django.contrib.postgres.fields.ArrayField(
|
||||||
|
base_field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("view_all_domains", "View all domains and domain reports"),
|
||||||
|
("view_managed_domains", "View managed domains"),
|
||||||
|
("view_members", "View members"),
|
||||||
|
("edit_members", "Create and edit members"),
|
||||||
|
("view_all_requests", "View all requests"),
|
||||||
|
("view_created_requests", "View created requests"),
|
||||||
|
("edit_requests", "Create and edit requests"),
|
||||||
|
("view_portfolio", "View organization"),
|
||||||
|
("edit_portfolio", "Edit organization"),
|
||||||
|
("view_suborganization", "View suborganization"),
|
||||||
|
("edit_suborganization", "Edit suborganization"),
|
||||||
|
],
|
||||||
|
max_length=50,
|
||||||
|
),
|
||||||
|
blank=True,
|
||||||
|
help_text="Select one or more additional permissions.",
|
||||||
|
null=True,
|
||||||
|
size=None,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,64 @@
|
||||||
|
# Generated by Django 4.2.10 on 2024-09-09 14:48
|
||||||
|
|
||||||
|
import django.contrib.postgres.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("registrar", "0123_alter_portfolioinvitation_portfolio_additional_permissions_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="portfolioinvitation",
|
||||||
|
name="portfolio_additional_permissions",
|
||||||
|
field=django.contrib.postgres.fields.ArrayField(
|
||||||
|
base_field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("view_all_domains", "View all domains and domain reports"),
|
||||||
|
("view_managed_domains", "View managed domains"),
|
||||||
|
("view_members", "View members"),
|
||||||
|
("edit_members", "Create and edit members"),
|
||||||
|
("view_all_requests", "View all requests"),
|
||||||
|
("edit_requests", "Create and edit requests"),
|
||||||
|
("view_portfolio", "View organization"),
|
||||||
|
("edit_portfolio", "Edit organization"),
|
||||||
|
("view_suborganization", "View suborganization"),
|
||||||
|
("edit_suborganization", "Edit suborganization"),
|
||||||
|
],
|
||||||
|
max_length=50,
|
||||||
|
),
|
||||||
|
blank=True,
|
||||||
|
help_text="Select one or more additional permissions.",
|
||||||
|
null=True,
|
||||||
|
size=None,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userportfoliopermission",
|
||||||
|
name="additional_permissions",
|
||||||
|
field=django.contrib.postgres.fields.ArrayField(
|
||||||
|
base_field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("view_all_domains", "View all domains and domain reports"),
|
||||||
|
("view_managed_domains", "View managed domains"),
|
||||||
|
("view_members", "View members"),
|
||||||
|
("edit_members", "Create and edit members"),
|
||||||
|
("view_all_requests", "View all requests"),
|
||||||
|
("edit_requests", "Create and edit requests"),
|
||||||
|
("view_portfolio", "View organization"),
|
||||||
|
("edit_portfolio", "Edit organization"),
|
||||||
|
("view_suborganization", "View suborganization"),
|
||||||
|
("edit_suborganization", "Edit suborganization"),
|
||||||
|
],
|
||||||
|
max_length=50,
|
||||||
|
),
|
||||||
|
blank=True,
|
||||||
|
help_text="Select one or more additional permissions.",
|
||||||
|
null=True,
|
||||||
|
size=None,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,38 @@
|
||||||
|
# Generated by Django 4.2.10 on 2024-08-29 23:17
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("registrar", "0124_alter_portfolioinvitation_portfolio_additional_permissions_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="domaininformation",
|
||||||
|
name="submitter",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
help_text='Person listed under "your contact information" in the request form',
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="submitted_domain_requests_information",
|
||||||
|
to="registrar.contact",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="domainrequest",
|
||||||
|
name="submitter",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
help_text='Person listed under "your contact information" in the request form; will receive email updates',
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="submitted_domain_requests",
|
||||||
|
to="registrar.contact",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,27 @@
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db.models import Q
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
# Deletes Contact objects associated with a submitter which we are deprecating
|
||||||
|
def cascade_delete_submitter_contacts(apps, schema_editor) -> Any:
|
||||||
|
contacts_model = apps.get_model("registrar", "Contact")
|
||||||
|
submitter_contacts = contacts_model.objects.filter(
|
||||||
|
Q(submitted_domain_requests__isnull=False) | Q(submitted_domain_requests_information__isnull=False)
|
||||||
|
).filter(
|
||||||
|
information_senior_official__isnull=True,
|
||||||
|
senior_official__isnull=True,
|
||||||
|
contact_domain_requests_information__isnull=True,
|
||||||
|
contact_domain_requests__isnull=True,
|
||||||
|
)
|
||||||
|
submitter_contacts.delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("registrar", "0125_alter_domaininformation_submitter_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(cascade_delete_submitter_contacts, reverse_code=migrations.RunPython.noop, atomic=True),
|
||||||
|
]
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Generated by Django 4.2.10 on 2024-08-29 24:13
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("registrar", "0126_delete_cascade_submitter_contacts"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="domaininformation",
|
||||||
|
name="submitter",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="domainrequest",
|
||||||
|
name="submitter",
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="domainrequest",
|
||||||
|
name="creator",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
help_text="Person who submitted the domain request. Will receive email updates.",
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="domain_requests_created",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -21,6 +21,8 @@ from .portfolio import Portfolio
|
||||||
from .domain_group import DomainGroup
|
from .domain_group import DomainGroup
|
||||||
from .suborganization import Suborganization
|
from .suborganization import Suborganization
|
||||||
from .senior_official import SeniorOfficial
|
from .senior_official import SeniorOfficial
|
||||||
|
from .user_portfolio_permission import UserPortfolioPermission
|
||||||
|
from .allowed_email import AllowedEmail
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
@ -46,6 +48,8 @@ __all__ = [
|
||||||
"DomainGroup",
|
"DomainGroup",
|
||||||
"Suborganization",
|
"Suborganization",
|
||||||
"SeniorOfficial",
|
"SeniorOfficial",
|
||||||
|
"UserPortfolioPermission",
|
||||||
|
"AllowedEmail",
|
||||||
]
|
]
|
||||||
|
|
||||||
auditlog.register(Contact)
|
auditlog.register(Contact)
|
||||||
|
@ -70,3 +74,5 @@ auditlog.register(Portfolio)
|
||||||
auditlog.register(DomainGroup)
|
auditlog.register(DomainGroup)
|
||||||
auditlog.register(Suborganization)
|
auditlog.register(Suborganization)
|
||||||
auditlog.register(SeniorOfficial)
|
auditlog.register(SeniorOfficial)
|
||||||
|
auditlog.register(UserPortfolioPermission)
|
||||||
|
auditlog.register(AllowedEmail)
|
||||||
|
|
52
src/registrar/models/allowed_email.py
Normal file
52
src/registrar/models/allowed_email.py
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models import Q
|
||||||
|
import re
|
||||||
|
from .utility.time_stamped_model import TimeStampedModel
|
||||||
|
|
||||||
|
|
||||||
|
class AllowedEmail(TimeStampedModel):
|
||||||
|
"""
|
||||||
|
AllowedEmail is a whitelist for email addresses that we can send to
|
||||||
|
in non-production environments.
|
||||||
|
"""
|
||||||
|
|
||||||
|
email = models.EmailField(
|
||||||
|
unique=True,
|
||||||
|
null=False,
|
||||||
|
blank=False,
|
||||||
|
max_length=320,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_allowed_email(cls, email):
|
||||||
|
"""Given an email, check if this email exists within our AllowEmail whitelist"""
|
||||||
|
|
||||||
|
if not email:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Split the email into a local part and a domain part
|
||||||
|
local, domain = email.split("@")
|
||||||
|
|
||||||
|
# If the email exists within the whitelist, then do nothing else.
|
||||||
|
email_exists = cls.objects.filter(email__iexact=email).exists()
|
||||||
|
if email_exists:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check if there's a '+' in the local part
|
||||||
|
if "+" in local:
|
||||||
|
base_local = local.split("+")[0]
|
||||||
|
base_email_exists = cls.objects.filter(Q(email__iexact=f"{base_local}@{domain}")).exists()
|
||||||
|
|
||||||
|
# Given an example email, such as "joe.smoe+1@igorville.com"
|
||||||
|
# The full regex statement will be: "^joe.smoe\\+\\d+@igorville.com$"
|
||||||
|
pattern = f"^{re.escape(base_local)}\\+\\d+@{re.escape(domain)}$"
|
||||||
|
return base_email_exists and re.match(pattern, email)
|
||||||
|
else:
|
||||||
|
# Edge case, the +1 record exists but the base does not,
|
||||||
|
# and the record we are checking is the base record.
|
||||||
|
pattern = f"^{re.escape(local)}\\+\\d+@{re.escape(domain)}$"
|
||||||
|
plus_email_exists = cls.objects.filter(Q(email__iregex=pattern)).exists()
|
||||||
|
return plus_email_exists
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return str(self.email)
|
|
@ -48,8 +48,7 @@ class DomainInformation(TimeStampedModel):
|
||||||
null=True,
|
null=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# This is the domain request user who created this domain request. The contact
|
# This is the domain request user who created this domain request.
|
||||||
# information that they gave is in the `submitter` field
|
|
||||||
creator = models.ForeignKey(
|
creator = models.ForeignKey(
|
||||||
"registrar.User",
|
"registrar.User",
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
|
@ -197,17 +196,6 @@ class DomainInformation(TimeStampedModel):
|
||||||
related_name="domain_info",
|
related_name="domain_info",
|
||||||
)
|
)
|
||||||
|
|
||||||
# This is the contact information provided by the domain requestor. The
|
|
||||||
# user who created the domain request is in the `creator` field.
|
|
||||||
submitter = models.ForeignKey(
|
|
||||||
"registrar.Contact",
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
related_name="submitted_domain_requests_information",
|
|
||||||
on_delete=models.PROTECT,
|
|
||||||
help_text='Person listed under "your contact information" in the request form',
|
|
||||||
)
|
|
||||||
|
|
||||||
purpose = models.TextField(
|
purpose = models.TextField(
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
|
|
|
@ -6,7 +6,6 @@ from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django_fsm import FSMField, transition # type: ignore
|
from django_fsm import FSMField, transition # type: ignore
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from waffle import flag_is_active
|
|
||||||
from registrar.models.domain import Domain
|
from registrar.models.domain import Domain
|
||||||
from registrar.models.federal_agency import FederalAgency
|
from registrar.models.federal_agency import FederalAgency
|
||||||
from registrar.models.utility.generic_helper import CreateOrUpdateOrganizationTypeHelper
|
from registrar.models.utility.generic_helper import CreateOrUpdateOrganizationTypeHelper
|
||||||
|
@ -339,13 +338,12 @@ class DomainRequest(TimeStampedModel):
|
||||||
help_text="The suborganization that this domain request is included under",
|
help_text="The suborganization that this domain request is included under",
|
||||||
)
|
)
|
||||||
|
|
||||||
# This is the domain request user who created this domain request. The contact
|
# This is the domain request user who created this domain request.
|
||||||
# information that they gave is in the `submitter` field
|
|
||||||
creator = models.ForeignKey(
|
creator = models.ForeignKey(
|
||||||
"registrar.User",
|
"registrar.User",
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name="domain_requests_created",
|
related_name="domain_requests_created",
|
||||||
help_text="Person who submitted the domain request; will not receive email updates",
|
help_text="Person who submitted the domain request. Will receive email updates.",
|
||||||
)
|
)
|
||||||
|
|
||||||
investigator = models.ForeignKey(
|
investigator = models.ForeignKey(
|
||||||
|
@ -483,17 +481,6 @@ class DomainRequest(TimeStampedModel):
|
||||||
help_text="Other domain names the creator provided for consideration",
|
help_text="Other domain names the creator provided for consideration",
|
||||||
)
|
)
|
||||||
|
|
||||||
# This is the contact information provided by the domain requestor. The
|
|
||||||
# user who created the domain request is in the `creator` field.
|
|
||||||
submitter = models.ForeignKey(
|
|
||||||
"registrar.Contact",
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
related_name="submitted_domain_requests",
|
|
||||||
on_delete=models.PROTECT,
|
|
||||||
help_text='Person listed under "your contact information" in the request form; will receive email updates',
|
|
||||||
)
|
|
||||||
|
|
||||||
purpose = models.TextField(
|
purpose = models.TextField(
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
|
@ -563,20 +550,43 @@ class DomainRequest(TimeStampedModel):
|
||||||
help_text="Acknowledged .gov acceptable use policy",
|
help_text="Acknowledged .gov acceptable use policy",
|
||||||
)
|
)
|
||||||
|
|
||||||
# submission date records when domain request is submitted
|
# Records when the domain request was first submitted
|
||||||
submission_date = models.DateField(
|
first_submitted_date = models.DateField(
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
default=None,
|
default=None,
|
||||||
verbose_name="submitted at",
|
verbose_name="first submitted on",
|
||||||
help_text="Date submitted",
|
help_text="Date initially submitted",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Records when domain request was last submitted
|
||||||
|
last_submitted_date = models.DateField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
default=None,
|
||||||
|
verbose_name="last submitted on",
|
||||||
|
help_text="Date last submitted",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Records when domain request status was last updated by an admin or analyst
|
||||||
|
last_status_update = models.DateField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
default=None,
|
||||||
|
verbose_name="last updated on",
|
||||||
|
help_text="Date of the last status update",
|
||||||
|
)
|
||||||
notes = models.TextField(
|
notes = models.TextField(
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_statuses_that_send_emails(cls):
|
||||||
|
"""Returns a list of statuses that send an email to the user"""
|
||||||
|
excluded_statuses = [cls.DomainRequestStatus.INELIGIBLE, cls.DomainRequestStatus.IN_REVIEW]
|
||||||
|
return [status for status in cls.DomainRequestStatus if status not in excluded_statuses]
|
||||||
|
|
||||||
def sync_organization_type(self):
|
def sync_organization_type(self):
|
||||||
"""
|
"""
|
||||||
Updates the organization_type (without saving) to match
|
Updates the organization_type (without saving) to match
|
||||||
|
@ -621,6 +631,9 @@ class DomainRequest(TimeStampedModel):
|
||||||
self.sync_organization_type()
|
self.sync_organization_type()
|
||||||
self.sync_yes_no_form_fields()
|
self.sync_yes_no_form_fields()
|
||||||
|
|
||||||
|
if self._cached_status != self.status:
|
||||||
|
self.last_status_update = timezone.now().date()
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
# Handle the action needed email.
|
# Handle the action needed email.
|
||||||
|
@ -715,9 +728,6 @@ class DomainRequest(TimeStampedModel):
|
||||||
contact information. If there is not creator information, then do
|
contact information. If there is not creator information, then do
|
||||||
nothing.
|
nothing.
|
||||||
|
|
||||||
If the waffle flag "profile_feature" is active, then this email will be sent to the
|
|
||||||
domain request creator rather than the submitter
|
|
||||||
|
|
||||||
Optional args:
|
Optional args:
|
||||||
bcc_address: str -> the address to bcc to
|
bcc_address: str -> the address to bcc to
|
||||||
|
|
||||||
|
@ -732,7 +742,7 @@ class DomainRequest(TimeStampedModel):
|
||||||
custom_email_content: str -> Renders an email with the content of this string as its body text.
|
custom_email_content: str -> Renders an email with the content of this string as its body text.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
recipient = self.creator if flag_is_active(None, "profile_feature") else self.submitter
|
recipient = self.creator
|
||||||
if recipient is None or recipient.email is None:
|
if recipient is None or recipient.email is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Cannot send {new_status} email, no creator email address for domain request with pk: {self.pk}."
|
f"Cannot send {new_status} email, no creator email address for domain request with pk: {self.pk}."
|
||||||
|
@ -803,8 +813,12 @@ class DomainRequest(TimeStampedModel):
|
||||||
if not DraftDomain.string_could_be_domain(self.requested_domain.name):
|
if not DraftDomain.string_could_be_domain(self.requested_domain.name):
|
||||||
raise ValueError("Requested domain is not a valid domain name.")
|
raise ValueError("Requested domain is not a valid domain name.")
|
||||||
|
|
||||||
# Update submission_date to today
|
# if the domain has not been submitted before this must be the first time
|
||||||
self.submission_date = timezone.now().date()
|
if not self.first_submitted_date:
|
||||||
|
self.first_submitted_date = timezone.now().date()
|
||||||
|
|
||||||
|
# Update last_submitted_date to today
|
||||||
|
self.last_submitted_date = timezone.now().date()
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
# Limit email notifications to transitions from Started and Withdrawn
|
# Limit email notifications to transitions from Started and Withdrawn
|
||||||
|
@ -1152,6 +1166,10 @@ class DomainRequest(TimeStampedModel):
|
||||||
# Special District -> "Election office" and "About your organization" page can't be empty
|
# Special District -> "Election office" and "About your organization" page can't be empty
|
||||||
return self.is_election_board is not None and self.about_your_organization is not None
|
return self.is_election_board is not None and self.about_your_organization is not None
|
||||||
|
|
||||||
|
# Do we still want to test this after creator is autogenerated? Currently it went back to being selectable
|
||||||
|
def _is_creator_complete(self):
|
||||||
|
return self.creator is not None
|
||||||
|
|
||||||
def _is_organization_name_and_address_complete(self):
|
def _is_organization_name_and_address_complete(self):
|
||||||
return not (
|
return not (
|
||||||
self.organization_name is None
|
self.organization_name is None
|
||||||
|
@ -1170,9 +1188,6 @@ class DomainRequest(TimeStampedModel):
|
||||||
def _is_purpose_complete(self):
|
def _is_purpose_complete(self):
|
||||||
return self.purpose is not None
|
return self.purpose is not None
|
||||||
|
|
||||||
def _is_submitter_complete(self):
|
|
||||||
return self.submitter is not None
|
|
||||||
|
|
||||||
def _has_other_contacts_and_filled(self):
|
def _has_other_contacts_and_filled(self):
|
||||||
# Other Contacts Radio button is Yes and if all required fields are filled
|
# Other Contacts Radio button is Yes and if all required fields are filled
|
||||||
return (
|
return (
|
||||||
|
@ -1221,14 +1236,12 @@ class DomainRequest(TimeStampedModel):
|
||||||
return self.is_policy_acknowledged is not None
|
return self.is_policy_acknowledged is not None
|
||||||
|
|
||||||
def _is_general_form_complete(self, request):
|
def _is_general_form_complete(self, request):
|
||||||
has_profile_feature_flag = flag_is_active(request, "profile_feature")
|
|
||||||
return (
|
return (
|
||||||
self._is_organization_name_and_address_complete()
|
self._is_creator_complete()
|
||||||
|
and self._is_organization_name_and_address_complete()
|
||||||
and self._is_senior_official_complete()
|
and self._is_senior_official_complete()
|
||||||
and self._is_requested_domain_complete()
|
and self._is_requested_domain_complete()
|
||||||
and self._is_purpose_complete()
|
and self._is_purpose_complete()
|
||||||
# NOTE: This flag leaves submitter as empty (request wont submit) hence set to True
|
|
||||||
and (self._is_submitter_complete() if not has_profile_feature_flag else True)
|
|
||||||
and self._is_other_contacts_complete()
|
and self._is_other_contacts_complete()
|
||||||
and self._is_additional_details_complete()
|
and self._is_additional_details_complete()
|
||||||
and self._is_policy_acknowledgement_complete()
|
and self._is_policy_acknowledgement_complete()
|
||||||
|
|
|
@ -131,9 +131,13 @@ class Portfolio(TimeStampedModel):
|
||||||
Returns a combination of organization_type / federal_type, seperated by ' - '.
|
Returns a combination of organization_type / federal_type, seperated by ' - '.
|
||||||
If no federal_type is found, we just return the org type.
|
If no federal_type is found, we just return the org type.
|
||||||
"""
|
"""
|
||||||
org_type_label = self.OrganizationChoices.get_org_label(self.organization_type)
|
return self.get_portfolio_type(self.organization_type, self.federal_type)
|
||||||
agency_type_label = BranchChoices.get_branch_label(self.federal_type)
|
|
||||||
if self.organization_type == self.OrganizationChoices.FEDERAL and agency_type_label:
|
@classmethod
|
||||||
|
def get_portfolio_type(cls, organization_type, federal_type):
|
||||||
|
org_type_label = cls.OrganizationChoices.get_org_label(organization_type)
|
||||||
|
agency_type_label = BranchChoices.get_branch_label(federal_type)
|
||||||
|
if organization_type == cls.OrganizationChoices.FEDERAL and agency_type_label:
|
||||||
return " - ".join([org_type_label, agency_type_label])
|
return " - ".join([org_type_label, agency_type_label])
|
||||||
else:
|
else:
|
||||||
return org_type_label
|
return org_type_label
|
||||||
|
@ -141,7 +145,11 @@ class Portfolio(TimeStampedModel):
|
||||||
@property
|
@property
|
||||||
def federal_type(self):
|
def federal_type(self):
|
||||||
"""Returns the federal_type value on the underlying federal_agency field"""
|
"""Returns the federal_type value on the underlying federal_agency field"""
|
||||||
return self.federal_agency.federal_type if self.federal_agency else None
|
return self.get_federal_type(self.federal_agency)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_federal_type(cls, federal_agency):
|
||||||
|
return federal_agency.federal_type if federal_agency else None
|
||||||
|
|
||||||
# == Getters for domains == #
|
# == Getters for domains == #
|
||||||
def get_domains(self):
|
def get_domains(self):
|
||||||
|
|
|
@ -1,13 +1,11 @@
|
||||||
"""People are invited by email to administer domains."""
|
"""People are invited by email to administer domains."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
from django_fsm import FSMField, transition
|
from django_fsm import FSMField, transition
|
||||||
|
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||||
from .utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices # type: ignore
|
from .utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices # type: ignore
|
||||||
|
|
||||||
from .utility.time_stamped_model import TimeStampedModel
|
from .utility.time_stamped_model import TimeStampedModel
|
||||||
from django.contrib.postgres.fields import ArrayField
|
from django.contrib.postgres.fields import ArrayField
|
||||||
|
|
||||||
|
@ -87,9 +85,11 @@ class PortfolioInvitation(TimeStampedModel):
|
||||||
raise RuntimeError("Cannot find the user to retrieve this portfolio invitation.")
|
raise RuntimeError("Cannot find the user to retrieve this portfolio invitation.")
|
||||||
|
|
||||||
# and create a role for that user on this portfolio
|
# and create a role for that user on this portfolio
|
||||||
user.portfolio = self.portfolio
|
user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
|
||||||
|
portfolio=self.portfolio, user=user
|
||||||
|
)
|
||||||
if self.portfolio_roles and len(self.portfolio_roles) > 0:
|
if self.portfolio_roles and len(self.portfolio_roles) > 0:
|
||||||
user.portfolio_roles = self.portfolio_roles
|
user_portfolio_permission.roles = self.portfolio_roles
|
||||||
if self.portfolio_additional_permissions and len(self.portfolio_additional_permissions) > 0:
|
if self.portfolio_additional_permissions and len(self.portfolio_additional_permissions) > 0:
|
||||||
user.portfolio_additional_permissions = self.portfolio_additional_permissions
|
user_portfolio_permission.additional_permissions = self.portfolio_additional_permissions
|
||||||
user.save()
|
user_portfolio_permission.save()
|
||||||
|
|
|
@ -3,11 +3,10 @@ import logging
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.forms import ValidationError
|
from django.http import HttpRequest
|
||||||
|
|
||||||
from registrar.models.domain_information import DomainInformation
|
from registrar.models import DomainInformation, UserDomainRole
|
||||||
from registrar.models.user_domain_role import UserDomainRole
|
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices
|
||||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
|
||||||
|
|
||||||
from .domain_invitation import DomainInvitation
|
from .domain_invitation import DomainInvitation
|
||||||
from .portfolio_invitation import PortfolioInvitation
|
from .portfolio_invitation import PortfolioInvitation
|
||||||
|
@ -15,7 +14,6 @@ from .transition_domain import TransitionDomain
|
||||||
from .verified_by_staff import VerifiedByStaff
|
from .verified_by_staff import VerifiedByStaff
|
||||||
from .domain import Domain
|
from .domain import Domain
|
||||||
from .domain_request import DomainRequest
|
from .domain_request import DomainRequest
|
||||||
from django.contrib.postgres.fields import ArrayField
|
|
||||||
from waffle.decorators import flag_is_active
|
from waffle.decorators import flag_is_active
|
||||||
|
|
||||||
from phonenumber_field.modelfields import PhoneNumberField # type: ignore
|
from phonenumber_field.modelfields import PhoneNumberField # type: ignore
|
||||||
|
@ -66,32 +64,6 @@ class User(AbstractUser):
|
||||||
# after they login.
|
# after they login.
|
||||||
FIXTURE_USER = "fixture_user", "Created by fixtures"
|
FIXTURE_USER = "fixture_user", "Created by fixtures"
|
||||||
|
|
||||||
PORTFOLIO_ROLE_PERMISSIONS = {
|
|
||||||
UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [
|
|
||||||
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
|
|
||||||
UserPortfolioPermissionChoices.VIEW_MEMBER,
|
|
||||||
UserPortfolioPermissionChoices.EDIT_MEMBER,
|
|
||||||
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
|
|
||||||
UserPortfolioPermissionChoices.EDIT_REQUESTS,
|
|
||||||
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
|
||||||
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
|
|
||||||
# Domain: field specific permissions
|
|
||||||
UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION,
|
|
||||||
UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION,
|
|
||||||
],
|
|
||||||
UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY: [
|
|
||||||
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
|
|
||||||
UserPortfolioPermissionChoices.VIEW_MEMBER,
|
|
||||||
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
|
|
||||||
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
|
||||||
# Domain: field specific permissions
|
|
||||||
UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION,
|
|
||||||
],
|
|
||||||
UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [
|
|
||||||
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
# #### Constants for choice fields ####
|
# #### Constants for choice fields ####
|
||||||
RESTRICTED = "restricted"
|
RESTRICTED = "restricted"
|
||||||
STATUS_CHOICES = ((RESTRICTED, RESTRICTED),)
|
STATUS_CHOICES = ((RESTRICTED, RESTRICTED),)
|
||||||
|
@ -112,34 +84,6 @@ class User(AbstractUser):
|
||||||
related_name="users",
|
related_name="users",
|
||||||
)
|
)
|
||||||
|
|
||||||
portfolio = models.ForeignKey(
|
|
||||||
"registrar.Portfolio",
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
related_name="user",
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
)
|
|
||||||
|
|
||||||
portfolio_roles = ArrayField(
|
|
||||||
models.CharField(
|
|
||||||
max_length=50,
|
|
||||||
choices=UserPortfolioRoleChoices.choices,
|
|
||||||
),
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
help_text="Select one or more roles.",
|
|
||||||
)
|
|
||||||
|
|
||||||
portfolio_additional_permissions = ArrayField(
|
|
||||||
models.CharField(
|
|
||||||
max_length=50,
|
|
||||||
choices=UserPortfolioPermissionChoices.choices,
|
|
||||||
),
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
help_text="Select one or more additional permissions.",
|
|
||||||
)
|
|
||||||
|
|
||||||
phone = PhoneNumberField(
|
phone = PhoneNumberField(
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
|
@ -187,6 +131,12 @@ class User(AbstractUser):
|
||||||
else:
|
else:
|
||||||
return self.username
|
return self.username
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_default_user(cls):
|
||||||
|
"""Returns the default "system" user"""
|
||||||
|
default_creator, _ = User.objects.get_or_create(username="System")
|
||||||
|
return default_creator
|
||||||
|
|
||||||
def restrict_user(self):
|
def restrict_user(self):
|
||||||
self.status = self.RESTRICTED
|
self.status = self.RESTRICTED
|
||||||
self.save()
|
self.save()
|
||||||
|
@ -230,68 +180,134 @@ class User(AbstractUser):
|
||||||
def has_contact_info(self):
|
def has_contact_info(self):
|
||||||
return bool(self.title or self.email or self.phone)
|
return bool(self.title or self.email or self.phone)
|
||||||
|
|
||||||
def clean(self):
|
def _has_portfolio_permission(self, portfolio, portfolio_permission):
|
||||||
"""Extends clean method to perform additional validation, which can raise errors in django admin."""
|
|
||||||
super().clean()
|
|
||||||
|
|
||||||
if self.portfolio is None and self._get_portfolio_permissions():
|
|
||||||
raise ValidationError("When portfolio roles or additional permissions are assigned, portfolio is required.")
|
|
||||||
|
|
||||||
if self.portfolio is not None and not self._get_portfolio_permissions():
|
|
||||||
raise ValidationError("When portfolio is assigned, portfolio roles or additional permissions are required.")
|
|
||||||
|
|
||||||
def _get_portfolio_permissions(self):
|
|
||||||
"""
|
|
||||||
Retrieve the permissions for the user's portfolio roles.
|
|
||||||
"""
|
|
||||||
portfolio_permissions = set() # Use a set to avoid duplicate permissions
|
|
||||||
|
|
||||||
if self.portfolio_roles:
|
|
||||||
for role in self.portfolio_roles:
|
|
||||||
if role in self.PORTFOLIO_ROLE_PERMISSIONS:
|
|
||||||
portfolio_permissions.update(self.PORTFOLIO_ROLE_PERMISSIONS[role])
|
|
||||||
if self.portfolio_additional_permissions:
|
|
||||||
portfolio_permissions.update(self.portfolio_additional_permissions)
|
|
||||||
return list(portfolio_permissions) # Convert back to list if necessary
|
|
||||||
|
|
||||||
def _has_portfolio_permission(self, portfolio_permission):
|
|
||||||
"""The views should only call this function when testing for perms and not rely on roles."""
|
"""The views should only call this function when testing for perms and not rely on roles."""
|
||||||
|
|
||||||
if not self.portfolio:
|
if not portfolio:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
portfolio_permissions = self._get_portfolio_permissions()
|
user_portfolio_perms = self.portfolio_permissions.filter(portfolio=portfolio, user=self).first()
|
||||||
|
if not user_portfolio_perms:
|
||||||
|
return False
|
||||||
|
|
||||||
return portfolio_permission in portfolio_permissions
|
return portfolio_permission in user_portfolio_perms._get_portfolio_permissions()
|
||||||
|
|
||||||
# the methods below are checks for individual portfolio permissions. They are defined here
|
def has_base_portfolio_permission(self, portfolio):
|
||||||
# to make them easier to call elsewhere throughout the application
|
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_PORTFOLIO)
|
||||||
def has_base_portfolio_permission(self):
|
|
||||||
return self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_PORTFOLIO)
|
|
||||||
|
|
||||||
def has_edit_org_portfolio_permission(self):
|
def has_edit_org_portfolio_permission(self, portfolio):
|
||||||
return self._has_portfolio_permission(UserPortfolioPermissionChoices.EDIT_PORTFOLIO)
|
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_PORTFOLIO)
|
||||||
|
|
||||||
def has_domains_portfolio_permission(self):
|
def has_any_domains_portfolio_permission(self, portfolio):
|
||||||
return self._has_portfolio_permission(
|
return self._has_portfolio_permission(
|
||||||
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS
|
portfolio, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS
|
||||||
) or self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS)
|
) or self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS)
|
||||||
|
|
||||||
def has_domain_requests_portfolio_permission(self):
|
def has_organization_requests_flag(self):
|
||||||
return self._has_portfolio_permission(
|
request = HttpRequest()
|
||||||
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS
|
request.user = self
|
||||||
) or self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS)
|
return flag_is_active(request, "organization_requests")
|
||||||
|
|
||||||
def has_view_all_domains_permission(self):
|
def has_organization_members_flag(self):
|
||||||
|
request = HttpRequest()
|
||||||
|
request.user = self
|
||||||
|
return flag_is_active(request, "organization_members")
|
||||||
|
|
||||||
|
def has_view_members_portfolio_permission(self, portfolio):
|
||||||
|
# BEGIN
|
||||||
|
# Note code below is to add organization_request feature
|
||||||
|
if not self.has_organization_members_flag():
|
||||||
|
return False
|
||||||
|
# END
|
||||||
|
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_MEMBERS)
|
||||||
|
|
||||||
|
def has_edit_members_portfolio_permission(self, portfolio):
|
||||||
|
# BEGIN
|
||||||
|
# Note code below is to add organization_request feature
|
||||||
|
if not self.has_organization_members_flag():
|
||||||
|
return False
|
||||||
|
# END
|
||||||
|
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_MEMBERS)
|
||||||
|
|
||||||
|
def has_view_all_domains_portfolio_permission(self, portfolio):
|
||||||
"""Determines if the current user can view all available domains in a given portfolio"""
|
"""Determines if the current user can view all available domains in a given portfolio"""
|
||||||
return self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)
|
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)
|
||||||
|
|
||||||
|
def has_any_requests_portfolio_permission(self, portfolio):
|
||||||
|
# BEGIN
|
||||||
|
# Note code below is to add organization_request feature
|
||||||
|
if not self.has_organization_requests_flag():
|
||||||
|
return False
|
||||||
|
# END
|
||||||
|
return self._has_portfolio_permission(
|
||||||
|
portfolio, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS
|
||||||
|
) or self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_REQUESTS)
|
||||||
|
|
||||||
|
def has_view_all_requests_portfolio_permission(self, portfolio):
|
||||||
|
"""Determines if the current user can view all available domain requests in a given portfolio"""
|
||||||
|
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)
|
||||||
|
|
||||||
|
def has_edit_request_portfolio_permission(self, portfolio):
|
||||||
|
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_REQUESTS)
|
||||||
|
|
||||||
# Field specific permission checks
|
# Field specific permission checks
|
||||||
def has_view_suborganization(self):
|
def has_view_suborganization_portfolio_permission(self, portfolio):
|
||||||
return self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION)
|
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION)
|
||||||
|
|
||||||
def has_edit_suborganization(self):
|
def has_edit_suborganization_portfolio_permission(self, portfolio):
|
||||||
return self._has_portfolio_permission(UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION)
|
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION)
|
||||||
|
|
||||||
|
def get_first_portfolio(self):
|
||||||
|
permission = self.portfolio_permissions.first()
|
||||||
|
if permission:
|
||||||
|
return permission.portfolio
|
||||||
|
return None
|
||||||
|
|
||||||
|
def portfolio_role_summary(self, portfolio):
|
||||||
|
"""Returns a list of roles based on the user's permissions."""
|
||||||
|
roles = []
|
||||||
|
|
||||||
|
# Define the conditions and their corresponding roles
|
||||||
|
conditions_roles = [
|
||||||
|
(self.has_edit_suborganization_portfolio_permission(portfolio), ["Admin"]),
|
||||||
|
(
|
||||||
|
self.has_view_all_domains_portfolio_permission(portfolio)
|
||||||
|
and self.has_any_requests_portfolio_permission(portfolio)
|
||||||
|
and self.has_edit_request_portfolio_permission(portfolio),
|
||||||
|
["View-only admin", "Domain requestor"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
self.has_view_all_domains_portfolio_permission(portfolio)
|
||||||
|
and self.has_any_requests_portfolio_permission(portfolio),
|
||||||
|
["View-only admin"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
self.has_base_portfolio_permission(portfolio)
|
||||||
|
and self.has_edit_request_portfolio_permission(portfolio)
|
||||||
|
and self.has_any_domains_portfolio_permission(portfolio),
|
||||||
|
["Domain requestor", "Domain manager"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
self.has_base_portfolio_permission(portfolio) and self.has_edit_request_portfolio_permission(portfolio),
|
||||||
|
["Domain requestor"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
self.has_base_portfolio_permission(portfolio) and self.has_any_domains_portfolio_permission(portfolio),
|
||||||
|
["Domain manager"],
|
||||||
|
),
|
||||||
|
(self.has_base_portfolio_permission(portfolio), ["Member"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Evaluate conditions and add roles
|
||||||
|
for condition, role_list in conditions_roles:
|
||||||
|
if condition:
|
||||||
|
roles.extend(role_list)
|
||||||
|
break
|
||||||
|
|
||||||
|
return roles
|
||||||
|
|
||||||
|
def get_portfolios(self):
|
||||||
|
return self.portfolio_permissions.all()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def needs_identity_verification(cls, email, uuid):
|
def needs_identity_verification(cls, email, uuid):
|
||||||
|
@ -406,7 +422,14 @@ class User(AbstractUser):
|
||||||
for invitation in PortfolioInvitation.objects.filter(
|
for invitation in PortfolioInvitation.objects.filter(
|
||||||
email__iexact=self.email, status=PortfolioInvitation.PortfolioInvitationStatus.INVITED
|
email__iexact=self.email, status=PortfolioInvitation.PortfolioInvitationStatus.INVITED
|
||||||
):
|
):
|
||||||
if self.portfolio is None:
|
# need to create a bogus request and assign user to it, in order to pass request
|
||||||
|
# to flag_is_active
|
||||||
|
request = HttpRequest()
|
||||||
|
request.user = self
|
||||||
|
only_single_portfolio = (
|
||||||
|
not flag_is_active(request, "multiple_portfolios") and self.get_first_portfolio() is None
|
||||||
|
)
|
||||||
|
if only_single_portfolio or flag_is_active(None, "multiple_portfolios"):
|
||||||
try:
|
try:
|
||||||
invitation.retrieve()
|
invitation.retrieve()
|
||||||
invitation.save()
|
invitation.save()
|
||||||
|
@ -433,11 +456,13 @@ class User(AbstractUser):
|
||||||
|
|
||||||
def is_org_user(self, request):
|
def is_org_user(self, request):
|
||||||
has_organization_feature_flag = flag_is_active(request, "organization_feature")
|
has_organization_feature_flag = flag_is_active(request, "organization_feature")
|
||||||
return has_organization_feature_flag and self.has_base_portfolio_permission()
|
portfolio = request.session.get("portfolio")
|
||||||
|
return has_organization_feature_flag and self.has_base_portfolio_permission(portfolio)
|
||||||
|
|
||||||
def get_user_domain_ids(self, request):
|
def get_user_domain_ids(self, request):
|
||||||
"""Returns either the domains ids associated with this user on UserDomainRole or Portfolio"""
|
"""Returns either the domains ids associated with this user on UserDomainRole or Portfolio"""
|
||||||
if self.is_org_user(request) and self.has_view_all_domains_permission():
|
portfolio = request.session.get("portfolio")
|
||||||
return DomainInformation.objects.filter(portfolio=self.portfolio).values_list("domain_id", flat=True)
|
if self.is_org_user(request) and self.has_view_all_domains_portfolio_permission(portfolio):
|
||||||
|
return DomainInformation.objects.filter(portfolio=portfolio).values_list("domain_id", flat=True)
|
||||||
else:
|
else:
|
||||||
return UserDomainRole.objects.filter(user=self).values_list("domain_id", flat=True)
|
return UserDomainRole.objects.filter(user=self).values_list("domain_id", flat=True)
|
||||||
|
|
119
src/registrar/models/user_portfolio_permission.py
Normal file
119
src/registrar/models/user_portfolio_permission.py
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
from django.db import models
|
||||||
|
from django.forms import ValidationError
|
||||||
|
from django.http import HttpRequest
|
||||||
|
from waffle import flag_is_active
|
||||||
|
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||||
|
from .utility.time_stamped_model import TimeStampedModel
|
||||||
|
from django.contrib.postgres.fields import ArrayField
|
||||||
|
|
||||||
|
|
||||||
|
class UserPortfolioPermission(TimeStampedModel):
|
||||||
|
"""This is a linking table that connects a user with a role on a portfolio."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ["user", "portfolio"]
|
||||||
|
|
||||||
|
PORTFOLIO_ROLE_PERMISSIONS = {
|
||||||
|
UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [
|
||||||
|
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
|
||||||
|
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||||
|
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||||
|
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
|
||||||
|
UserPortfolioPermissionChoices.EDIT_REQUESTS,
|
||||||
|
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
||||||
|
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
|
||||||
|
# Domain: field specific permissions
|
||||||
|
UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION,
|
||||||
|
UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION,
|
||||||
|
],
|
||||||
|
UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY: [
|
||||||
|
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
|
||||||
|
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||||
|
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
|
||||||
|
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
||||||
|
# Domain: field specific permissions
|
||||||
|
UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION,
|
||||||
|
],
|
||||||
|
UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [
|
||||||
|
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
user = models.ForeignKey(
|
||||||
|
"registrar.User",
|
||||||
|
null=False,
|
||||||
|
# when a user is deleted, permissions are too
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="portfolio_permissions",
|
||||||
|
)
|
||||||
|
|
||||||
|
portfolio = models.ForeignKey(
|
||||||
|
"registrar.Portfolio",
|
||||||
|
null=False,
|
||||||
|
# when a portfolio is deleted, permissions are too
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="portfolio_users",
|
||||||
|
)
|
||||||
|
|
||||||
|
roles = ArrayField(
|
||||||
|
models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=UserPortfolioRoleChoices.choices,
|
||||||
|
),
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Select one or more roles.",
|
||||||
|
)
|
||||||
|
|
||||||
|
additional_permissions = ArrayField(
|
||||||
|
models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=UserPortfolioPermissionChoices.choices,
|
||||||
|
),
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Select one or more additional permissions.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"User '{self.user}' on Portfolio '{self.portfolio}' " f"<Roles: {self.roles}>" if self.roles else ""
|
||||||
|
|
||||||
|
def _get_portfolio_permissions(self):
|
||||||
|
"""
|
||||||
|
Retrieve the permissions for the user's portfolio roles.
|
||||||
|
"""
|
||||||
|
# Use a set to avoid duplicate permissions
|
||||||
|
portfolio_permissions = set()
|
||||||
|
|
||||||
|
if self.roles:
|
||||||
|
for role in self.roles:
|
||||||
|
portfolio_permissions.update(self.PORTFOLIO_ROLE_PERMISSIONS.get(role, []))
|
||||||
|
|
||||||
|
if self.additional_permissions:
|
||||||
|
portfolio_permissions.update(self.additional_permissions)
|
||||||
|
|
||||||
|
return list(portfolio_permissions)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
"""Extends clean method to perform additional validation, which can raise errors in django admin."""
|
||||||
|
super().clean()
|
||||||
|
|
||||||
|
# Check if a user is set without accessing the related object.
|
||||||
|
has_user = bool(self.user_id)
|
||||||
|
if self.pk is None and has_user:
|
||||||
|
# Have to create a bogus request to set the user and pass to flag_is_active
|
||||||
|
request = HttpRequest()
|
||||||
|
request.user = self.user
|
||||||
|
existing_permissions = UserPortfolioPermission.objects.filter(user=self.user)
|
||||||
|
if not flag_is_active(request, "multiple_portfolios") and existing_permissions.exists():
|
||||||
|
raise ValidationError(
|
||||||
|
"Only one portfolio permission is allowed per user when multiple portfolios are disabled."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if portfolio is set without accessing the related object.
|
||||||
|
has_portfolio = bool(self.portfolio_id)
|
||||||
|
if not has_portfolio and self._get_portfolio_permissions():
|
||||||
|
raise ValidationError("When portfolio roles or additional permissions are assigned, portfolio is required.")
|
||||||
|
|
||||||
|
if has_portfolio and not self._get_portfolio_permissions():
|
||||||
|
raise ValidationError("When portfolio is assigned, portfolio roles or additional permissions are required.")
|
|
@ -17,11 +17,10 @@ class UserPortfolioPermissionChoices(models.TextChoices):
|
||||||
VIEW_ALL_DOMAINS = "view_all_domains", "View all domains and domain reports"
|
VIEW_ALL_DOMAINS = "view_all_domains", "View all domains and domain reports"
|
||||||
VIEW_MANAGED_DOMAINS = "view_managed_domains", "View managed domains"
|
VIEW_MANAGED_DOMAINS = "view_managed_domains", "View managed domains"
|
||||||
|
|
||||||
VIEW_MEMBER = "view_member", "View members"
|
VIEW_MEMBERS = "view_members", "View members"
|
||||||
EDIT_MEMBER = "edit_member", "Create and edit members"
|
EDIT_MEMBERS = "edit_members", "Create and edit members"
|
||||||
|
|
||||||
VIEW_ALL_REQUESTS = "view_all_requests", "View all requests"
|
VIEW_ALL_REQUESTS = "view_all_requests", "View all requests"
|
||||||
VIEW_CREATED_REQUESTS = "view_created_requests", "View created requests"
|
|
||||||
EDIT_REQUESTS = "edit_requests", "Create and edit requests"
|
EDIT_REQUESTS = "edit_requests", "Create and edit requests"
|
||||||
|
|
||||||
VIEW_PORTFOLIO = "view_portfolio", "View organization"
|
VIEW_PORTFOLIO = "view_portfolio", "View organization"
|
||||||
|
|
|
@ -6,7 +6,7 @@ import logging
|
||||||
from urllib.parse import parse_qs
|
from urllib.parse import parse_qs
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect
|
||||||
from registrar.models.user import User
|
from registrar.models import User
|
||||||
from waffle.decorators import flag_is_active
|
from waffle.decorators import flag_is_active
|
||||||
|
|
||||||
from registrar.models.utility.generic_helper import replace_url_queryparams
|
from registrar.models.utility.generic_helper import replace_url_queryparams
|
||||||
|
@ -125,8 +125,9 @@ class CheckUserProfileMiddleware:
|
||||||
|
|
||||||
class CheckPortfolioMiddleware:
|
class CheckPortfolioMiddleware:
|
||||||
"""
|
"""
|
||||||
Checks if the current user has a portfolio
|
this middleware should serve two purposes:
|
||||||
If they do, redirect them to the portfolio homepage when they navigate to home.
|
1 - set the portfolio in session if appropriate # views will need the session portfolio
|
||||||
|
2 - if path is home and session portfolio is set, redirect based on permissions of user
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, get_response):
|
def __init__(self, get_response):
|
||||||
|
@ -140,19 +141,33 @@ class CheckPortfolioMiddleware:
|
||||||
def process_view(self, request, view_func, view_args, view_kwargs):
|
def process_view(self, request, view_func, view_args, view_kwargs):
|
||||||
current_path = request.path
|
current_path = request.path
|
||||||
|
|
||||||
if current_path == self.home and request.user.is_authenticated and request.user.is_org_user(request):
|
if not request.user.is_authenticated:
|
||||||
|
return None
|
||||||
|
|
||||||
if request.user.has_base_portfolio_permission():
|
# if multiple portfolios are allowed for this user
|
||||||
portfolio = request.user.portfolio
|
if flag_is_active(request, "organization_feature"):
|
||||||
|
self.set_portfolio_in_session(request)
|
||||||
|
elif request.session.get("portfolio"):
|
||||||
|
# Edge case: User disables flag while already logged in
|
||||||
|
request.session["portfolio"] = None
|
||||||
|
elif "portfolio" not in request.session:
|
||||||
|
# Set the portfolio in the session if its not already in it
|
||||||
|
request.session["portfolio"] = None
|
||||||
|
|
||||||
# Add the portfolio to the request object
|
if request.user.is_org_user(request):
|
||||||
request.portfolio = portfolio
|
if current_path == self.home:
|
||||||
|
if request.user.has_any_domains_portfolio_permission(request.session["portfolio"]):
|
||||||
if request.user.has_domains_portfolio_permission():
|
|
||||||
portfolio_redirect = reverse("domains")
|
portfolio_redirect = reverse("domains")
|
||||||
else:
|
else:
|
||||||
portfolio_redirect = reverse("no-portfolio-domains")
|
portfolio_redirect = reverse("no-portfolio-domains")
|
||||||
|
|
||||||
return HttpResponseRedirect(portfolio_redirect)
|
return HttpResponseRedirect(portfolio_redirect)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def set_portfolio_in_session(self, request):
|
||||||
|
# NOTE: we will want to change later to have a workflow for selecting
|
||||||
|
# portfolio and another for switching portfolio; for now, select first
|
||||||
|
if flag_is_active(request, "multiple_portfolios"):
|
||||||
|
request.session["portfolio"] = request.user.get_first_portfolio()
|
||||||
|
else:
|
||||||
|
request.session["portfolio"] = request.user.get_first_portfolio()
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div id="content-main" class="analytics">
|
<div id="content-main" class="custom-admin-template">
|
||||||
|
|
||||||
<div class="grid-row grid-gap-2">
|
<div class="grid-row grid-gap-2">
|
||||||
<div class="tablet:grid-col-6 margin-top-2">
|
<div class="tablet:grid-col-6 margin-top-2">
|
||||||
|
@ -29,28 +29,28 @@
|
||||||
<div class="padding-top-2 padding-x-2">
|
<div class="padding-top-2 padding-x-2">
|
||||||
<ul class="usa-button-group wrapped-button-group">
|
<ul class="usa-button-group wrapped-button-group">
|
||||||
<li class="usa-button-group__item">
|
<li class="usa-button-group__item">
|
||||||
<a href="{% url 'export_data_type' %}" class="button text-no-wrap" role="button">
|
<a href="{% url 'export_data_type' %}" class="usa-button usa-button--dja text-no-wrap" role="button">
|
||||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||||
</svg><span class="margin-left-05">All domain metadata</span>
|
</svg><span class="margin-left-05">All domain metadata</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="usa-button-group__item">
|
<li class="usa-button-group__item">
|
||||||
<a href="{% url 'export_data_full' %}" class="button text-no-wrap" role="button">
|
<a href="{% url 'export_data_full' %}" class="usa-button usa-button--dja text-no-wrap" role="button">
|
||||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||||
</svg><span class="margin-left-05">Current full</span>
|
</svg><span class="margin-left-05">Current full</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="usa-button-group__item">
|
<li class="usa-button-group__item">
|
||||||
<a href="{% url 'export_data_federal' %}" class="button text-no-wrap" role="button">
|
<a href="{% url 'export_data_federal' %}" class="usa-button usa-button--dja text-no-wrap" role="button">
|
||||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||||
</svg><span class="margin-left-05">Current federal</span>
|
</svg><span class="margin-left-05">Current federal</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="usa-button-group__item">
|
<li class="usa-button-group__item">
|
||||||
<a href="{% url 'export_data_domain_requests_full' %}" class="button text-no-wrap" role="button">
|
<a href="{% url 'export_data_domain_requests_full' %}" class="usa-button usa-button--dja text-no-wrap" role="button">
|
||||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||||
</svg><span class="margin-left-05">All domain requests metadata</span>
|
</svg><span class="margin-left-05">All domain requests metadata</span>
|
||||||
|
@ -84,35 +84,35 @@
|
||||||
</div>
|
</div>
|
||||||
<ul class="usa-button-group">
|
<ul class="usa-button-group">
|
||||||
<li class="usa-button-group__item">
|
<li class="usa-button-group__item">
|
||||||
<button class="button exportLink" data-export-url="{% url 'export_domains_growth' %}" type="button">
|
<button class="usa-button usa-button--dja exportLink" data-export-url="{% url 'export_domains_growth' %}" type="button">
|
||||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||||
</svg><span class="margin-left-05">Domain growth</span>
|
</svg><span class="margin-left-05">Domain growth</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="usa-button-group__item">
|
<li class="usa-button-group__item">
|
||||||
<button class="button exportLink" data-export-url="{% url 'export_requests_growth' %}" type="button">
|
<button class="usa-button usa-button--dja exportLink" data-export-url="{% url 'export_requests_growth' %}" type="button">
|
||||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||||
</svg><span class="margin-left-05">Request growth</span>
|
</svg><span class="margin-left-05">Request growth</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="usa-button-group__item">
|
<li class="usa-button-group__item">
|
||||||
<button class="button exportLink" data-export-url="{% url 'export_managed_domains' %}" type="button">
|
<button class="usa-button usa-button--dja exportLink" data-export-url="{% url 'export_managed_domains' %}" type="button">
|
||||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||||
</svg><span class="margin-left-05">Managed domains</span>
|
</svg><span class="margin-left-05">Managed domains</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="usa-button-group__item">
|
<li class="usa-button-group__item">
|
||||||
<button class="button exportLink" data-export-url="{% url 'export_unmanaged_domains' %}" type="button">
|
<button class="usa-button usa-button--dja exportLink" data-export-url="{% url 'export_unmanaged_domains' %}" type="button">
|
||||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||||
</svg><span class="margin-left-05">Unmanaged domains</span>
|
</svg><span class="margin-left-05">Unmanaged domains</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="usa-button-group__item">
|
<li class="usa-button-group__item">
|
||||||
<button class="button exportLink usa-button--secondary" data-export-url="{% url 'analytics' %}" type="button">
|
<button class="usa-button usa-button--dja exportLink usa-button--secondary" data-export-url="{% url 'analytics' %}" type="button">
|
||||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||||
<use xlink:href="{%static 'img/sprite.svg'%}#assessment"></use>
|
<use xlink:href="{%static 'img/sprite.svg'%}#assessment"></use>
|
||||||
</svg><span class="margin-left-05">Update charts</span>
|
</svg><span class="margin-left-05">Update charts</span>
|
||||||
|
|
|
@ -17,7 +17,7 @@ Template for an input field with a clipboard
|
||||||
>
|
>
|
||||||
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
|
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Copy</span>
|
Copy
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -25,7 +25,7 @@ Template for an input field with a clipboard
|
||||||
<div class="admin-icon-group admin-icon-group__clipboard-link">
|
<div class="admin-icon-group admin-icon-group__clipboard-link">
|
||||||
<input aria-hidden="true" class="display-none" value="{{ field.email }}" />
|
<input aria-hidden="true" class="display-none" value="{{ field.email }}" />
|
||||||
<button
|
<button
|
||||||
class="usa-button usa-button--unstyled padding-right-1 usa-button--icon button--clipboard copy-to-clipboard text-no-underline"
|
class="usa-button usa-button--unstyled padding-right-1 usa-button--icon button--clipboard copy-to-clipboard text-no-underline padding-left-05"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
@ -33,7 +33,7 @@ Template for an input field with a clipboard
|
||||||
>
|
>
|
||||||
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
|
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="padding-left-05">Copy</span>
|
Copy
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
|
@ -32,6 +32,8 @@
|
||||||
{% include "django/admin/includes/descriptions/website_description.html" %}
|
{% include "django/admin/includes/descriptions/website_description.html" %}
|
||||||
{% elif opts.model_name == 'portfolioinvitation' %}
|
{% elif opts.model_name == 'portfolioinvitation' %}
|
||||||
{% include "django/admin/includes/descriptions/portfolio_invitation_description.html" %}
|
{% include "django/admin/includes/descriptions/portfolio_invitation_description.html" %}
|
||||||
|
{% elif opts.model_name == 'allowedemail' %}
|
||||||
|
{% include "django/admin/includes/descriptions/allowed_email_description.html" %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>This table does not have a description yet.</p>
|
<p>This table does not have a description yet.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
260
src/registrar/templates/admin/transfer_user.html
Normal file
260
src/registrar/templates/admin/transfer_user.html
Normal file
|
@ -0,0 +1,260 @@
|
||||||
|
{% extends 'admin/base_site.html' %}
|
||||||
|
{% load i18n static %}
|
||||||
|
|
||||||
|
{% block content_title %}<h1>Transfer user</h1>{% endblock %}
|
||||||
|
|
||||||
|
{% block extrastyle %}
|
||||||
|
|
||||||
|
{{ block.super }}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extrahead %}
|
||||||
|
{{ block.super }}
|
||||||
|
<!-- Making the user select a combobox: -->
|
||||||
|
<!-- Load Django Admin's base JavaScript. This is NEEDED because select2 relies on it. -->
|
||||||
|
<script src="{% static 'admin/js/vendor/jquery/jquery.min.js' %}"></script>
|
||||||
|
|
||||||
|
<!-- Include Select2 JavaScript. Since this view technically falls outside of admin, this is needed. -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||||
|
<script type="application/javascript" src="{% static 'js/get-gov-admin-extra.js' %}" defer></script>
|
||||||
|
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block breadcrumbs %}
|
||||||
|
<div class="breadcrumbs">
|
||||||
|
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
|
||||||
|
› <a href="{% url 'admin:app_list' 'registrar' %}">{% trans 'Registrar' %}</a>
|
||||||
|
› <a href="{% url 'admin:registrar_user_changelist' %}">{% trans 'Users' %}</a>
|
||||||
|
› <a href="{% url 'admin:registrar_user_change' current_user.pk %}">{{ current_user.first_name }} {{ current_user.last_name }}</a>
|
||||||
|
› {% trans 'Transfer User' %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div id="content-main" class="custom-admin-template">
|
||||||
|
|
||||||
|
<div class="module padding-4 display-flex flex-row flex-justify submit-row">
|
||||||
|
|
||||||
|
<div class="desktop:flex-align-center">
|
||||||
|
<form class="transfer-user-selector" method="GET" action="{% url 'transfer_user' current_user.pk %}">
|
||||||
|
<label for="selected_user" class="text-middle">Select user to transfer data from:</label>
|
||||||
|
<select name="selected_user" id="selected_user" class="admin-combobox margin-top-0" onchange="this.form.submit()">
|
||||||
|
<option value="">Select a user</option>
|
||||||
|
{% for user in other_users %}
|
||||||
|
<option value="{{ user.pk }}" {% if selected_user and user.pk == selected_user.pk %}selected{% endif %}>
|
||||||
|
{{ user.first_name }} {{ user.last_name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<input type="submit" value="Select and preview" class="button--dja-toolbar">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="desktop:flex-align-center">
|
||||||
|
{% if selected_user %}
|
||||||
|
<a class="usa-button usa-button--dja" href="#transfer-and-delete" aria-controls="transfer-and-delete" data-open-modal>
|
||||||
|
Transfer and delete user
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-row grid-gap-2">
|
||||||
|
|
||||||
|
<div class="tablet:grid-col-6 margin-top-2">
|
||||||
|
<div class="module height-full">
|
||||||
|
<h2>User to transfer data from</h2>
|
||||||
|
<div class="padding-top-2 padding-x-2">
|
||||||
|
{% if selected_user %}
|
||||||
|
<dl class="dl-dja">
|
||||||
|
<dt>Username:</dt>
|
||||||
|
<dd>{{ selected_user.username }}</dd>
|
||||||
|
<dt>Created at:</dt>
|
||||||
|
<dd>{{ selected_user.created_at }}</dd>
|
||||||
|
<dt>Last login:</dt>
|
||||||
|
<dd>{{ selected_user.last_login }}</dd>
|
||||||
|
<dt>First name:</dt>
|
||||||
|
<dd>{{ selected_user.first_name }}</dd>
|
||||||
|
<dt>Middle name:</dt>
|
||||||
|
<dd>{{ selected_user.middle_name }}</dd>
|
||||||
|
<dt>Last name:</dt>
|
||||||
|
<dd>{{ selected_user.last_name }}</dd>
|
||||||
|
<dt>Title:</dt>
|
||||||
|
<dd>{{ selected_user.title }}</dd>
|
||||||
|
<dt>Email:</dt>
|
||||||
|
<dd>{{ selected_user.email }}</dd>
|
||||||
|
<dt>Phone:</dt>
|
||||||
|
<dd>{{ selected_user.phone }}</dd>
|
||||||
|
<h3 class="font-heading-md">Data that will get transferred:</h3>
|
||||||
|
<dt>Domains:</dt>
|
||||||
|
<dd>
|
||||||
|
{% if selected_user_domains %}
|
||||||
|
<ul>
|
||||||
|
{% for domain in selected_user_domains %}
|
||||||
|
<li>{{ domain }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
None
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
<dt>Domain requests:</dt>
|
||||||
|
<dd>
|
||||||
|
{% if selected_user_domain_requests %}
|
||||||
|
<ul>
|
||||||
|
{% for request in selected_user_domain_requests %}
|
||||||
|
<li>{{ request }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
None
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
<dt>Portfolios:</dt>
|
||||||
|
<dd>
|
||||||
|
{% if selected_user_portfolios %}
|
||||||
|
<ul>
|
||||||
|
{% for portfolio in selected_user_portfolios %}
|
||||||
|
<li>{{ portfolio.portfolio }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
None
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
{% else %}
|
||||||
|
<p>No user selected yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tablet:grid-col-6 margin-top-2">
|
||||||
|
<div class="module height-full">
|
||||||
|
<h2>User to receive data</h2>
|
||||||
|
<div class="padding-top-2 padding-x-2">
|
||||||
|
<dl class="dl-dja">
|
||||||
|
<dt>Username:</dt>
|
||||||
|
<dd>{{ current_user.username }}</dd>
|
||||||
|
<dt>Created at:</dt>
|
||||||
|
<dd>{{ current_user.created_at }}</dd>
|
||||||
|
<dt>Last login:</dt>
|
||||||
|
<dd>{{ current_user.last_login }}</dd>
|
||||||
|
<dt>First name:</dt>
|
||||||
|
<dd>{{ current_user.first_name }}</dd>
|
||||||
|
<dt>Middle name:</dt>
|
||||||
|
<dd>{{ current_user.middle_name }}</dd>
|
||||||
|
<dt>Last name:</dt>
|
||||||
|
<dd>{{ current_user.last_name }}</dd>
|
||||||
|
<dt>Title:</dt>
|
||||||
|
<dd>{{ current_user.title }}</dd>
|
||||||
|
<dt>Email:</dt>
|
||||||
|
<dd>{{ current_user.email }}</dd>
|
||||||
|
<dt>Phone:</dt>
|
||||||
|
<dd>{{ current_user.phone }}</dd>
|
||||||
|
<h3 class="font-heading-md" aria-label="Data that will added to:"> </h3>
|
||||||
|
<dt>Domains:</dt>
|
||||||
|
<dd>
|
||||||
|
{% if current_user_domains %}
|
||||||
|
<ul>
|
||||||
|
{% for domain in current_user_domains %}
|
||||||
|
<li>{{ domain }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
None
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
<dt>Domain requests:</dt>
|
||||||
|
<dd>
|
||||||
|
{% if current_user_domain_requests %}
|
||||||
|
<ul>
|
||||||
|
{% for request in current_user_domain_requests %}
|
||||||
|
<li>{{ request }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
None
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
<dt>Portfolios:</dt>
|
||||||
|
<dd>
|
||||||
|
{% if current_user_portfolios %}
|
||||||
|
<ul>
|
||||||
|
{% for portfolio in current_user_portfolios %}
|
||||||
|
<li>{{ portfolio.portfolio }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
None
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="usa-modal"
|
||||||
|
id="transfer-and-delete"
|
||||||
|
aria-labelledby="This action will delete {{ selected_user }}"
|
||||||
|
aria-describedby="This action will delete {{ selected_user }}"
|
||||||
|
>
|
||||||
|
<div class="usa-modal__content">
|
||||||
|
<div class="usa-modal__main">
|
||||||
|
<h2 class="usa-modal__heading" id="transfer-and-delete-heading">
|
||||||
|
Are you sure you want to transfer data and delete this user?
|
||||||
|
</h2>
|
||||||
|
<div class="usa-prose">
|
||||||
|
{% if selected_user != logged_in_user %}
|
||||||
|
<p>Username: <b>{{ selected_user.username }}</b><br>
|
||||||
|
Name: <b>{{ selected_user.first_name }} {{ selected_user.last_name }}</b><br>
|
||||||
|
Email: <b>{{ selected_user.email }}</b></p>
|
||||||
|
<p>This action cannot be undone.</p>
|
||||||
|
{% else %}
|
||||||
|
<p>Don't do it!</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="usa-modal__footer">
|
||||||
|
<ul class="usa-button-group">
|
||||||
|
{% if selected_user != logged_in_user %}
|
||||||
|
<li class="usa-button-group__item">
|
||||||
|
<form method="POST" action="{% url 'transfer_user' current_user.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="selected_user" value="{{ selected_user.pk }}">
|
||||||
|
<input type="submit" class="usa-button usa-button--dja" value="Yes, transfer and delete user">
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li class="usa-button-group__item">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="usa-button usa-button--unstyled padding-105 text-center"
|
||||||
|
name="_cancel_domain_request_ineligible"
|
||||||
|
data-close-modal
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="usa-button usa-modal__close"
|
||||||
|
aria-label="Close this window"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -120,7 +120,7 @@
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<p class="padding-top-05 text-right margin-top-2 padding-right-2 margin-bottom-0 requested-domain-sticky float-right visible-768">
|
<p class="padding-top-05 text-right margin-top-2 padding-right-2 margin-bottom-0 submit-row-sticky float-right visible-768">
|
||||||
Requested domain: <strong>{{ original.requested_domain.name }}</strong>
|
Requested domain: <strong>{{ original.requested_domain.name }}</strong>
|
||||||
</p>
|
</p>
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
<p>This table is an email allow list for <strong>non-production</strong> environments.</p>
|
||||||
|
<p>
|
||||||
|
If an email is sent out and the email does not exist within this table (or is not a subset of it),
|
||||||
|
then no email will be sent.
|
||||||
|
</p>
|
||||||
|
<p>If this table is populated in a production environment, no change will occur as it will simply be ignored.</p>
|
|
@ -70,7 +70,7 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
||||||
<div class="readonly textarea-wrapper">
|
<div class="readonly textarea-wrapper">
|
||||||
<div id="action_needed_reason_email_readonly" class="dja-readonly-textarea-container padding-1 margin-top-0 padding-top-0 margin-bottom-1 thin-border collapse--dgsimple collapsed">
|
<div id="action_needed_reason_email_readonly" class="dja-readonly-textarea-container padding-1 margin-top-0 padding-top-0 margin-bottom-1 thin-border collapse--dgsimple collapsed">
|
||||||
<label class="max-full" for="action_needed_reason_email_view_more">
|
<label class="max-full" for="action_needed_reason_email_view_more">
|
||||||
<strong>Sent to {% if has_profile_feature_flag %}creator{%else%}submitter{%endif%}</strong>
|
<strong>Sent to creator</strong>
|
||||||
</label>
|
</label>
|
||||||
<textarea id="action_needed_reason_email_view_more" cols="40" rows="20" class="{% if not original_object.action_needed_reason %}display-none{% endif %}" readonly>
|
<textarea id="action_needed_reason_email_view_more" cols="40" rows="20" class="{% if not original_object.action_needed_reason %}display-none{% endif %}" readonly>
|
||||||
{{ original_object.action_needed_reason_email }}
|
{{ original_object.action_needed_reason_email }}
|
||||||
|
@ -107,7 +107,7 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% elif field.field.name == "requested_domain" %}
|
{% elif field.field.name == "requested_domain" %}
|
||||||
{% with current_path=request.get_full_path %}
|
{% with current_path=request.get_full_path %}
|
||||||
<a class="margin-top-05 padding-top-05" href="{% url 'admin:registrar_draftdomain_change' original.requested_domain.id %}?{{ 'return_path='|add:current_path }}">{{ original.requested_domain }}</a>
|
<a class="margin-top-05 padding-top-05" id="id_requested_domain" href="{% url 'admin:registrar_draftdomain_change' original.requested_domain.id %}?{{ 'return_path='|add:current_path }}">{{ original.requested_domain }}</a>
|
||||||
{% endwith%}
|
{% endwith%}
|
||||||
{% elif field.field.name == "current_websites" %}
|
{% elif field.field.name == "current_websites" %}
|
||||||
{% comment %}
|
{% comment %}
|
||||||
|
@ -137,6 +137,16 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
|
{% elif field.field.name == "display_admins" %}
|
||||||
|
<div class="readonly">{{ field.contents|safe }}</div>
|
||||||
|
{% elif field.field.name == "display_members" %}
|
||||||
|
<div class="readonly">
|
||||||
|
{% if display_members_summary %}
|
||||||
|
{{ display_members_summary }}
|
||||||
|
{% else %}
|
||||||
|
<p>No additional members found.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="readonly">{{ field.contents }}</div>
|
<div class="readonly">{{ field.contents }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -145,20 +155,110 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
||||||
|
|
||||||
{% block field_other %}
|
{% block field_other %}
|
||||||
{% if field.field.name == "action_needed_reason_email" %}
|
{% if field.field.name == "action_needed_reason_email" %}
|
||||||
<div id="action-needed-reason-email-readonly" class="readonly margin-top-0 padding-top-0 display-none">
|
|
||||||
<div class="margin-top-05 collapse--dgsimple collapsed">
|
|
||||||
{{ field.field.value|linebreaks }}
|
|
||||||
</div>
|
|
||||||
<button id="action_needed_reason_email__show_details" type="button" class="collapse-toggle--dgsimple usa-button usa-button--unstyled margin-top-0 margin-bottom-1 margin-left-1">
|
|
||||||
<span>Show details</span>
|
|
||||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
|
||||||
<use xlink:href="/public/img/sprite.svg#expand_more"></use>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
{{ field.field }}
|
<div id="action-needed-reason-email-placeholder-text" class="margin-top-05 text-faded">
|
||||||
<input id="action-needed-email-sent" class="display-none" value="{{action_needed_email_sent}}">
|
-
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div id="action-needed-reason-email-readonly" class="display-none usa-summary-box_admin padding-top-0 margin-top-0">
|
||||||
|
<div class="flex-container">
|
||||||
|
<div class="margin-top-05">
|
||||||
|
<p class="{% if action_needed_email_sent %}display-none{% endif %}" id="action-needed-email-header"><b>Auto-generated email that will be sent to the creator</b></p>
|
||||||
|
<p class="{% if not action_needed_email_sent %}display-none{% endif %}" id="action-needed-email-header-email-sent">
|
||||||
|
<svg class="usa-icon text-green" aria-hidden="true" focusable="false" role="img">
|
||||||
|
<use xlink:href="{%static 'img/sprite.svg'%}#check_circle"></use>
|
||||||
|
</svg>
|
||||||
|
<b>Email sent to the creator</b>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="vertical-separator margin-top-1 margin-bottom-1"></div>
|
||||||
|
<a
|
||||||
|
href="#email-already-sent-modal"
|
||||||
|
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text text-no-underline margin-left-1"
|
||||||
|
aria-controls="email-already-sent-modal"
|
||||||
|
data-open-modal
|
||||||
|
>Edit email</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="usa-modal"
|
||||||
|
id="email-already-sent-modal"
|
||||||
|
aria-labelledby="Are you sure you want to edit this email?"
|
||||||
|
aria-describedby="The creator of this request already received an email"
|
||||||
|
>
|
||||||
|
<div class="usa-modal__content">
|
||||||
|
<div class="usa-modal__main">
|
||||||
|
<h2 class="usa-modal__heading" id="modal-1-heading">
|
||||||
|
Are you sure you want to edit this email?
|
||||||
|
</h2>
|
||||||
|
<div class="usa-prose">
|
||||||
|
<p>
|
||||||
|
The creator of this request already received an email for this status/reason:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li class="font-body-sm">Status: <b>Action needed</b></li>
|
||||||
|
<li class="font-body-sm">Reason: <b>{{ original_object.get_action_needed_reason_display }}</b></li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
If you edit this email's text, <b>the system will send another email</b> to
|
||||||
|
the creator after you “save” your changes. If you do not want to send another email, click “cancel” below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="usa-modal__footer">
|
||||||
|
<ul class="usa-button-group">
|
||||||
|
<li class="usa-button-group__item">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="usa-button"
|
||||||
|
id="email-already-sent-modal_continue-editing-button"
|
||||||
|
data-close-modal
|
||||||
|
>
|
||||||
|
Yes, continue editing
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="usa-button-group__item">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="usa-button usa-button--unstyled padding-105 text-center"
|
||||||
|
name="_cancel_edit_email"
|
||||||
|
data-close-modal
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="usa-button usa-modal__close"
|
||||||
|
aria-label="Close this window"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="sr-only" for="action-needed-reason-email-readonly-textarea">Email:</label>
|
||||||
|
<textarea cols="40" rows="10" class="vLargeTextField" id="action-needed-reason-email-readonly-textarea" readonly>{{ field.field.value|striptags }}
|
||||||
|
</textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ field.field }}
|
||||||
|
<input id="action-needed-email-sent" class="display-none" value="{{action_needed_email_sent}}">
|
||||||
|
<input id="action-needed-email-last-sent-text" class="display-none" value="{{original_object.action_needed_reason_email}}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span id="action-needed-email-footer" class="help">
|
||||||
|
{% if not action_needed_email_sent %}
|
||||||
|
This email will be sent to the creator of this request after saving
|
||||||
|
{% else %}
|
||||||
|
This email has been sent to the creator of this request
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ field.field }}
|
{{ field.field }}
|
||||||
|
@ -187,11 +287,6 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
||||||
{% if not skip_additional_contact_info %}
|
{% if not skip_additional_contact_info %}
|
||||||
{% include "django/admin/includes/user_detail_list.html" with user=original_object.creator no_title_top_padding=field.is_readonly %}
|
{% include "django/admin/includes/user_detail_list.html" with user=original_object.creator no_title_top_padding=field.is_readonly %}
|
||||||
{% endif%}
|
{% endif%}
|
||||||
{% elif field.field.name == "submitter" %}
|
|
||||||
<div class="flex-container tablet:margin-top-2">
|
|
||||||
<label aria-label="Submitter contact details"></label>
|
|
||||||
{% include "django/admin/includes/contact_detail_list.html" with user=original_object.submitter no_title_top_padding=field.is_readonly %}
|
|
||||||
</div>
|
|
||||||
{% elif field.field.name == "senior_official" %}
|
{% elif field.field.name == "senior_official" %}
|
||||||
<div class="flex-container">
|
<div class="flex-container">
|
||||||
<label aria-label="Senior official contact details"></label>
|
<label aria-label="Senior official contact details"></label>
|
||||||
|
@ -240,7 +335,14 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
||||||
</details>
|
</details>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% elif field.field.name == "state_territory" %}
|
{% elif field.field.name == "display_members" and field.contents %}
|
||||||
|
<details class="margin-top-1 dja-detail-table" aria-role="button" open>
|
||||||
|
<summary class="padding-1 padding-left-0 dja-details-summary">Details</summary>
|
||||||
|
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
|
||||||
|
{{ field.contents|safe }}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% elif field.field.name == "state_territory" and original_object|model_name_lowercase != 'portfolio' %}
|
||||||
<div class="flex-container margin-top-2">
|
<div class="flex-container margin-top-2">
|
||||||
<span>
|
<span>
|
||||||
CISA region:
|
CISA region:
|
||||||
|
|
|
@ -1,10 +1,13 @@
|
||||||
{% extends 'django/admin/email_clipboard_change_form.html' %}
|
{% extends 'django/admin/email_clipboard_change_form.html' %}
|
||||||
|
{% load custom_filters %}
|
||||||
{% load i18n static %}
|
{% load i18n static %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
|
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
|
||||||
{% url 'get-senior-official-from-federal-agency-json' as url %}
|
{% url 'get-senior-official-from-federal-agency-json' as url %}
|
||||||
<input id="senior_official_from_agency_json_url" class="display-none" value="{{url}}" />
|
<input id="senior_official_from_agency_json_url" class="display-none" value="{{url}}" />
|
||||||
|
{% url 'get-federal-and-portfolio-types-from-federal-agency-json' as url %}
|
||||||
|
<input id="federal_and_portfolio_types_from_agency_json_url" class="display-none" value="{{url}}" />
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
||||||
|
@ -14,10 +17,28 @@
|
||||||
This is a placeholder for now.
|
This is a placeholder for now.
|
||||||
|
|
||||||
Disclaimer:
|
Disclaimer:
|
||||||
When extending the fieldset view - *make a new one* that extends from detail_table_fieldset.
|
When extending the fieldset view consider whether you need to make a new one that extends from detail_table_fieldset.
|
||||||
For instance, "portfolio_fieldset.html".
|
|
||||||
detail_table_fieldset is used on multiple admin pages, so a change there can have unintended consequences.
|
detail_table_fieldset is used on multiple admin pages, so a change there can have unintended consequences.
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
{% include "django/admin/includes/detail_table_fieldset.html" with original_object=original %}
|
{% include "django/admin/includes/detail_table_fieldset.html" with original_object=original %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block submit_buttons_bottom %}
|
||||||
|
<div class="submit-row-wrapper">
|
||||||
|
<span class="submit-row-toggle padding-1 padding-right-2 visible-desktop">
|
||||||
|
<button type="button" class="usa-button usa-button--unstyled" id="submitRowToggle">
|
||||||
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||||
|
<use xlink:href="{%static 'img/sprite.svg'%}#expand_more"></use>
|
||||||
|
</svg>
|
||||||
|
<span>Hide</span>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<p class="padding-top-05 text-right margin-top-2 padding-right-2 margin-bottom-0 submit-row-sticky float-right visible-768">
|
||||||
|
Organization Name: <strong>{{ original.organization_name }}</strong>
|
||||||
|
</p>
|
||||||
|
{{ block.super }}
|
||||||
|
</div>
|
||||||
|
<span class="scroll-indicator"></span>
|
||||||
|
{% endblock %}
|
||||||
|
|
|
@ -1,7 +1,42 @@
|
||||||
{% extends 'django/admin/email_clipboard_change_form.html' %}
|
{% extends 'django/admin/email_clipboard_change_form.html' %}
|
||||||
{% load i18n static %}
|
{% load i18n static %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block field_sets %}
|
||||||
|
<div class="display-flex flex-row flex-justify submit-row">
|
||||||
|
<div class="desktop:flex-align-self-end">
|
||||||
|
<a href="{% url 'transfer_user' original.pk %}" class="button">
|
||||||
|
Transfer data from old account
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for fieldset in adminform %}
|
||||||
|
{% include "django/admin/includes/domain_fieldset.html" with state_help_message=state_help_message %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block after_related_objects %}
|
{% block after_related_objects %}
|
||||||
|
{% if portfolios %}
|
||||||
|
<div class="module aligned padding-3">
|
||||||
|
<h2>Portfolio information</h2>
|
||||||
|
<div class="grid-row grid-gap mobile:padding-x-1 desktop:padding-x-4">
|
||||||
|
<div class="mobile:grid-col-12 tablet:grid-col-6 desktop:grid-col-4">
|
||||||
|
<h3>Portfolios</h3>
|
||||||
|
<ul class="margin-0 padding-0">
|
||||||
|
{% for portfolio in portfolios %}
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'admin:registrar_portfolio_change' portfolio.pk %}">
|
||||||
|
{{ portfolio }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="module aligned padding-3">
|
<div class="module aligned padding-3">
|
||||||
<h2>Associated requests and domains</h2>
|
<h2>Associated requests and domains</h2>
|
||||||
<div class="grid-row grid-gap mobile:padding-x-1 desktop:padding-x-4">
|
<div class="grid-row grid-gap mobile:padding-x-1 desktop:padding-x-4">
|
||||||
|
|
|
@ -7,7 +7,9 @@ for now we just carry the attribute to both the parent element and the select.
|
||||||
|
|
||||||
<div class="usa-combo-box"
|
<div class="usa-combo-box"
|
||||||
{% for name, value in widget.attrs.items %}
|
{% for name, value in widget.attrs.items %}
|
||||||
{{ name }}="{{ value }}"
|
{% if name != 'id' %}
|
||||||
|
{{ name }}="{{ value }}"
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
>
|
>
|
||||||
{% include "django/forms/widgets/select.html" %}
|
{% include "django/forms/widgets/select.html" %}
|
||||||
|
|
|
@ -72,9 +72,9 @@
|
||||||
{% include "includes/summary_item.html" with title='DNSSEC' value='Not Enabled' edit_link=url editable=is_editable %}
|
{% include "includes/summary_item.html" with title='DNSSEC' value='Not Enabled' edit_link=url editable=is_editable %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if portfolio and has_domains_portfolio_permission and request.user.has_view_suborganization %}
|
{% if portfolio and has_any_domains_portfolio_permission and has_view_suborganization_portfolio_permission %}
|
||||||
{% url 'domain-suborganization' pk=domain.id as url %}
|
{% url 'domain-suborganization' pk=domain.id as url %}
|
||||||
{% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:request.user.has_edit_suborganization %}
|
{% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:has_edit_suborganization_portfolio_permission %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% url 'domain-org-name-address' pk=domain.id as url %}
|
{% url 'domain-org-name-address' pk=domain.id as url %}
|
||||||
{% include "includes/summary_item.html" with title='Organization name and mailing address' value=domain.domain_info address='true' edit_link=url editable=is_editable %}
|
{% include "includes/summary_item.html" with title='Organization name and mailing address' value=domain.domain_info address='true' edit_link=url editable=is_editable %}
|
||||||
|
|
|
@ -63,10 +63,10 @@
|
||||||
|
|
||||||
<div class="grid-row margin-top-1">
|
<div class="grid-row margin-top-1">
|
||||||
<div class="grid-col">
|
<div class="grid-col">
|
||||||
<button type="button" class="usa-button usa-button--unstyled display-block float-right-tablet delete-record">
|
<button type="button" 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">
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||||
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
|
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
|
||||||
</svg><span class="margin-left-05">Delete</span>
|
</svg>Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -74,10 +74,10 @@
|
||||||
</fieldset>
|
</fieldset>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<button type="button" class="usa-button usa-button--unstyled display-block margin-bottom-2" id="add-form">
|
<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">
|
<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>
|
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
|
||||||
</svg><span class="margin-left-05">Add new record</span>
|
</svg>Add new record
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
|
@ -52,20 +52,20 @@
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
<div class="tablet:grid-col-2">
|
<div class="tablet:grid-col-2">
|
||||||
<button type="button" class="usa-button usa-button--unstyled display-block delete-record margin-bottom-075">
|
<button type="button" class="usa-button usa-button--unstyled usa-button--with-icon delete-record margin-bottom-075 text-secondary line-height-sans-5">
|
||||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||||
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
|
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
|
||||||
</svg><span class="margin-left-05">Delete</span>
|
</svg>Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<button type="button" class="usa-button usa-button--unstyled display-block" id="add-form">
|
<button type="button" class="usa-button usa-button--unstyled usa-button--with-icon" id="add-form">
|
||||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
<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>
|
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
|
||||||
</svg><span class="margin-left-05">Add another name server</span>
|
</svg>Add another name server
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{% comment %} Work around USWDS' button margins to add some spacing between the submit and the 'add more'
|
{% comment %} Work around USWDS' button margins to add some spacing between the submit and the 'add more'
|
||||||
|
|
|
@ -42,7 +42,7 @@
|
||||||
|
|
||||||
{% input_with_errors form.state_territory %}
|
{% input_with_errors form.state_territory %}
|
||||||
|
|
||||||
{% with add_class="usa-input--small" %}
|
{% with add_class="usa-input--small" sublabel_text="Enter a zip code in the required format, like 12345 or 12345-6789." %}
|
||||||
{% input_with_errors form.zipcode %}
|
{% input_with_errors form.zipcode %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
|
|
|
@ -16,6 +16,26 @@
|
||||||
<use xlink:href="{%static 'img/sprite.svg'%}#arrow_back"></use>
|
<use xlink:href="{%static 'img/sprite.svg'%}#arrow_back"></use>
|
||||||
</svg><span class="margin-left-05">Previous step</span>
|
</svg><span class="margin-left-05">Previous step</span>
|
||||||
</a>
|
</a>
|
||||||
|
{% comment %}
|
||||||
|
TODO: uncomment in #2596
|
||||||
|
{% else %}
|
||||||
|
{% if portfolio %}
|
||||||
|
{% url 'domain-requests' as url_2 %}
|
||||||
|
<nav class="usa-breadcrumb padding-top-0" aria-label="Domain request breadcrumb">
|
||||||
|
<ol class="usa-breadcrumb__list">
|
||||||
|
<li class="usa-breadcrumb__list-item">
|
||||||
|
<a href="{{ url_2 }}" class="usa-breadcrumb__link"><span>Domain requests</span></a>
|
||||||
|
</li>
|
||||||
|
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
|
||||||
|
{% if requested_domain__name %}
|
||||||
|
<span>{{ requested_domain__name }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span>Start a new domain request</span>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
{% endif %} {% endcomment %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% block form_messages %}
|
{% block form_messages %}
|
||||||
|
|
|
@ -33,7 +33,7 @@
|
||||||
|
|
||||||
{% input_with_errors forms.0.state_territory %}
|
{% input_with_errors forms.0.state_territory %}
|
||||||
|
|
||||||
{% with add_class="usa-input--small" %}
|
{% with add_class="usa-input--small" sublabel_text="Enter a zip code in the required format, like 12345 or 12345-6789." %}
|
||||||
{% input_with_errors forms.0.zipcode %}
|
{% input_with_errors forms.0.zipcode %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
|
|
|
@ -40,7 +40,7 @@
|
||||||
<h2 class="margin-top-1">Organization contact {{ forloop.counter }}</h2>
|
<h2 class="margin-top-1">Organization contact {{ forloop.counter }}</h2>
|
||||||
</legend>
|
</legend>
|
||||||
|
|
||||||
<button type="button" class="usa-button usa-button--unstyled display-block float-right-tablet delete-record margin-bottom-2">
|
<button type="button" class="usa-button usa-button--unstyled display-block float-right-tablet delete-record margin-bottom-2 text-secondary line-height-sans-5">
|
||||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||||
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
|
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
|
||||||
</svg><span class="margin-left-05">Delete</span>
|
</svg><span class="margin-left-05">Delete</span>
|
||||||
|
|
|
@ -130,8 +130,8 @@
|
||||||
|
|
||||||
{% if step == Step.YOUR_CONTACT %}
|
{% if step == Step.YOUR_CONTACT %}
|
||||||
{% namespaced_url 'domain-request' step as domain_request_url %}
|
{% namespaced_url 'domain-request' step as domain_request_url %}
|
||||||
{% if domain_request.submitter is not None %}
|
{% if domain_request.creator is not None %}
|
||||||
{% with title=form_titles|get_item:step value=domain_request.submitter %}
|
{% with title=form_titles|get_item:step value=domain_request.creator %}
|
||||||
{% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url contact='true' %}
|
{% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url contact='true' %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
|
@ -8,15 +8,33 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<main id="main-content" class="grid-container">
|
<main id="main-content" class="grid-container">
|
||||||
<div class="grid-col desktop:grid-offset-2 desktop:grid-col-8">
|
<div class="grid-col desktop:grid-offset-2 desktop:grid-col-8">
|
||||||
<a href="{% url 'home' %}" class="breadcrumb__back">
|
{% comment %}
|
||||||
|
TODO: Uncomment in #2596
|
||||||
|
{% if portfolio %}
|
||||||
|
{% url 'domain-requests' as url %}
|
||||||
|
<nav class="usa-breadcrumb padding-top-0" aria-label="Domain request breadcrumb">
|
||||||
|
<ol class="usa-breadcrumb__list">
|
||||||
|
<li class="usa-breadcrumb__list-item">
|
||||||
|
<a href="{{ url }}" class="usa-breadcrumb__link"><span>Domain requests</span></a>
|
||||||
|
</li>
|
||||||
|
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
|
||||||
|
<span>{{ DomainRequest.requested_domain.name }}</span
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
{% else %}{% endcomment %}
|
||||||
|
{% url 'home' as url %}
|
||||||
|
<a href="{{ url }}" class="breadcrumb__back">
|
||||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
|
||||||
<use xlink:href="{% static 'img/sprite.svg' %}#arrow_back"></use>
|
<use xlink:href="{% static 'img/sprite.svg' %}#arrow_back"></use>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<p class="margin-left-05 margin-top-0 margin-bottom-0 line-height-sans-1">
|
<p class="margin-left-05 margin-top-0 margin-bottom-0 line-height-sans-1">
|
||||||
Back to manage your domains
|
Back to manage your domains
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</a>
|
||||||
|
{% comment %} {% endif %}{% endcomment %}
|
||||||
<h1>Domain request for {{ DomainRequest.requested_domain.name }}</h1>
|
<h1>Domain request for {{ DomainRequest.requested_domain.name }}</h1>
|
||||||
<div
|
<div
|
||||||
class="usa-summary-box dotgov-status-box margin-top-3 padding-left-2"
|
class="usa-summary-box dotgov-status-box margin-top-3 padding-left-2"
|
||||||
|
@ -109,8 +127,8 @@
|
||||||
{% include "includes/summary_item.html" with title='Purpose of your domain' value=DomainRequest.purpose heading_level=heading_level %}
|
{% include "includes/summary_item.html" with title='Purpose of your domain' value=DomainRequest.purpose heading_level=heading_level %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if DomainRequest.submitter and not has_profile_feature_flag %}
|
{% if DomainRequest.creator %}
|
||||||
{% include "includes/summary_item.html" with title='Your contact information' value=DomainRequest.submitter contact='true' heading_level=heading_level %}
|
{% include "includes/summary_item.html" with title='Your contact information' value=DomainRequest.creator contact='true' heading_level=heading_level %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if DomainRequest.other_contacts.all %}
|
{% if DomainRequest.other_contacts.all %}
|
||||||
|
|
|
@ -61,7 +61,7 @@
|
||||||
|
|
||||||
{% if portfolio %}
|
{% if portfolio %}
|
||||||
{% comment %} Only show this menu option if the user has the perms to do so {% endcomment %}
|
{% comment %} Only show this menu option if the user has the perms to do so {% endcomment %}
|
||||||
{% if has_domains_portfolio_permission and request.user.has_view_suborganization %}
|
{% if has_any_domains_portfolio_permission and has_view_suborganization_portfolio_permission %}
|
||||||
{% with url_name="domain-suborganization" %}
|
{% with url_name="domain-suborganization" %}
|
||||||
{% include "includes/domain_sidenav_item.html" with item_text="Suborganization" %}
|
{% include "includes/domain_sidenav_item.html" with item_text="Suborganization" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
@ -72,13 +72,6 @@
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if not has_profile_feature_flag %}
|
|
||||||
{# Conditionally display profile link in main nav #}
|
|
||||||
{% with url_name="domain-your-contact-information" %}
|
|
||||||
{% include "includes/domain_sidenav_item.html" with item_text="Your contact information" %}
|
|
||||||
{% endwith %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% with url_name="domain-security-email" %}
|
{% with url_name="domain-security-email" %}
|
||||||
{% include "includes/domain_sidenav_item.html" with item_text="Security email" %}
|
{% include "includes/domain_sidenav_item.html" with item_text="Security email" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
{% extends "domain_base.html" %}
|
{% extends "domain_base.html" %}
|
||||||
{% load static field_helpers%}
|
{% load static field_helpers%}
|
||||||
|
|
||||||
{% block title %}Suborganization{% endblock %}
|
{% block title %}Suborganization{% if suborganization_name %} | suborganization_name{% endif %} | {% endblock %}
|
||||||
|
|
||||||
{% block domain_content %}
|
{% block domain_content %}
|
||||||
{# this is right after the messages block in the parent template #}
|
{# this is right after the messages block in the parent template #}
|
||||||
|
@ -15,7 +15,7 @@
|
||||||
If you believe there is an error please contact <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
|
If you believe there is an error please contact <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% if has_domains_portfolio_permission and request.user.has_edit_suborganization %}
|
{% if has_any_domains_portfolio_permission and has_edit_suborganization_portfolio_permission %}
|
||||||
<form class="usa-form usa-form--large" method="post" novalidate id="form-container">
|
<form class="usa-form usa-form--large" method="post" novalidate id="form-container">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% input_with_errors form.sub_organization %}
|
{% input_with_errors form.sub_organization %}
|
||||||
|
|
|
@ -21,7 +21,7 @@
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{% if domain.permissions %}
|
{% if domain.permissions %}
|
||||||
<section class="section--outlined">
|
<section class="section-outlined">
|
||||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table">
|
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table">
|
||||||
<h2 class> Domain managers </h2>
|
<h2 class> Domain managers </h2>
|
||||||
<caption class="sr-only">Domain managers</caption>
|
<caption class="sr-only">Domain managers</caption>
|
||||||
|
@ -112,7 +112,7 @@
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if domain.invitations.exists %}
|
{% if domain.invitations.exists %}
|
||||||
<section class="section--outlined">
|
<section class="section-outlined">
|
||||||
<h2>Invitations</h2>
|
<h2>Invitations</h2>
|
||||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table">
|
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table">
|
||||||
<caption class="sr-only">Domain invitations</caption>
|
<caption class="sr-only">Domain invitations</caption>
|
||||||
|
|
|
@ -1,37 +0,0 @@
|
||||||
{% extends "domain_base.html" %}
|
|
||||||
{% load static field_helpers %}
|
|
||||||
|
|
||||||
{% block title %}Your contact information | {{ domain.name }} | {% endblock %}
|
|
||||||
|
|
||||||
{% block domain_content %}
|
|
||||||
{% include "includes/form_errors.html" with form=form %}
|
|
||||||
|
|
||||||
<h1>Your contact information</h1>
|
|
||||||
|
|
||||||
<p>If you’d like us to use a different name, email, or phone number you can make those changes below. <strong>Updating your contact information here will update the contact information for all domains in your account.</strong> Changing your information here won’t affect your Login.gov account information. The contact information you provide here won’t be made public and will only be used for the .gov program.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{% include "includes/required_fields.html" %}
|
|
||||||
|
|
||||||
<form class="usa-form usa-form--large" method="post" novalidate id="form-container">
|
|
||||||
{% csrf_token %}
|
|
||||||
|
|
||||||
{% input_with_errors form.first_name %}
|
|
||||||
|
|
||||||
{% input_with_errors form.middle_name %}
|
|
||||||
|
|
||||||
{% input_with_errors form.last_name %}
|
|
||||||
|
|
||||||
{% input_with_errors form.title %}
|
|
||||||
|
|
||||||
{% input_with_errors form.email %}
|
|
||||||
|
|
||||||
{% input_with_errors form.phone %}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="usa-button"
|
|
||||||
>Save</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{% endblock %} {# domain_content #}
|
|
|
@ -4,7 +4,7 @@ Hi, {{ recipient.first_name }}.
|
||||||
We've identified an action that you’ll need to complete before we continue reviewing your .gov domain request.
|
We've identified an action that you’ll need to complete before we continue reviewing your .gov domain request.
|
||||||
|
|
||||||
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
|
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
|
||||||
REQUEST RECEIVED ON: {{ domain_request.submission_date|date }}
|
REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
|
||||||
STATUS: Action needed
|
STATUS: Action needed
|
||||||
|
|
||||||
----------------------------------------------------------------
|
----------------------------------------------------------------
|
||||||
|
|
|
@ -4,7 +4,7 @@ Hi, {{ recipient.first_name }}.
|
||||||
We've identified an action that you’ll need to complete before we continue reviewing your .gov domain request.
|
We've identified an action that you’ll need to complete before we continue reviewing your .gov domain request.
|
||||||
|
|
||||||
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
|
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
|
||||||
REQUEST RECEIVED ON: {{ domain_request.submission_date|date }}
|
REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
|
||||||
STATUS: Action needed
|
STATUS: Action needed
|
||||||
|
|
||||||
----------------------------------------------------------------
|
----------------------------------------------------------------
|
||||||
|
|
|
@ -4,7 +4,7 @@ Hi, {{ recipient.first_name }}.
|
||||||
We've identified an action that you’ll need to complete before we continue reviewing your .gov domain request.
|
We've identified an action that you’ll need to complete before we continue reviewing your .gov domain request.
|
||||||
|
|
||||||
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
|
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
|
||||||
REQUEST RECEIVED ON: {{ domain_request.submission_date|date }}
|
REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
|
||||||
STATUS: Action needed
|
STATUS: Action needed
|
||||||
|
|
||||||
----------------------------------------------------------------
|
----------------------------------------------------------------
|
||||||
|
|
|
@ -4,7 +4,7 @@ Hi, {{ recipient.first_name }}.
|
||||||
We've identified an action that you’ll need to complete before we continue reviewing your .gov domain request.
|
We've identified an action that you’ll need to complete before we continue reviewing your .gov domain request.
|
||||||
|
|
||||||
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
|
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
|
||||||
REQUEST RECEIVED ON: {{ domain_request.submission_date|date }}
|
REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
|
||||||
STATUS: Action needed
|
STATUS: Action needed
|
||||||
|
|
||||||
----------------------------------------------------------------
|
----------------------------------------------------------------
|
||||||
|
|
|
@ -4,7 +4,7 @@ Hi, {{ recipient.first_name }}.
|
||||||
Your .gov domain request has been withdrawn and will not be reviewed by our team.
|
Your .gov domain request has been withdrawn and will not be reviewed by our team.
|
||||||
|
|
||||||
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
|
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
|
||||||
REQUEST RECEIVED ON: {{ domain_request.submission_date|date }}
|
REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
|
||||||
STATUS: Withdrawn
|
STATUS: Withdrawn
|
||||||
|
|
||||||
----------------------------------------------------------------
|
----------------------------------------------------------------
|
||||||
|
|
|
@ -4,7 +4,7 @@ Hi, {{ recipient.first_name }}.
|
||||||
Congratulations! Your .gov domain request has been approved.
|
Congratulations! Your .gov domain request has been approved.
|
||||||
|
|
||||||
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
|
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
|
||||||
REQUEST RECEIVED ON: {{ domain_request.submission_date|date }}
|
REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
|
||||||
STATUS: Approved
|
STATUS: Approved
|
||||||
|
|
||||||
You can manage your approved domain on the .gov registrar <https://manage.get.gov>.
|
You can manage your approved domain on the .gov registrar <https://manage.get.gov>.
|
||||||
|
|
|
@ -4,7 +4,7 @@ Hi, {{ recipient.first_name }}.
|
||||||
Your .gov domain request has been rejected.
|
Your .gov domain request has been rejected.
|
||||||
|
|
||||||
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
|
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
|
||||||
REQUEST RECEIVED ON: {{ domain_request.submission_date|date }}
|
REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
|
||||||
STATUS: Rejected
|
STATUS: Rejected
|
||||||
|
|
||||||
----------------------------------------------------------------
|
----------------------------------------------------------------
|
||||||
|
|
|
@ -4,7 +4,7 @@ Hi, {{ recipient.first_name }}.
|
||||||
We received your .gov domain request.
|
We received your .gov domain request.
|
||||||
|
|
||||||
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
|
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
|
||||||
REQUEST RECEIVED ON: {{ domain_request.submission_date|date }}
|
REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
|
||||||
STATUS: Submitted
|
STATUS: Submitted
|
||||||
|
|
||||||
----------------------------------------------------------------
|
----------------------------------------------------------------
|
||||||
|
|
|
@ -3,12 +3,15 @@
|
||||||
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
|
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
|
||||||
{% url 'get_domain_requests_json' as url %}
|
{% url 'get_domain_requests_json' as url %}
|
||||||
<span id="get_domain_requests_json_url" class="display-none">{{url}}</span>
|
<span id="get_domain_requests_json_url" class="display-none">{{url}}</span>
|
||||||
<section class="section--outlined domain-requests" id="domain-requests">
|
<section class="section-outlined domain-requests{% if portfolio %} section-outlined--border-base-light{% endif %}" id="domain-requests">
|
||||||
<div class="grid-row">
|
<div class="grid-row">
|
||||||
{% if not has_domain_requests_portfolio_permission %}
|
{% if not portfolio %}
|
||||||
<div class="mobile:grid-col-12 desktop:grid-col-6">
|
<div class="mobile:grid-col-12 desktop:grid-col-6">
|
||||||
<h2 id="domain-requests-header" class="flex-6">Domain requests</h2>
|
<h2 id="domain-requests-header" class="flex-6">Domain requests</h2>
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<!-- Embedding the portfolio value in a data attribute -->
|
||||||
|
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="mobile:grid-col-12 desktop:grid-col-6">
|
<div class="mobile:grid-col-12 desktop:grid-col-6">
|
||||||
<section aria-label="Domain requests search component" class="flex-6 margin-y-2">
|
<section aria-label="Domain requests search component" class="flex-6 margin-y-2">
|
||||||
|
@ -45,7 +48,10 @@
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th data-sortable="requested_domain__name" scope="col" role="columnheader">Domain name</th>
|
<th data-sortable="requested_domain__name" scope="col" role="columnheader">Domain name</th>
|
||||||
<th data-sortable="submission_date" scope="col" role="columnheader">Date submitted</th>
|
<th data-sortable="last_submitted_date" scope="col" role="columnheader">Submitted</th>
|
||||||
|
{% if portfolio %}
|
||||||
|
<th data-sortable="creator" scope="col" role="columnheader">Created by</th>
|
||||||
|
{% endif %}
|
||||||
<th data-sortable="status" scope="col" role="columnheader">Status</th>
|
<th data-sortable="status" scope="col" role="columnheader">Status</th>
|
||||||
<th scope="col" role="columnheader"><span class="usa-sr-only">Action</span></th>
|
<th scope="col" role="columnheader"><span class="usa-sr-only">Action</span></th>
|
||||||
<!-- AJAX will conditionally add a th for delete actions -->
|
<!-- AJAX will conditionally add a th for delete actions -->
|
||||||
|
|
|
@ -5,16 +5,15 @@
|
||||||
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
|
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
|
||||||
{% url 'get_domains_json' as url %}
|
{% url 'get_domains_json' as url %}
|
||||||
<span id="get_domains_json_url" class="display-none">{{url}}</span>
|
<span id="get_domains_json_url" class="display-none">{{url}}</span>
|
||||||
<section class="section--outlined domains{% if not portfolio %} margin-top-0{% endif %}" id="domains">
|
<section class="section-outlined domains margin-top-0{% if portfolio %} section-outlined--border-base-light{% endif %}" id="domains">
|
||||||
<div class="section--outlined__header margin-bottom-3 {% if not portfolio %} section--outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}">
|
<div class="section-outlined__header margin-bottom-3 {% if not portfolio %} section-outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}">
|
||||||
{% if not portfolio %}
|
{% if not portfolio %}
|
||||||
<h2 id="domains-header" class="display-inline-block">Domains</h2>
|
<h2 id="domains-header" class="display-inline-block">Domains</h2>
|
||||||
<span class="display-none" id="no-portfolio-js-flag"></span>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<!-- Embedding the portfolio value in a data attribute -->
|
<!-- Embedding the portfolio value in a data attribute -->
|
||||||
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>
|
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="section--outlined__search {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6{% endif %}">
|
<div class="section-outlined__search {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6{% endif %}">
|
||||||
<section aria-label="Domains search component" class="margin-top-2">
|
<section aria-label="Domains search component" class="margin-top-2">
|
||||||
<form class="usa-search usa-search--small" method="POST" role="search">
|
<form class="usa-search usa-search--small" method="POST" role="search">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
@ -43,10 +42,10 @@
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
{% if user_domain_count and user_domain_count > 0 %}
|
{% if user_domain_count and user_domain_count > 0 %}
|
||||||
<div class="section--outlined__utility-button mobile-lg:padding-right-105 {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}">
|
<div class="section-outlined__utility-button mobile-lg:padding-right-105 {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}">
|
||||||
<section aria-label="Domains report component" class="mobile-lg:margin-top-205">
|
<section aria-label="Domains report component" class="margin-top-205">
|
||||||
<a href="{% url 'export_data_type_user' %}" class="usa-button usa-button--unstyled" role="button">
|
<a href="{% url 'export_data_type_user' %}" class="usa-button usa-button--unstyled usa-button--with-icon" role="button">
|
||||||
<svg class="usa-icon usa-icon--big margin-right-neg-4px" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
<svg class="usa-icon usa-icon--big" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||||
</svg>Export as CSV
|
</svg>Export as CSV
|
||||||
</a>
|
</a>
|
||||||
|
@ -157,8 +156,8 @@
|
||||||
<th data-sortable="name" scope="col" role="columnheader">Domain name</th>
|
<th data-sortable="name" scope="col" role="columnheader">Domain name</th>
|
||||||
<th data-sortable="expiration_date" scope="col" role="columnheader">Expires</th>
|
<th data-sortable="expiration_date" scope="col" role="columnheader">Expires</th>
|
||||||
<th data-sortable="state_display" scope="col" role="columnheader">Status</th>
|
<th data-sortable="state_display" scope="col" role="columnheader">Status</th>
|
||||||
{% if portfolio and request.user.has_view_suborganization %}
|
{% if portfolio and has_view_suborganization_portfolio_permission %}
|
||||||
<th data-sortable="suborganization" scope="col" role="columnheader">Suborganization</th>
|
<th data-sortable="domain_info__sub_organization" scope="col" role="columnheader">Suborganization</th>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
|
|
|
@ -12,46 +12,6 @@
|
||||||
<button type="button" class="usa-nav__close">
|
<button type="button" class="usa-nav__close">
|
||||||
<img src="{%static 'img/usa-icons/close.svg'%}" role="img" alt="Close" />
|
<img src="{%static 'img/usa-icons/close.svg'%}" role="img" alt="Close" />
|
||||||
</button>
|
</button>
|
||||||
<ul class="usa-nav__primary usa-accordion">
|
|
||||||
<li class="usa-nav__primary-item">
|
|
||||||
{% if has_domains_portfolio_permission %}
|
|
||||||
{% url 'domains' as url %}
|
|
||||||
{%else %}
|
|
||||||
{% url 'no-portfolio-domains' as url %}
|
|
||||||
{% endif %}
|
|
||||||
<a href="{{ url }}" class="usa-nav-link{% if 'domain'|in_path:request.path %} usa-current{% endif %}">
|
|
||||||
Domains
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="usa-nav__primary-item">
|
|
||||||
<a href="#" class="usa-nav-link">
|
|
||||||
Domain groups
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
{% if has_domain_requests_portfolio_permission %}
|
|
||||||
<li class="usa-nav__primary-item">
|
|
||||||
{% url 'domain-requests' as url %}
|
|
||||||
<a href="{{ url }}" class="usa-nav-link{% if 'request'|in_path:request.path %} usa-current{% endif %}">
|
|
||||||
Domain requests
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
<li class="usa-nav__primary-item">
|
|
||||||
<a href="#" class="usa-nav-link">
|
|
||||||
Members
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="usa-nav__primary-item">
|
|
||||||
{% url 'organization' as url %}
|
|
||||||
<!-- Move the padding from the a to the span so that the descenders do not get cut off -->
|
|
||||||
<a href="{{ url }}" class="usa-nav-link padding-y-0">
|
|
||||||
<span class="ellipsis ellipsis--23 ellipsis--desktop-50 padding-y-1 desktop:padding-y-2">
|
|
||||||
{{ portfolio.organization_name }}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div class="usa-nav__secondary">
|
<div class="usa-nav__secondary">
|
||||||
<ul class="usa-nav__secondary-links">
|
<ul class="usa-nav__secondary-links">
|
||||||
<li class="usa-nav__secondary-item">
|
<li class="usa-nav__secondary-item">
|
||||||
|
@ -75,6 +35,82 @@
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
<ul class="usa-nav__primary usa-accordion">
|
||||||
|
<li class="usa-nav__primary-item">
|
||||||
|
{% if has_any_domains_portfolio_permission %}
|
||||||
|
{% url 'domains' as url %}
|
||||||
|
{% else %}
|
||||||
|
{% url 'no-portfolio-domains' as url %}
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ url }}" class="usa-nav-link{% if 'domain'|in_path:request.path %} usa-current{% endif %}">
|
||||||
|
Domains
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<!-- <li class="usa-nav__primary-item">
|
||||||
|
<a href="#" class="usa-nav-link">
|
||||||
|
Domain groups
|
||||||
|
</a>
|
||||||
|
</li> -->
|
||||||
|
|
||||||
|
{% if has_organization_requests_flag %}
|
||||||
|
<li class="usa-nav__primary-item">
|
||||||
|
<!-- user has one of the view permissions plus the edit permission, show the dropdown -->
|
||||||
|
{% if has_edit_request_portfolio_permission %}
|
||||||
|
{% url 'domain-requests' as url %}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="usa-accordion__button usa-nav__link{% if 'request'|in_path:request.path %} usa-current{% endif %}"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="basic-nav-section-two"
|
||||||
|
>
|
||||||
|
<span>Domain requests</span>
|
||||||
|
</button>
|
||||||
|
<ul id="basic-nav-section-two" class="usa-nav__submenu">
|
||||||
|
<li class="usa-nav__submenu-item">
|
||||||
|
<a href="{{ url }}"
|
||||||
|
><span>Domain requests</span></a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li class="usa-nav__submenu-item">
|
||||||
|
<a href="{% url 'domain-request:' %}"
|
||||||
|
><span>Start a new domain request</span></a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<!-- user has view but no edit permissions -->
|
||||||
|
{% elif has_any_requests_portfolio_permission %}
|
||||||
|
{% url 'domain-requests' as url %}
|
||||||
|
<a href="{{ url }}" class="usa-nav-link{% if 'request'|in_path:request.path %} usa-current{% endif %}">
|
||||||
|
Domain requests
|
||||||
|
</a>
|
||||||
|
<!-- user does not have permissions -->
|
||||||
|
{% else %}
|
||||||
|
{% url 'no-portfolio-requests' as url %}
|
||||||
|
<a href="{{ url }}" class="usa-nav-link{% if 'request'|in_path:request.path %} usa-current{% endif %}">
|
||||||
|
Domain requests
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if has_organization_members_flag %}
|
||||||
|
<li class="usa-nav__primary-item">
|
||||||
|
<a href="#" class="usa-nav-link">
|
||||||
|
Members
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<li class="usa-nav__primary-item">
|
||||||
|
{% url 'organization' as url %}
|
||||||
|
<!-- Move the padding from the a to the span so that the descenders do not get cut off -->
|
||||||
|
<a href="{{ url }}" class="usa-nav-link padding-y-0 {% if request.path == '/organization/' %} usa-current{% endif %}">
|
||||||
|
<span class="ellipsis ellipsis--23 ellipsis--desktop-50 padding-y-1 desktop:padding-y-2">
|
||||||
|
{{ portfolio.organization_name }}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
{% include "includes/form_errors.html" with form=form %}
|
{% include "includes/form_errors.html" with form=form %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<h1>Senior Official</h1>
|
<h1>Senior official</h1>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Your senior official is a person within your organization who can authorize domain requests.
|
Your senior official is a person within your organization who can authorize domain requests.
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{% load static field_helpers url_helpers custom_filters %}
|
{% load static field_helpers url_helpers custom_filters %}
|
||||||
|
|
||||||
<div id="{{field.name}}__edit-button-readonly" class="margin-top-2 margin-bottom-1 input-with-edit-button {% if not field.value and field.field.required %}input-with-edit-button__error{% endif %}">
|
<div id="{{field.name}}__edit-button-readonly" class="margin-top-2 margin-bottom-1 toggleable_input {% if not field.value and field.field.required %}toggleable_input__error{% endif %}">
|
||||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||||
{% if field.value or not field.field.required %}
|
{% if field.value or not field.field.required %}
|
||||||
<use xlink:href="{%static 'img/sprite.svg'%}#check_circle"></use>
|
<use xlink:href="{%static 'img/sprite.svg'%}#check_circle"></use>
|
||||||
|
@ -8,7 +8,7 @@
|
||||||
<use xlink:href="{%static 'img/sprite.svg'%}#error"></use>
|
<use xlink:href="{%static 'img/sprite.svg'%}#error"></use>
|
||||||
{%endif %}
|
{%endif %}
|
||||||
</svg>
|
</svg>
|
||||||
<div class="display-inline padding-left-05 margin-left-3 input-with-edit-button__readonly-field {% if not field.field.required %}text-base{% endif %}">
|
<div class="display-inline padding-left-05 margin-left-3 toggleable_input__readonly-field {% if not field.field.required %}text-base{% endif %}">
|
||||||
{% if field.name != "phone" %}
|
{% if field.name != "phone" %}
|
||||||
{{ field.value }}
|
{{ field.value }}
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block wrapper %}
|
{% block wrapper %}
|
||||||
<div id="wrapper" class="dashboard--portfolio">
|
<div id="wrapper" class="{% block wrapper_class %}dashboard--portfolio{% endblock %}">
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<main id="main-content" class="grid-container">
|
<main class="grid-container">
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
{# the entire logged in page goes here #}
|
{# the entire logged in page goes here #}
|
||||||
|
|
||||||
|
@ -26,10 +26,8 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock content%}
|
||||||
|
|
||||||
<div role="complementary">{% block complementary %}{% endblock %}</div>
|
<div role="complementary">{% block complementary %}{% endblock %}</div>
|
||||||
|
|
||||||
{% block content_bottom %}{% endblock %}
|
{% block content_bottom %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock wrapper %}
|
{% endblock wrapper %}
|
||||||
|
|
|
@ -4,7 +4,13 @@
|
||||||
|
|
||||||
{% block title %} Domains | {% endblock %}
|
{% block title %} Domains | {% endblock %}
|
||||||
|
|
||||||
|
{% block wrapper_class %}
|
||||||
|
{{ block.super }} dashboard--grey-1
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block portfolio_content %}
|
{% block portfolio_content %}
|
||||||
|
<div id="main-content">
|
||||||
<h1 id="domains-header">Domains</h1>
|
<h1 id="domains-header">Domains</h1>
|
||||||
{% include "includes/domains_table.html" with portfolio=portfolio user_domain_count=user_domain_count %}
|
{% include "includes/domains_table.html" with portfolio=portfolio user_domain_count=user_domain_count %}
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue