Merge branch 'main' into ky/add-christina-fixtures

This commit is contained in:
Kristina Yin 2024-04-24 11:37:18 -07:00 committed by GitHub
commit 123a9f9218
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
202 changed files with 13409 additions and 3487 deletions

View file

@ -22,7 +22,7 @@ body:
attributes:
label: Expected Behavior
description: "Please add a concise description of the behavior you would expect if this issue were not occurring"
placeholder: "Example: When submitting a new domain application, the request should be successful, OR if there is a problem with the user's application preventing submission, errors should be enumerated to the user"
placeholder: "Example: When submitting a new domain request, the request should be successful, OR if there is a problem with the user's application preventing submission, errors should be enumerated to the user"
validations:
required: true
- type: textarea
@ -33,8 +33,8 @@ body:
How can the issue be reliably reproduced? Feel free to include screenshots or other supporting artifacts
Example:
1. In the test environment, fill out the application for a new domain
2. Click the button to trigger a save/submit on the final page and complete the application
1. In the test environment, fill out the domain request for a new domain
2. Click the button to trigger a save/submit on the final page and complete the domain request
3. See the error
value: |
1.

View file

@ -0,0 +1,62 @@
---
name: Designer Onboarding
about: Onboarding steps for designers.
title: 'Designer Onboarding: GH_HANDLE'
labels: design, onboarding
assignees: katherineosos
---
# Designer Onboarding
- Onboardee: _GH handle of person being onboarded_
- Onboarder: _GH handle of onboard buddy_
Welcome to the .gov team! We're excited to have you here. Please follow the steps below to get everything set up. An onboarding buddy will help grant you access to all the tools and platforms we use. If you haven't been assigned an onboarding buddy, let us know in the #dotgov-disco channel.
## Onboardee
### Steps for the onboardee
- [ ] Read the [.gov onboarding doc](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit?usp=sharing) thoroughly.
- [ ] Accept an invitation to our Slack workspace and fill out your profile.
- [ ] For our Slack profile names, we usually follow the naming convention of `Firstname Lastname (Org, State, pronouns)`.
Example: Katherine Osos (Truss, MN, she/her)
- [ ] Make sure you have been added to the necessary [channels](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit#heading=h.li3lqcygw8ax) and familiarize yourself with them.
- [ ] FOR FEDERAL EMPLOYEES: Create a [Google workspace enterprise account](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit?pli=1#heading=h.xowzg9w0qlis) for CISA.
- [ ] Get access to our [Project Folder](https://drive.google.com/drive/folders/1qkoFQBlzXA7axi9CZ_OBhlJqRcqlNfpW?usp=drive_link) on Google Drive.
- [ ] Explore the folders and docs. Designers interface with the Product Design, Content, and Research folders most often.
- [ ] Make sure you have been invited to our [team meetings](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit#heading=h.emgtp2hgvabr) on Google Meet.
- [ ] Review our [design tools](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit?pli=1#heading=h.aprurp3z4gmv).
- [ ] Accept invitation to our [Figma workspace](https://www.figma.com/files/1287135731043703282/team/1299882813146449644).
- [ ] Follow the steps in [preparing for your sandbox](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit?pli=1#heading=h.au66hq5e0l8s) section on the onboarding doc.
- [ ] Schedule coffee chats with Design Lead, other designers, scrum master, and product manager ([team directory](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit?pli=1#heading=h.1vq6r8e52e9f)).
- [ ] Look over [recommended reading](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit?pli=1#heading=h.7ox9ee7v5q5n) and [relevant links](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit?pli=1#heading=h.d9pac1gc751t).
- [ ] Fill out your own Personal Operating Manual (POM) on the [team norming board](https://miro.com/app/board/uXjVMxMu1SA=/). OPTIONAL: Present it on the next team coffee meeting.
- [ ] FOR FEDERAL EMPLOYEES: Check in with your manager on the CISA onboarding process and getting your PIV card.
- [ ] FOR CONTRACTORS: Check with your manager on your EOD clearance process.
- [ ] OPTIONAL: Request access to our [analytics tools](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit?pli=1#heading=h.9q334hs4lbks).
- [ ] OPTIONAL: If you would like to manage content updates by running code locally rather than through GitHub, follow the steps in the [dev onboarding ticket](https://github.com/cisagov/getgov/issues/new?assignees=loganmeetsworld&labels=dev%2C+onboarding&template=developer-onboarding.md&title=Developer+Onboarding%3A+GH_HANDLE).
- [ ] If you would like to run code locally on a CISA laptop, reference the "[Setting up a developer environment on CISA laptops](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit#heading=h.2ctyba51d1zp)" section of the onboarding doc.
### Access
By following the steps, you should have access / been added to the following:
- [ ] The [.gov team](https://github.com/orgs/cisagov/teams/gov) under cisagov on GitHub
- [ ] [Slack](https://dhscisa.enterprise.slack.com), and have been added to the necessary channels
- [ ] [Google Drive Project folder](https://drive.google.com/drive/folders/1qkoFQBlzXA7axi9CZ_OBhlJqRcqlNfpW?usp=drive_link)
- [ ] [.gov team on Figma](https://www.figma.com/files/1287135731043703282/team/1299882813146449644) (as an editor if you have a license)
- [ ] [Team meetings](https://docs.google.com/document/d/1ukbpW4LSqkb_CCt8LWfpehP03qqfyYfvK3Fl21NaEq8/edit#heading=h.h62kzew057p1)
## Onboarder
### Steps for the onboarder
- [ ] Make sure onboardee is given an invitation to our Slack workspace
- [ ] Add onboardee to dotgov Slack channels
- [ ] Add onboardee to Project Folder as a "Contributor" (or if you do not have permissions, confirm with Cameron)
- If onboardee if a federal employee, add them as a "Content Manager" instead
- [ ] Coordinate with Cameron to grant onboardee a Figma license
- [ ] Add onboardee as an editor to the Figma team workspace, If they do not have a license (yet), you can add them as a viewer.
- [ ] Add onboardee to our team meetings
- [ ] If applicable, invite onboardee to Google Analytics, Google Search Console, and Search.gov console

View file

@ -19,7 +19,7 @@ body:
Example:
As an analyst
I want the ability to approve a domain application
I want the ability to approve a domain request
so that a request can be fulfilled and a new .gov domain can be provisioned
value: |
As a
@ -36,11 +36,11 @@ body:
Example:
- Application sends an email when analysts approve domain requests
- Domain application status is "approved"
- Domain request status is "approved"
Example ("given, when, then" format):
Given that I am an analyst who has finished reviewing a domain application
When I click to approve a domain application
Given that I am an analyst who has finished reviewing a domain request
When I click to approve a domain request
Then the domain provisioning process should be initiated, and the applicant should receive an email update.
validations:
required: true

View file

@ -31,3 +31,12 @@ jobs:
cf_space: ${{ secrets.CF_REPORT_ENV }}
cf_command: "run-task getgov-${{ secrets.CF_REPORT_ENV }} --command 'python manage.py generate_current_full_report' --name full"
- name: Generate and email domain-metadata.csv
uses: cloud-gov/cg-cli-tools@main
with:
cf_username: ${{ secrets[env.CF_USERNAME] }}
cf_password: ${{ secrets[env.CF_PASSWORD] }}
cf_org: cisa-dotgov
cf_space: ${{ secrets.CF_REPORT_ENV }}
cf_command: "run-task getgov-${{ secrets.CF_REPORT_ENV }} --command 'python manage.py email_current_metadata_report' --name metadata"

View file

@ -22,6 +22,8 @@ jobs:
|| startsWith(github.head_ref, 'es/')
|| startsWith(github.head_ref, 'ky/')
|| startsWith(github.head_ref, 'backup/')
|| startsWith(github.head_ref, 'meoward/')
|| startsWith(github.head_ref, 'bob/')
outputs:
environment: ${{ steps.var.outputs.environment}}
runs-on: "ubuntu-latest"

View file

@ -16,6 +16,8 @@ on:
- stable
- staging
- development
- bob
- meoward
- backup
- ky
- es

View file

@ -16,6 +16,8 @@ on:
options:
- staging
- development
- bob
- meoward
- backup
- ky
- es

View file

@ -1,41 +0,0 @@
# This workflow is to for testing a change to our deploy structure and will be deleted when testing finishes
name: Deploy Main
run-name: Run deploy for ${{ github.event.inputs.environment }}
on:
workflow_dispatch:
inputs:
environment:
type: choice
description: Which environment should we run deploy for?
options:
- development
- backup
- ky
- es
- nl
- rh
- za
- gd
- rb
- ko
- ab
- rjm
- dk
jobs:
deploy:
runs-on: ubuntu-latest
env:
CF_USERNAME: CF_${{ github.event.inputs.environment }}_USERNAME
CF_PASSWORD: CF_${{ github.event.inputs.environment }}_PASSWORD
steps:
- name: Deploy to cloud.gov sandbox
uses: cloud-gov/cg-cli-tools@main
with:
cf_username: ${{ secrets[env.CF_USERNAME] }}
cf_password: ${{ secrets[env.CF_PASSWORD] }}
cf_org: cisa-dotgov
cf_space: ${{ github.event.inputs.environment }}
cf_command: "push -f ops/manifests/manifest-${{ github.event.inputs.environment }}.yaml --strategy rolling"

View file

@ -4,7 +4,7 @@ The .gov domain helps U.S.-based government organizations gain public trust by b
## Onboarding
For new members of the @cisagov/dotgov team looking to contribute to the registrar, please open an [onboarding ticket](https://github.com/cisagov/getgov/issues/new?assignees=loganmeetsworld&labels=dev%2C+onboarding&template=developer-onboarding.md&title=Developer+Onboarding%3A+GH_HANDLE).
For new members of the @cisagov/dotgov team looking to contribute to the registrar, please open a [developer onboarding ticket](https://github.com/cisagov/getgov/issues/new?assignees=loganmeetsworld&labels=dev%2C+onboarding&template=developer-onboarding.md&title=Developer+Onboarding%3A+GH_HANDLE) or a [designer onboarding ticket](https://github.com/cisagov/getgov/issues/new?assignees=loganmeetsworld&labels=design%2C+onboarding&template=designer-onboarding.md&title=Designer+Onboarding%3A+GH_HANDLE).
## Code

View file

@ -1,4 +1,4 @@
# 15. Use Django-FSM library for domain application state
# 15. Use Django-FSM library for domain request state
Date: 2022-11-03
@ -10,12 +10,12 @@ Accepted
The applications that registrants submit for domains move through a variety of
different states or stages as they are processed by CISA staff. Traditionally,
there would be a “domain application” data model with a “status” field. The
there would be a “domain request” data model with a “status” field. The
rules in the application code that control what changes are permitted to the
statuses are called “domain logic”.
In a large piece of software, domain logic often spreads around the code base
because while handling a single request like “mark this application as
because while handling a single request like “mark this domain request as
approved”, requirements can be enforced at many different points during the
process.
@ -28,7 +28,7 @@ states and can change states (or “transition”) according to fixed rules.
We will use the django-fsm library to represent the status of our domain
registration applications as a finite state machine. The library allows us to
list what statuses are possible and describe which state transitions are
possible (e.g. Can an approved application ever be marked as “in-process”?).
possible (e.g. Can an approved domain request ever be marked as “in-process”?).
## Consequences

View file

@ -8,17 +8,17 @@ Accepted
## Context
The application form by which registrants apply for a .gov domain is presented over many pages.
The domain request form by which registrants apply for a .gov domain is presented over many pages.
Because we use server-side rendering, each page of the application is a unique HTML page with form fields surrounded by a form tag.
Because we use server-side rendering, each page of the domain request is a unique HTML page with form fields surrounded by a form tag.
Needing a way to coordinate state between the pages as a user fills in their application, we initially used the Form wizard from [django-formtools](https://django-formtools.readthedocs.io/en/latest/wizard.html). This eventually proved unworkable due to the lack of native ability to have more than one Django form object displayed on a single HTML page.
Needing a way to coordinate state between the pages as a user fills in their domain request, we initially used the Form wizard from [django-formtools](https://django-formtools.readthedocs.io/en/latest/wizard.html). This eventually proved unworkable due to the lack of native ability to have more than one Django form object displayed on a single HTML page.
However, a significant portion of the user workflow had already been coded, so it seemed prudent to port some of the formtools logic into our codebase.
## Decision
To maintain each page of the domain application as its own Django view class, inheriting common code from a parent class.
To maintain each page of the domain request as its own Django view class, inheriting common code from a parent class.
To maintain Django form and formset class in accordance with the Django models whose data they collect, independently of the pages on which they appear.

View file

@ -9,7 +9,7 @@ Approved
## Context
Our application needs to be able to send email to applicants for various
purposes including notifying them that their application has been submitted.
purposes including notifying them that their domain request has been submitted.
We need infrastructure for programmatically sending email. Amazon Web Services
(AWS) provides the Simple Email Service (SES) that can do that. CISA can
provide access to AWS SES for our application.

View file

@ -8,8 +8,7 @@ Accepted
## Context
CISA needs a way to perform administrative actions to manage the new get.gov application as well as the .gov domain
application requests submitted. Analysts need to be able to view, review, and approve domain applications. Other
CISA needs a way to perform administrative actions to manage the new get.gov application as well as the .gov domain requests submitted. Analysts need to be able to view, review, and approve domain requests. Other
dashboard views, reports, searches (with filters and sorting) are also highly desired.
## Decision

View file

@ -8,13 +8,13 @@ Accepted
## Context
Historically, the .gov vendor managed initial identity verification and organizational affiliation for users that request a .gov domain. With the new registrar, _any user with a valid Login.gov account_ will be able to make a request. As a primary layer of abuse prevention (i.e., DDoSing the registry program with illegitimate requests), we need a way to stop new users from submitting multiple domain requests before they are known to the .gov registry. In this case, "known" means they have at least one approved domain application or existing domain.
Historically, the .gov vendor managed initial identity verification and organizational affiliation for users that request a .gov domain. With the new registrar, _any user with a valid Login.gov account_ will be able to make a request. As a primary layer of abuse prevention (i.e., DDoSing the registry program with illegitimate requests), we need a way to stop new users from submitting multiple domain requests before they are known to the .gov registry. In this case, "known" means they have at least one approved domain request or existing domain.
## Considered Options
**Option 1:** Users will not be able to submit any new applications if they have 0 prior approved applications OR prior registered .gov domains. We would add a page alert informing the user that they cannot submit their application because they have an application in one of these "3" statuses (Submitted, In Review or Action Needed). They would still be able to create and edit new applications, just not submit them. The benefits of this option are that it would allow users to have multiple applications essentially in "draft mode" that are queued up and ready for submission after they are permitted to submit.
**Option 1:** Users will not be able to submit any new applications if they have 0 prior approved applications OR prior registered .gov domains. We would add a page alert informing the user that they cannot submit their application because they have a domain request in one of these "3" statuses (Submitted, In Review or Action Needed). They would still be able to create and edit new applications, just not submit them. The benefits of this option are that it would allow users to have multiple applications essentially in "draft mode" that are queued up and ready for submission after they are permitted to submit.
**Option 2:** Users will not be able to submit any new applications if they have 0 prior approved applications OR prior registered .gov domains. Additionally, we would remove the ability to edit any application with the started/withdrawn/rejected status, or start a new application. The benefit of this option is that a user would not be able to begin an action (submitting an application) that they are not allowed to complete.
**Option 2:** Users will not be able to submit any new applications if they have 0 prior approved applications OR prior registered .gov domains. Additionally, we would remove the ability to edit any application with the started/withdrawn/rejected status, or start a new application. The benefit of this option is that a user would not be able to begin an action (submitting a domain request) that they are not allowed to complete.
## Decision

View file

@ -18,13 +18,13 @@ Deployment_Node(aws, "AWS GovCloud", "Amazon Web Services Region") {
Deployment_Node(organization, "get.gov organization") {
Deployment_Node(sandbox, "sandbox space") {
System_Boundary(dashboard_sandbox, "get.gov registrar") {
Container(getgov_app_sandbox, "Registrar Application", "Python, Django", "Delivers static HTML/CSS and forms")
Container(getgov_app_sandbox, "Registrar Domain Request", "Python, Django", "Delivers static HTML/CSS and forms")
ContainerDb(dashboard_db_sandbox, "sandbox PostgreSQL Database", "AWS RDS", "Stores agency information and reports")
}
}
Deployment_Node(stable, "stable space") {
System_Boundary(dashboard_stable, "get.gov registrar") {
Container(getgov_app_stable, "Registrar Application", "Python, Django", "Delivers static HTML/CSS and forms")
Container(getgov_app_stable, "Registrar Domain Request", "Python, Django", "Delivers static HTML/CSS and forms")
ContainerDb(dashboard_db_stable, "stable PostgreSQL Database", "AWS RDS", "Stores agency information and reports")
}
}

View file

@ -3,9 +3,9 @@
This diagram connects the data models along with various workflow stages.
1. The applicant starts the process at `/request` interacting with the
`DomainApplication` object.
`DomainRequest` object.
2. The analyst approves the application using the `DomainApplication`'s
2. The analyst approves the domain request using the `DomainRequest`'s
`approve()` method which creates many related objects: `UserDomainRole`,
`Domain`, and `DomainInformation`.
@ -36,8 +36,8 @@ $ docker run -v $(pwd):$(pwd) -w $(pwd) -it plantuml/plantuml -tsvg model_timeli
allowmixing
left to right direction
class DomainApplication {
Application for a domain
class DomainRequest {
Request for a domain
--
creator (User)
investigator (User)
@ -66,7 +66,7 @@ note left of User
<b>username</b> is the Login UUID
end note
DomainApplication -l- User : creator, investigator
DomainRequest -l- User : creator, investigator
class Contact {
Contact info for a person
@ -80,7 +80,7 @@ class Contact {
--
}
DomainApplication *-r-* Contact : authorizing_official, submitter, other_contacts
DomainRequest *-r-* Contact : authorizing_official, submitter, other_contacts
class DraftDomain {
Requested domain
@ -89,7 +89,7 @@ class DraftDomain {
--
}
DomainApplication -l- DraftDomain : requested_domain
DomainRequest -l- DraftDomain : requested_domain
class Domain {
Approved domain
@ -99,21 +99,21 @@ class Domain {
<b>EPP methods</b>
}
DomainApplication .right[#blue].> Domain : approve()
DomainRequest .right[#blue].> Domain : approve()
class DomainInformation {
Registrar information on a domain
--
domain (Domain)
domain_application (DomainApplication)
domain_request (DomainRequest)
security_email
--
Request information...
}
DomainInformation -- Domain
DomainInformation -- DomainApplication
DomainApplication .[#blue].> DomainInformation : approve()
DomainInformation -- DomainRequest
DomainRequest .[#blue].> DomainInformation : approve()
class UserDomainRole {
Permissions
@ -125,7 +125,7 @@ class UserDomainRole {
}
UserDomainRole -- User
UserDomainRole -- Domain
DomainApplication .[#blue].> UserDomainRole : approve()
DomainRequest .[#blue].> UserDomainRole : approve()
class DomainInvitation {
Email invitations sent
@ -139,10 +139,10 @@ DomainInvitation -- Domain
DomainInvitation .[#green].> UserDomainRole : User.on_each_login()
actor applicant #Red
applicant -d-> DomainApplication : **/request**
applicant -d-> DomainRequest : **/request**
actor analyst #Blue
analyst -[#blue]-> DomainApplication : **approve()**
analyst -[#blue]-> DomainRequest : **approve()**
actor user1 #Green
user1 -[#green]-> Domain : **/domain/<id>/nameservers**

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Before After
Before After

View file

@ -39,8 +39,8 @@ class "registrar.Contact <Registrar>" as registrar.Contact #d6f4e9 {
registrar.Contact -- registrar.User
class "registrar.DomainApplication <Registrar>" as registrar.DomainApplication #d6f4e9 {
domain application
class "registrar.DomainRequest <Registrar>" as registrar.DomainRequest #d6f4e9 {
domain request
--
+ id (BigAutoField)
+ created_at (DateTimeField)
@ -77,15 +77,15 @@ class "registrar.DomainApplication <Registrar>" as registrar.DomainApplication #
# other_contacts (ManyToManyField)
--
}
registrar.DomainApplication -- registrar.User
registrar.DomainApplication -- registrar.User
registrar.DomainApplication -- registrar.Contact
registrar.DomainApplication -- registrar.DraftDomain
registrar.DomainApplication -- registrar.Domain
registrar.DomainApplication -- registrar.Contact
registrar.DomainApplication *--* registrar.Website
registrar.DomainApplication *--* registrar.Website
registrar.DomainApplication *--* registrar.Contact
registrar.DomainRequest -- registrar.User
registrar.DomainRequest -- registrar.User
registrar.DomainRequest -- registrar.Contact
registrar.DomainRequest -- registrar.DraftDomain
registrar.DomainRequest -- registrar.Domain
registrar.DomainRequest -- registrar.Contact
registrar.DomainRequest *--* registrar.Website
registrar.DomainRequest *--* registrar.Website
registrar.DomainRequest *--* registrar.Contact
class "registrar.DomainInformation <Registrar>" as registrar.DomainInformation #d6f4e9 {
@ -95,7 +95,7 @@ class "registrar.DomainInformation <Registrar>" as registrar.DomainInformation #
+ created_at (DateTimeField)
+ updated_at (DateTimeField)
~ creator (ForeignKey)
~ domain_application (OneToOneField)
~ domain_request (OneToOneField)
+ organization_type (CharField)
+ federally_recognized_tribe (BooleanField)
+ state_recognized_tribe (BooleanField)
@ -124,7 +124,7 @@ class "registrar.DomainInformation <Registrar>" as registrar.DomainInformation #
--
}
registrar.DomainInformation -- registrar.User
registrar.DomainInformation -- registrar.DomainApplication
registrar.DomainInformation -- registrar.DomainRequest
registrar.DomainInformation -- registrar.Contact
registrar.DomainInformation -- registrar.Domain
registrar.DomainInformation -- registrar.Contact

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Before After
Before After

View file

@ -73,14 +73,23 @@ cp ./.env-example .env
Get the secrets from Cloud.gov by running `cf env getgov-YOURSANDBOX`. More information is available in [rotate_application_secrets.md](../operations/runbooks/rotate_application_secrets.md).
## Adding user to /admin
## Getting access to /admin on all development sandboxes (also referred to as "adding to fixtures")
The endpoint /admin can be used to view and manage site content, including but not limited to user information and the list of current applications in the database. To be able to view and use /admin locally:
The endpoint /admin can be used to view and manage site content, including but not limited to user information and the list of current applications in the database. However, access to this is limited to analysts and full-access users with regular domain requestors and domain managers not being able to see this page.
While on production (the sandbox referred to as `stable`), an existing analyst or full-access user typically grants access /admin as part of onboarding ([see these instructions](../django-admin/roles.md)), doing this for all development sandboxes is very time consuming. Instead, to get access to /admin on all development sandboxes and when developing code locally, refer to the following sections depending on what level of user access you desire.
### Adding full-access user to /admin
To get access to /admin on every non-production sandbox and to use /admin in local development, do the following:
1. Login via login.gov
2. Go to the home page and make sure you can see the part where you can submit an application
3. Go to /admin and it will tell you that UUID is not authorized, copy that UUID for use in 4
4. in src/registrar/fixtures_users.py add to the `ADMINS` list in that file by adding your UUID as your username along with your first and last name. See below:
2. Go to the home page and make sure you can see the part where you can submit a domain request
3. Go to /admin and it will tell you that your UUID is not authorized (it shows a very long string, this is your UUID). Copy that UUID for use in 4.
4. (Designers) Message in #getgov-dev that you need access to admin as a `superuser` and send them this UUID along with your desired email address. Please see the "Adding an Analyst to /admin" section below to complete similiar steps if you also desire an `analyst` user account. Engineers will handle the remaining steps for designers, stop here.
(Engineers) In src/registrar/fixtures_users.py add to the `ADMINS` list in that file by adding your UUID as your username along with your first and last name. See below:
```
ADMINS = [
@ -93,16 +102,18 @@ The endpoint /admin can be used to view and manage site content, including but n
]
```
5. In the browser, navigate to /admin. To verify that all is working correctly, under "domain applications" you should see fake domains with various fake statuses.
6. Add an optional email key/value pair
5. (Engineers) In the browser, navigate to /admin. To verify that all is working correctly, under "domain requests" you should see fake domains with various fake statuses.
6. (Engineers) Add an optional email key/value pair
### Adding an Analyst to /admin
### Adding an analyst-level user to /admin
Analysts are a variant of the admin role with limited permissions. The process for adding an Analyst is much the same as adding an admin:
1. Login via login.gov (if you already exist as an admin, you will need to create a separate login.gov account for this: i.e. first.last+1@email.com)
2. Go to the home page and make sure you can see the part where you can submit an application
2. Go to the home page and make sure you can see the part where you can submit a domain request
3. Go to /admin and it will tell you that UUID is not authorized, copy that UUID for use in 4 (this will be a different UUID than the one obtained from creating an admin)
4. in src/registrar/fixtures_users.py add to the `STAFF` list in that file by adding your UUID as your username along with your first and last name. See below:
4. (Designers) Message in #getgov-dev that you need access to admin as a `superuser` and send them this UUID along with your desired email address. Engineers will handle the remaining steps for designers, stop here.
5. (Engineers) In src/registrar/fixtures_users.py add to the `STAFF` list in that file by adding your UUID as your username along with your first and last name. See below:
```
STAFF = [
@ -115,10 +126,11 @@ Analysts are a variant of the admin role with limited permissions. The process f
]
```
5. In the browser, navigate to /admin. To verify that all is working correctly, verify that you can only see a sub-section of the modules and some are set to view-only.
6. Add an optional email key/value pair
5. (Engineers) In the browser, navigate to /admin. To verify that all is working correctly, verify that you can only see a sub-section of the modules and some are set to view-only.
6. (Engineers) Add an optional email key/value pair
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 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.
@ -145,7 +157,7 @@ You can change the logging verbosity, if needed. Do a web search for "django log
## Mock data
[load.py](../../src/registrar/management/commands/load.py) called from docker-compose (locally) and reset-db.yml (upper) loads the fixtures from [fixtures_user.py](../../src/registrar/fixtures_users.py) and [fixtures_applications.py](../../src/registrar/fixtures_applications.py), giving you some test data to play with while developing.
[load.py](../../src/registrar/management/commands/load.py) called from docker-compose (locally) and reset-db.yml (upper) loads the fixtures from [fixtures_user.py](../../src/registrar/fixtures_users.py) and [fixtures_domain_requests.py](../../src/registrar/fixtures_domain_requests.py), giving you some test data to play with while developing.
See the [database-access README](./database-access.md) for information on how to pull data to update these fixtures.
@ -204,6 +216,16 @@ from .common import less_console_noise
# <test code goes here>
```
Or alternatively, if you prefer using a decorator, just use:
```python
from .common import less_console_noise_decorator
@less_console_noise_decorator
def some_function():
# <test code goes here>
```
### Accessibility Testing in the browser
We use the [ANDI](https://www.ssa.gov/accessibility/andi/help/install.html) browser extension
@ -240,7 +262,7 @@ type
docker-compose run owasp
```
# Images, stylesheets, and JavaScript
## Images, stylesheets, and JavaScript
We use the U.S. Web Design System (USWDS) for styling our applications.
@ -252,7 +274,7 @@ Assets are stored in `registrar/assets` during development and served from `regi
We utilize the [uswds-compile tool](https://designsystem.digital.gov/documentation/getting-started/developers/phase-two-compile/) from USWDS to compile and package USWDS assets.
## Making and viewing style changes
### Making and viewing style changes
When you run `docker-compose up` the `node` service in the container will begin to watch for changes in the `registrar/assets` folder, and will recompile once any changes are made.
@ -263,7 +285,11 @@ Within the `registrar/assets` folder, the `_theme` folder contains three files i
You can also compile the **Sass** at any time using `npx gulp compile`. Similarly, you can copy over **other static assets** (images and javascript files), using `npx gulp copyAssets`.
## Upgrading USWDS and other JavaScript packages
### CSS class naming conventions
We use the [CSS Block Element Modifier (BEM)](https://getbem.com/naming/) naming convention for our custom classes. This is in line with how USWDS [approaches](https://designsystem.digital.gov/whats-new/updates/2019/04/08/introducing-uswds-2-0/) their CSS class architecture and helps keep our code cohesive and readable.
### Upgrading USWDS and other JavaScript packages
Version numbers can be manually controlled in `package.json`. Edit that, if desired.
@ -330,11 +356,12 @@ To associate a S3 instance to your sandbox, follow these steps:
3. Click `Services` on the application nav bar
4. Add a new service (plus symbol)
5. Click `Marketplace Service`
6. On the `Select the service` dropdown, select `s3`
7. Under the dropdown on `Select Plan`, select `basic-sandbox`
8. Under `Service Instance` enter `getgov-s3` for the name
6. For Space, put in your sandbox initials
7. On the `Select the service` dropdown, select `s3`
8. Under the dropdown on `Select Plan`, select `basic-sandbox`
9. Under `Service Instance` enter `getgov-s3` for the name and leave the other fields empty
See this [resource](https://cloud.gov/docs/services/s3/) for information on associating an S3 instance with your sandbox through the CLI.
See this [resource](https://cloud.gov/docs/services/s3/) for information on associating an S3 instance with your sandbox through the CLI.
### Testing your S3 instance locally
To test the S3 bucket associated with your sandbox, you will need to add four additional variables to your `.env` file. These are as follows:

View file

@ -56,6 +56,13 @@ cf ssh getgov-ENVIRONMENT
./manage.py dumpdata
```
## Access certain table in the database
1. `cf connect-to-service getgov-ENVIRONMENT getgov-ENVIRONMENT-database` gets you into whichever environments database you'd like
2. `\c [table name here that starts cgaws...etc];` connects to the [cgaws...etc] table
3. `\dt` retrieves information about that table and displays it
4. Make sure the table you are looking for exists. For this example, we are looking for `django_migrations`
5. Run `SELECT * FROM django_migrations;` to see everything that's in it!
## Dropping and re-creating the database
For your sandbox environment, it might be necessary to start the database over from scratch.

View file

@ -24,8 +24,8 @@
## Status Change Approved
- Starting Location: Django Admin
- Workflow: Analyst Admin
- Workflow Step: Click "Domain applications" -> Click an application 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 an application, then set the status to "approved". This will send you an email.
- 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.
- [Email Content](https://github.com/cisagov/manage.get.gov/blob/main/src/registrar/templates/emails/status_change_approved.txt)
### Status Change Approved Subject
@ -35,8 +35,8 @@
## Status Change Rejected
- Starting Location: Django Admin
- Workflow: Analyst Admin
- Workflow Step: Click "Domain applications" -> Click an application 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 an application, 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.
- 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.
- [Email Content](https://github.com/cisagov/manage.get.gov/blob/main/src/registrar/templates/emails/status_change_rejected.txt)
### Status Change Rejected Subject

View file

@ -121,3 +121,19 @@ https://cisa-corp.slack.com/archives/C05BGB4L5NF/p1697810600723069
2. `./manage.py migrate model_name_here file_name_WITH_create` (run the last data creation migration AND ONLY THAT ONE)
3. `./manage.py migrate --fake model_name_here most_recent_file_name` (fake migrate the last migration in the migration list)
4. `./manage.py load` (rerun fixtures)
### Scenario 9: Inconsistent Migration History
If you see `django.db.migrations.exceptions.InconsistentMigrationHistory` error, or when you run `./manage.py showmigrations` it looks like:
[x] 0056_example_migration
[ ] 0057_other_migration
[x] 0058_some_other_migration
1. Go to [database-access.md](../database-access.md#access-certain-table-in-the-database) to see the commands on how to access a certain table in the database.
2. In this case, we want to remove the migration "history" from the `django_migrations` table
3. Once you are in the `cgaws...` table, select the `django_migrations` table with the command `SELECT * FROM django_migrations;`
4. Find the id of the "history" you want to delete. This will be the one in the far left column. For this example, let's pretend the id is 101.
5. Run `DELETE FROM django_migrations WHERE id=101;` where 101 is an example id as seen above.
6. Go to your shell and run `./manage.py showmigrations` to make sure your migrations are now back to the right state. Most likely you will see several unapplied migrations.
7. If you still have unapplied migrations, run `./manage.py migrate`. If an error occurs saying one has already been applied, fake that particular migration `./manage.py migrate --fake model_name_here migration_number` and then run the normal `./manage.py migrate` command to then apply those migrations that come after the one that threw the error.

View file

@ -19,6 +19,18 @@ role or set of permissions that they have. We use a `UserDomainRole`
`User.domains` many-to-many relationship that works through the
`UserDomainRole` link table.
## Migrating changes to Analyst Permissions model
Analysts are allowed a certain set of read/write registrar permissions.
Setting user permissions requires a migration to change the UserGroup
and Permission models, which requires us to manually make a migration
file for user permission changes.
To update analyst permissions do the following:
1. Make desired changes to analyst group permissions in user_group.py.
2. Follow the steps in the migration file0037_create_groups_v01.py to
create a duplicate migration for the updated user group permissions.
3. To migrate locally, run docker-compose up. To migrate on a sandbox,
push the new migration onto your sandbox before migrating.
## Permission decorator
The Django objects that need to be permission controlled are various views.

View file

@ -9,7 +9,7 @@ our `user_group` model and run in a migration.
For more details, refer to the [user group model](../../src/registrar/models/user_group.py).
## Adding a user as analyst or granting full access via django-admin
## Adding a user as analyst or granting full access via django-admin (/admin)
If a new team member has joined, then they will need to be granted analyst (`cisa_analysts_group`) or full access (`full_access_group`) permissions in order to view the admin pages. These admin pages are the ones found at manage.get.gov/admin.
To do this, do the following:
@ -21,7 +21,7 @@ To do this, do the following:
5. (Optional) If the user needs access to django admin (such as an analyst), then you will also need to make sure "Staff Status" is checked. This can be found in the same `User Permissions` section right below the checkbox for `Active`.
6. Click `Save` to apply all changes.
## Removing a user group permission via django-admin
## Removing a user group permission via django-admin (/admin)
If an employee was given the wrong permissions or has had a change in roles that subsequently requires a permission change, then their permissions should be updated in django-admin. Much like in the previous section you can accomplish this by doing the following:

View file

@ -114,7 +114,7 @@ that can be used for specific tasks.
## Cloud.gov dashboard
At <https://dashboard.fr.cloud.gov/applications> there is a list for all of the
applications that a Cloud.gov user has access to. Clicking on an application
applications that a Cloud.gov user has access to. Clicking on a domain request
goes to a screen for that individual application, e.g.
<https://dashboard.fr.cloud.gov/applications/2oBn9LBurIXUNpfmtZCQTCHnxUM/53b88024-1492-46aa-8fb6-1429bdb35f95/summary>.
On that page is a left-hand link for "Log Stream" e.g.

View file

@ -586,3 +586,59 @@ Example: `cf ssh getgov-za`
| | Parameter | Description |
|:-:|:-------------------------- |:----------------------------------------------------------------------------|
| 1 | **debug** | Increases logging detail. Defaults to False. |
## Populate Organization type
This section outlines how to run the `populate_organization_type` script.
The script is used to update the organization_type field on DomainRequest and DomainInformation when it is None.
That data are synthesized from the generic_org_type field and the is_election_board field by concatenating " - Elections" on the end of generic_org_type string if is_elections_board is True.
### Running on sandboxes
#### Step 1: Login to CloudFoundry
```cf login -a api.fr.cloud.gov --sso```
#### Step 2: Get the domain_election_board file
The latest domain_election_board csv can be found [here](https://drive.google.com/file/d/1aDeCqwHmBnXBl2arvoFCN0INoZmsEGsQ/view).
After downloading this file, place it in `src/migrationdata`
#### Step 2: Upload the domain_election_board file to your sandbox
Follow [Step 1: Transfer data to sandboxes](#step-1-transfer-data-to-sandboxes) and [Step 2: Transfer uploaded files to the getgov directory](#step-2-transfer-uploaded-files-to-the-getgov-directory) from the [Set Up Migrations on Sandbox](#set-up-migrations-on-sandbox) portion of this doc.
#### 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_organization_type {domain_election_board_filename}```
- The domain_election_board_filename file must adhere to this format:
- example.gov\
example2.gov\
example3.gov
Example:
`./manage.py populate_organization_type migrationdata/election-domains.csv`
### Running locally
#### Step 1: Get the domain_election_board file
The latest domain_election_board csv can be found [here](https://drive.google.com/file/d/1aDeCqwHmBnXBl2arvoFCN0INoZmsEGsQ/view).
After downloading this file, place it in `src/migrationdata`
#### Step 2: Running the script
```docker-compose exec app ./manage.py populate_organization_type {domain_election_board_filename}```
Example (assuming that this is being ran from src/):
`docker-compose exec app ./manage.py populate_organization_type migrationdata/election-domains.csv`
### Required parameters
| | Parameter | Description |
|:-:|:------------------------------------|:-------------------------------------------------------------------|
| 1 | **domain_election_board_filename** | A file containing every domain that is an election office.

View file

@ -16,12 +16,14 @@ The following set of rules should be followed while an incident is in progress.
- If downtime occurs outside of working hours, team members who are off for the day may still be pinged and called but are not required to join if unavailable to do so.
- Uncomment the [banner on get.gov](https://github.com/cisagov/get.gov/blob/0365d3d34b041cc9353497b2b5f81b6ab7fe75a9/_includes/header.html#L9), so it is transparent to users that we know about the issue on manage.get.gov.
- Designers or Developers should be able to make this change; if designers are online and can help with this task, that will allow developers to focus on fixing the bug.
- If the issue persists for three hours or more, follow the [instructions for enabling/disabling a redirect to get.gov](https://docs.google.com/document/d/1PiWXpjBzbiKsSYqEo9Rkl72HMytMp7zTte9CI-vvwYw/edit).
## Post Incident
The following checklist should be followed after the site is back up and running.
- [ ] Message in #dotgov-announce with an @here saying the issue is resolved
- [ ] Message in #dotgov-announce with an @here saying the issue is resolved.
- [ ] If the redirect was used, refer to the [instructions for enabling/disabling a redirect to get.gov](https://docs.google.com/document/d/1PiWXpjBzbiKsSYqEo9Rkl72HMytMp7zTte9CI-vvwYw/edit) to turn off this redirect. Double-check in the browser that this redirect is no longer occurring (the change may take a few minutes to take full effect).
- [ ] Remove the [banner on get.gov](https://github.com/cisagov/get.gov/blob/0365d3d34b041cc9353497b2b5f81b6ab7fe75a9/_includes/header.html#L9) by commenting it out.
- [ ] Write up what happened and when; if the cause is already known, write that as well. This is a draft for internal communications and not for any public facing site and can be as simple as using bullet points.
- [ ] If the cause is not known yet, developers should investigate the issue as the highest priority task.

View file

@ -117,3 +117,11 @@ You'll need to give the new certificate to the registry vendor _before_ rotating
## REGISTRY_HOSTNAME
This is the hostname at which the registry can be found.
## SECRET_METADATA_KEY
This is the passphrase for the zipped and encrypted metadata email that is sent out daily. Reach out to product team members or leads with access to security passwords if the passcode is needed.
To change the password, use a password generator to generate a password, then update the user credentials per the above instructions. Be sure to update the [KBDX](https://docs.google.com/document/d/1_BbJmjYZNYLNh4jJPPnUEG9tFCzJrOc0nMrZrnSKKyw) file in Google Drive with this password change.

View file

@ -2,8 +2,8 @@
========================
1. Check the [Pipfile](../../../src/Pipfile) for pinned dependencies and manually adjust the version numbers
2. Run
2. Run `docker-compose stop` to spin down the current containers and images so we can start afresh
3. Run
cd src
docker-compose run app bash -c "pipenv lock && pipenv requirements > requirements.txt"
@ -13,9 +13,6 @@
It is necessary to use `bash -c` because `run pipenv requirements` will not recognize that it is running non-interactively and will include garbage formatting characters.
The requirements.txt is used by Cloud.gov. It is needed to work around a bug in the CloudFoundry buildpack version of Pipenv that breaks on installing from a git repository.
3. Change geventconnpool back to what it was originally within the Pipfile.lock and requirements.txt.
This is done by either saving what it was originally or opening a PR and using that as a reference to undo changes to any mention of geventconnpool.
Geventconnpool, when set as a requirement without the reference portion, is defaulting to get a commit from 2014 which then breaks the code, as we want the newest version from them.
4. (optional) Run `docker-compose stop` and `docker-compose build` to build a new image for local development with the updated dependencies.
4. Run `docker-compose build` to build a new image for local development with the updated dependencies.
The reason for de-coupling the `build` and `lock` steps is to increase consistency between builds--a run of `build` will always get exactly the dependencies listed in `Pipfile.lock`, nothing more, nothing less.

View file

@ -4,7 +4,7 @@ applications:
buildpacks:
- python_buildpack
path: ../../src
instances: 2
instances: 1
memory: 512M
stack: cflinuxfs4
timeout: 180

View file

@ -0,0 +1,32 @@
---
applications:
- name: getgov-bob
buildpacks:
- python_buildpack
path: ../../src
instances: 1
memory: 512M
stack: cflinuxfs4
timeout: 180
command: ./run.sh
health-check-type: http
health-check-http-endpoint: /health
health-check-invocation-timeout: 40
env:
# Send stdout and stderr straight to the terminal without buffering
PYTHONUNBUFFERED: yup
# Tell Django where to find its configuration
DJANGO_SETTINGS_MODULE: registrar.config.settings
# Tell Django where it is being hosted
DJANGO_BASE_URL: https://getgov-bob.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# default public site location
GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments
IS_PRODUCTION: False
routes:
- route: getgov-bob.app.cloud.gov
services:
- getgov-credentials
- getgov-bob-database

View file

@ -0,0 +1,32 @@
---
applications:
- name: getgov-meoward
buildpacks:
- python_buildpack
path: ../../src
instances: 1
memory: 512M
stack: cflinuxfs4
timeout: 180
command: ./run.sh
health-check-type: http
health-check-http-endpoint: /health
health-check-invocation-timeout: 40
env:
# Send stdout and stderr straight to the terminal without buffering
PYTHONUNBUFFERED: yup
# Tell Django where to find its configuration
DJANGO_SETTINGS_MODULE: registrar.config.settings
# Tell Django where it is being hosted
DJANGO_BASE_URL: https://getgov-meoward.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
# default public site location
GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments
IS_PRODUCTION: False
routes:
- route: getgov-meoward.app.cloud.gov
services:
- getgov-credentials
- getgov-meoward-database

View file

@ -5,7 +5,7 @@ applications:
- python_buildpack
path: ../../src
instances: 2
memory: 512M
memory: 1G
stack: cflinuxfs4
timeout: 180
command: ./run.sh

View file

@ -9,7 +9,7 @@ cfenv = "*"
django-cors-headers = "*"
pycryptodomex = "*"
django-allow-cidr = "*"
django-auditlog = "*"
django-auditlog = "2.3.0"
django-csp = "*"
environs = {extras=["django"]}
Faker = "*"
@ -21,7 +21,7 @@ whitenoise = "*"
django-widget-tweaks = "*"
cachetools = "*"
requests = "*"
django-fsm = "*"
django-fsm = "2.8.1"
django-phonenumber-field = {extras = ["phonenumberslite"], version = "*"}
boto3 = "*"
typing-extensions ='*'
@ -29,7 +29,9 @@ django-login-required-middleware = "*"
greenlet = "*"
gevent = "*"
fred-epplib = {git = "https://github.com/cisagov/epplib.git", ref = "master"}
pyzipper="*"
tblib = "*"
django-admin-multiple-choice-list-filter = "*"
[dev-packages]
django-debug-toolbar = "*"
@ -44,4 +46,4 @@ django-webtest = "*"
types-cachetools = "*"
boto3-mocking = "*"
boto3-stubs = "*"
django-model2puml = "*"
django-model2puml = "*"

812
src/Pipfile.lock generated

File diff suppressed because it is too large Load diff

View file

@ -49,3 +49,17 @@ def less_console_noise():
handler.setStream(restore[handler.name])
# close the file we opened
devnull.close()
def less_console_noise_decorator(func):
"""
Decorator to silence console logging using the less_console_noise() function.
"""
# "Wrap" the original function in the less_console_noise with clause,
# then just return this wrapper.
def wrapper(*args, **kwargs):
with less_console_noise():
return func(*args, **kwargs)
return wrapper

View file

@ -33,8 +33,8 @@ class AuthenticationFailed(OIDCException):
friendly_message = "This login attempt didn't work."
class NoStateDefined(OIDCException):
friendly_message = "The session state is None."
class StateMismatch(AuthenticationFailed):
friendly_message = "State mismatch. This login attempt didn't work."
class InternalError(OIDCException):

View file

@ -182,10 +182,20 @@ class Client(oic.Client):
if authn_response["state"] != session.get("state", None):
# this most likely means the user's Django session vanished
logger.error("Received state not the same as expected for %s" % state)
if session.get("state", None) is None:
raise o_e.NoStateDefined()
raise o_e.AuthenticationFailed(locator=state)
logger.error(
f"The OP state {state} does not match the session state. "
f"The session state is None. "
f"authn_response['state'] = {authn_response['state']} "
f"session.get('state', None) = {session.get('state', None)}"
)
else:
logger.error(
f"The OP state {state} does not match the session state. "
f"authn_response['state'] = {authn_response['state']} "
f"session.get('state', None) = {session.get('state', None)}"
)
raise o_e.StateMismatch()
if self.behaviour.get("response_type") == "code":
# need an access token to get user info (and to log the user out later)

View file

@ -4,7 +4,7 @@ from django.http import HttpResponse
from django.test import Client, TestCase, RequestFactory
from django.urls import reverse
from djangooidc.exceptions import NoStateDefined, InternalError
from djangooidc.exceptions import StateMismatch, InternalError
from ..views import login_callback
from .common import less_console_noise
@ -129,21 +129,35 @@ class ViewsTest(TestCase):
self.assertContains(response, "Hi")
def test_login_callback_with_no_session_state(self, mock_client):
"""If the local session is None (ie the server restarted while user was logged out),
"""If the local session does not match the OP session,
we do not throw an exception. Rather, we attempt to login again."""
with less_console_noise():
# MOCK
# mock the acr_value to some string
# mock the callback function to raise the NoStateDefined Exception
# MOCK get_default_acr_value and the callback to raise StateMismatch
# error when called
mock_client.get_default_acr_value.side_effect = self.create_acr
mock_client.callback.side_effect = NoStateDefined()
# TEST
# test the login callback
mock_client.callback.side_effect = StateMismatch()
# TEST receiving a response from login.gov
response = self.client.get(reverse("openid_login_callback"))
# ASSERTIONS
# assert that the user is redirected to the start of the login process
# ASSERT
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, "/")
# Check that the redirect_attempted flag is set in the session
self.assertTrue(self.client.session.get("redirect_attempted", False))
def test_login_callback_with_no_session_state_attempt_again_only_once(self, mock_client):
"""We only attempt to relogin once. After that, it's the error page for you."""
with less_console_noise():
# MOCK get_default_acr_value, redirect_attempted to True and the callback
# to raise StateMismatch error when called
mock_client.get_default_acr_value.side_effect = self.create_acr
mock_client.callback.side_effect = StateMismatch()
session = self.client.session
session["redirect_attempted"] = True
session.save()
# TEST receiving a response from login.gov
response = self.client.get(reverse("openid_login_callback"))
# ASSERT
self.assertEqual(response.status_code, 401)
def test_login_callback_reads_next(self, mock_client):
"""If the next value is set in the session, test that login_callback returns

View file

@ -6,12 +6,13 @@ from django.conf import settings
from django.contrib.auth import logout as auth_logout
from django.contrib.auth import authenticate, login
from django.http import HttpResponseRedirect
from django.shortcuts import redirect, render
from django.shortcuts import redirect
from urllib.parse import parse_qs, urlencode
from djangooidc.oidc import Client
from djangooidc import exceptions as o_e
from registrar.models import User
from registrar.views.utility.error_views import custom_500_error_view, custom_401_error_view
logger = logging.getLogger(__name__)
@ -49,27 +50,19 @@ def error_page(request, error):
"""Display a sensible message and log the error."""
logger.error(error)
if isinstance(error, o_e.AuthenticationFailed):
return render(
request,
"401.html",
context={
"friendly_message": error.friendly_message,
"log_identifier": error.locator,
},
status=401,
)
context = {
"friendly_message": error.friendly_message,
"log_identifier": error.locator,
}
return custom_401_error_view(request, context)
if isinstance(error, o_e.InternalError):
return render(
request,
"500.html",
context={
"friendly_message": error.friendly_message,
"log_identifier": error.locator,
},
status=500,
)
context = {
"friendly_message": error.friendly_message,
"log_identifier": error.locator,
}
return custom_500_error_view(request, context)
if isinstance(error, Exception):
return render(request, "500.html", status=500)
return custom_500_error_view(request)
def openid(request):
@ -108,16 +101,25 @@ def login_callback(request):
if user:
login(request, user)
logger.info("Successfully logged in user %s" % user)
# Double login bug (1507)?
# Clear the flag if the exception is not caught
request.session.pop("redirect_attempted", None)
return redirect(request.session.get("next", "/"))
else:
raise o_e.BannedUser()
except o_e.NoStateDefined as nsd_err:
# In the event that a user is in the middle of a login when the app is restarted,
# their session state will no longer be available, so redirect the user to the
# beginning of login process without raising an error to the user.
logger.warning(f"No State Defined: {nsd_err}")
return redirect(request.session.get("next", "/"))
except o_e.StateMismatch as nsd_err:
# Check if the redirect has already been attempted
if not request.session.get("redirect_attempted", False):
# Set the flag to indicate that the redirect has been attempted
request.session["redirect_attempted"] = True
# In the event of a state mismatch between OP and session, redirect the user to the
# beginning of login process without raising an error to the user. Attempt once.
logger.warning(f"No State Defined: {nsd_err}")
return redirect(request.session.get("next", "/"))
else:
# Clear the flag if the exception is not caught
request.session.pop("redirect_attempted", None)
return error_page(request, nsd_err)
except Exception as err:
return error_page(request, err)

View file

@ -58,6 +58,8 @@ services:
- AWS_S3_SECRET_ACCESS_KEY
- AWS_S3_REGION
- AWS_S3_BUCKET_NAME
# File encryption credentials
- SECRET_ENCRYPT_METADATA
stdin_open: true
tty: true
ports:

View file

@ -1,6 +1,7 @@
"""Provide a wrapper around epplib to handle authentication and errors."""
import logging
from gevent.lock import BoundedSemaphore
try:
from epplib.client import Client
@ -52,10 +53,16 @@ class EPPLibWrapper:
"urn:ietf:params:xml:ns:contact-1.0",
],
)
# We should only ever have one active connection at a time
self.connection_lock = BoundedSemaphore(1)
self.connection_lock.acquire()
try:
self._initialize_client()
except Exception:
logger.warning("Unable to configure epplib. Registrar cannot contact registry.")
logger.warning("Unable to configure the connection to the registry.")
finally:
self.connection_lock.release()
def _initialize_client(self) -> None:
"""Initialize a client, assuming _login defined. Sets _client to initialized
@ -74,11 +81,7 @@ class EPPLibWrapper:
)
try:
# use the _client object to connect
self._client.connect() # type: ignore
response = self._client.send(self._login) # type: ignore
if response.code >= 2000: # type: ignore
self._client.close() # type: ignore
raise LoginError(response.msg) # type: ignore
self._connect()
except TransportError as err:
message = "_initialize_client failed to execute due to a connection error."
logger.error(f"{message} Error: {err}")
@ -90,13 +93,33 @@ class EPPLibWrapper:
logger.error(f"{message} Error: {err}")
raise RegistryError(message) from err
def _connect(self) -> None:
"""Connects to EPP. Sends a login command. If an invalid response is returned,
the client will be closed and a LoginError raised."""
self._client.connect() # type: ignore
response = self._client.send(self._login) # type: ignore
if response.code >= 2000: # type: ignore
self._client.close() # type: ignore
raise LoginError(response.msg) # type: ignore
def _disconnect(self) -> None:
"""Close the connection."""
"""Close the connection. Sends a logout command and closes the connection."""
self._send_logout_command()
self._close_client()
def _send_logout_command(self):
"""Sends a logout command to epp"""
try:
self._client.send(commands.Logout()) # type: ignore
self._client.close() # type: ignore
except Exception:
logger.warning("Connection to registry was not cleanly closed.")
except Exception as err:
logger.warning(f"Logout command not sent successfully: {err}")
def _close_client(self):
"""Closes an active client connection"""
try:
self._client.close()
except Exception as err:
logger.warning(f"Connection to registry was not cleanly closed: {err}")
def _send(self, command):
"""Helper function used by `send`."""
@ -146,6 +169,8 @@ class EPPLibWrapper:
cmd_type = command.__class__.__name__
if not cleaned:
raise ValueError("Please sanitize user input before sending it.")
self.connection_lock.acquire()
try:
return self._send(command)
except RegistryError as err:
@ -161,6 +186,8 @@ class EPPLibWrapper:
return self._retry(command)
else:
raise err
finally:
self.connection_lock.release()
try:

View file

@ -1,5 +1,9 @@
import datetime
from dateutil.tz import tzlocal # type: ignore
from unittest.mock import MagicMock, patch
from pathlib import Path
from django.test import TestCase
from gevent.exceptions import ConcurrentObjectUseError
from epplibwrapper.client import EPPLibWrapper
from epplibwrapper.errors import RegistryError, LoginError
from .common import less_console_noise
@ -8,6 +12,9 @@ import logging
try:
from epplib.exceptions import TransportError
from epplib.responses import Result
from epplib.transport import SocketTransport
from epplib import commands
from epplib.models import common, info
except ImportError:
pass
@ -255,3 +262,116 @@ class TestClient(TestCase):
mock_close.assert_called_once()
# send() is called 5 times: send(login), send(command) fail, send(logout), send(login), send(command)
self.assertEquals(mock_send.call_count, 5)
def fake_failure_send_concurrent_threads(self, command=None, cleaned=None):
"""
Raises a ConcurrentObjectUseError, which gevent throws when accessing
the same thread from two different locations.
"""
# This error is thrown when two threads are being used concurrently
raise ConcurrentObjectUseError("This socket is already used by another greenlet")
def do_nothing(self, command=None):
"""
A placeholder method that performs no action.
"""
pass # noqa
def fake_success_send(self, command=None, cleaned=None):
"""
Simulates receiving a success response from EPP.
"""
mock = MagicMock(
code=1000,
msg="Command completed successfully",
res_data=None,
cl_tr_id="xkw1uo#2023-10-17T15:29:09.559376",
sv_tr_id="5CcH4gxISuGkq8eqvr1UyQ==-35a",
extensions=[],
msg_q=None,
)
return mock
def fake_info_domain_received(self, command=None, cleaned=None):
"""
Simulates receiving a response by reading from a predefined XML file.
"""
location = Path(__file__).parent / "utility" / "infoDomain.xml"
xml = (location).read_bytes()
return xml
def get_fake_epp_result(self):
"""Mimics a return from EPP by returning a dictionary in the same format"""
result = {
"cl_tr_id": None,
"code": 1000,
"extensions": [],
"msg": "Command completed successfully",
"msg_q": None,
"res_data": [
info.InfoDomainResultData(
roid="DF1340360-GOV",
statuses=[
common.Status(
state="serverTransferProhibited",
description=None,
lang="en",
),
common.Status(state="inactive", description=None, lang="en"),
],
cl_id="gov2023-ote",
cr_id="gov2023-ote",
cr_date=datetime.datetime(2023, 8, 15, 23, 56, 36, tzinfo=tzlocal()),
up_id="gov2023-ote",
up_date=datetime.datetime(2023, 8, 17, 2, 3, 19, tzinfo=tzlocal()),
tr_date=None,
name="test3.gov",
registrant="TuaWnx9hnm84GCSU",
admins=[],
nsset=None,
keyset=None,
ex_date=datetime.date(2024, 8, 15),
auth_info=info.DomainAuthInfo(pw="2fooBAR123fooBaz"),
)
],
"sv_tr_id": "wRRNVhKhQW2m6wsUHbo/lA==-29a",
}
return result
def test_send_command_close_failure_recovers(self):
"""
Validates the resilience of the connection handling mechanism
during command execution on retry.
Scenario:
- Initialization of the connection is successful.
- An attempt to send a command fails with a specific error code (ConcurrentObjectUseError)
- The client attempts to retry.
- Subsequently, the client re-initializes the connection.
- A retry of the command execution post-reinitialization succeeds.
"""
expected_result = self.get_fake_epp_result()
wrapper = None
# Trigger a retry
# Do nothing on connect, as we aren't testing it and want to connect while
# mimicking the rest of the client as closely as possible (which is not entirely possible with MagicMock)
with patch.object(EPPLibWrapper, "_connect", self.do_nothing):
with patch.object(SocketTransport, "send", self.fake_failure_send_concurrent_threads):
wrapper = EPPLibWrapper()
tested_command = commands.InfoDomain(name="test.gov")
try:
wrapper.send(tested_command, cleaned=True)
except RegistryError as err:
expected_error = "InfoDomain failed to execute due to an unknown error."
self.assertEqual(err.args[0], expected_error)
else:
self.fail("Registry error was not thrown")
# After a retry, try sending again to see if the connection recovers
with patch.object(EPPLibWrapper, "_connect", self.do_nothing):
with patch.object(SocketTransport, "send", self.fake_success_send), patch.object(
SocketTransport, "receive", self.fake_info_domain_received
):
result = wrapper.send(tested_command, cleaned=True)
self.assertEqual(expected_result, result.__dict__)

File diff suppressed because it is too large Load diff

View file

@ -29,20 +29,26 @@ function openInNewTab(el, removeAttribute = false){
*/
(function (){
function createPhantomModalFormButtons(){
let submitButtons = document.querySelectorAll('.usa-modal button[type="submit"]');
let submitButtons = document.querySelectorAll('.usa-modal button[type="submit"].dja-form-placeholder');
form = document.querySelector("form")
submitButtons.forEach((button) => {
let input = document.createElement("input");
input.type = "submit";
input.name = button.name;
input.value = button.value;
if(button.name){
input.name = button.name;
}
if(button.value){
input.value = button.value;
}
input.style.display = "none"
// Add the hidden input to the form
form.appendChild(input);
button.addEventListener("click", () => {
console.log("clicking")
input.click();
})
})
@ -50,6 +56,61 @@ function openInNewTab(el, removeAttribute = false){
createPhantomModalFormButtons();
})();
/** An IIFE for DomainRequest to hook a modal to a dropdown option.
* This intentionally does not interact with createPhantomModalFormButtons()
*/
(function (){
function displayModalOnDropdownClick(linkClickedDisplaysModal, statusDropdown, actionButton, valueToCheck){
// If these exist all at the same time, we're on the right page
if (linkClickedDisplaysModal && statusDropdown && statusDropdown.value){
// Set the previous value in the event the user cancels.
let previousValue = statusDropdown.value;
if (actionButton){
// Otherwise, if the confirmation buttion is pressed, set it to that
actionButton.addEventListener('click', function() {
// Revert the dropdown to its previous value
statusDropdown.value = valueToCheck;
});
}else {
console.log("displayModalOnDropdownClick() -> Cancel button was null")
}
// Add a change event listener to the dropdown.
statusDropdown.addEventListener('change', function() {
// Check if "Ineligible" is selected
if (this.value && this.value.toLowerCase() === valueToCheck) {
// Set the old value in the event the user cancels,
// or otherwise exists the dropdown.
statusDropdown.value = previousValue
// Display the modal.
linkClickedDisplaysModal.click()
}
});
}
}
// When the status dropdown is clicked and is set to "ineligible", toggle a confirmation dropdown.
function hookModalToIneligibleStatus(){
// Grab the invisible element that will hook to the modal.
// This doesn't technically need to be done with one, but this is simpler to manage.
let modalButton = document.getElementById("invisible-ineligible-modal-toggler")
let statusDropdown = document.getElementById("id_status")
// Because the modal button does not have the class "dja-form-placeholder",
// it will not be affected by the createPhantomModalFormButtons() function.
let actionButton = document.querySelector('button[name="_set_domain_request_ineligible"]');
let valueToCheck = "ineligible"
displayModalOnDropdownClick(modalButton, statusDropdown, actionButton, valueToCheck);
}
hookModalToIneligibleStatus()
})();
/** An IIFE for pages in DjangoAdmin which may need custom JS implementation.
* Currently only appends target="_blank" to the domain_form object,
* but this can be expanded.
@ -76,6 +137,94 @@ function openInNewTab(el, removeAttribute = false){
prepareDjangoAdmin();
})();
/** An IIFE for pages in DjangoAdmin that use a clipboard button
*/
(function (){
function copyInnerTextToClipboard(elem) {
let text = elem.innerText
navigator.clipboard.writeText(text)
}
function copyToClipboardAndChangeIcon(button) {
// Assuming the input is the previous sibling of the button
let input = button.previousElementSibling;
let userId = input.getAttribute("user-id")
// Copy input value to clipboard
if (input) {
navigator.clipboard.writeText(input.value).then(function() {
// Change the icon to a checkmark on successful copy
let buttonIcon = button.querySelector('.usa-button__clipboard use');
if (buttonIcon) {
let currentHref = buttonIcon.getAttribute('xlink:href');
let baseHref = currentHref.split('#')[0];
// Append the new icon reference
buttonIcon.setAttribute('xlink:href', baseHref + '#check');
// Change the button text
nearestSpan = button.querySelector("span")
nearestSpan.innerText = "Copied to clipboard"
setTimeout(function() {
// Change back to the copy icon
buttonIcon.setAttribute('xlink:href', currentHref);
if (button.classList.contains('usa-button__small-text')) {
nearestSpan.innerText = "Copy email";
} else {
nearestSpan.innerText = "Copy";
}
}, 2000);
}
}).catch(function(error) {
console.error('Clipboard copy failed', error);
});
}
}
function handleClipboardButtons() {
clipboardButtons = document.querySelectorAll(".usa-button__clipboard")
clipboardButtons.forEach((button) => {
// Handle copying the text to your clipboard,
// and changing the icon.
button.addEventListener("click", ()=>{
copyToClipboardAndChangeIcon(button);
});
// Add a class that adds the outline style on click
button.addEventListener("mousedown", function() {
this.classList.add("no-outline-on-click");
});
// But add it back in after the user clicked,
// for accessibility reasons (so we can still tab, etc)
button.addEventListener("blur", function() {
this.classList.remove("no-outline-on-click");
});
});
}
function handleClipboardLinks() {
let emailButtons = document.querySelectorAll(".usa-button__clipboard-link");
if (emailButtons){
emailButtons.forEach((button) => {
button.addEventListener("click", ()=>{
copyInnerTextToClipboard(button);
})
});
}
}
handleClipboardButtons();
handleClipboardLinks();
})();
/**
* An IIFE to listen to changes on filter_horizontal and enable or disable the change/delete/view buttons as applicable
*
@ -307,43 +456,6 @@ function enableRelatedWidgetButtons(changeLink, deleteLink, viewLink, elementPk,
viewLink.setAttribute('title', viewLink.getAttribute('title-template').replace('selected item', elementText));
}
/** An IIFE for admin in DjangoAdmin to listen to clicks on the growth report export button,
* attach the seleted start and end dates to a url that'll trigger the view, and finally
* redirect to that url.
*/
(function (){
// Get the current date in the format YYYY-MM-DD
let currentDate = new Date().toISOString().split('T')[0];
// Default the value of the start date input field to the current date
let startDateInput =document.getElementById('start');
// Default the value of the end date input field to the current date
let endDateInput =document.getElementById('end');
let exportGrowthReportButton = document.getElementById('exportLink');
if (exportGrowthReportButton) {
startDateInput.value = currentDate;
endDateInput.value = currentDate;
exportGrowthReportButton.addEventListener('click', function() {
// Get the selected start and end dates
let startDate = startDateInput.value;
let endDate = endDateInput.value;
let exportUrl = document.getElementById('exportLink').dataset.exportUrl;
// Build the URL with parameters
exportUrl += "?start_date=" + startDate + "&end_date=" + endDate;
// Redirect to the export URL
window.location.href = exportUrl;
});
}
})();
/** An IIFE for admin in DjangoAdmin to listen to changes on the domain request
* status select amd to show/hide the rejection reason
*/
@ -386,3 +498,60 @@ function enableRelatedWidgetButtons(changeLink, deleteLink, viewLink, elementPk,
});
observer.observe({ type: "navigation" });
})();
/** An IIFE for toggling the submit bar on domain request forms
*/
(function (){
// Get a reference to the button element
const toggleButton = document.getElementById('submitRowToggle');
const submitRowWrapper = document.querySelector('.submit-row-wrapper');
if (toggleButton) {
// Add event listener to toggle the class and update content on click
toggleButton.addEventListener('click', function() {
// Toggle the 'collapsed' class on the bar
submitRowWrapper.classList.toggle('submit-row-wrapper--collapsed');
// Get a reference to the span element inside the button
const spanElement = this.querySelector('span');
// Get a reference to the use element inside the button
const useElement = this.querySelector('use');
// Check if the span element text is 'Hide'
if (spanElement.textContent.trim() === 'Hide') {
// Update the span element text to 'Show'
spanElement.textContent = 'Show';
// Update the xlink:href attribute to expand_more
useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_less');
} else {
// Update the span element text to 'Hide'
spanElement.textContent = 'Hide';
// Update the xlink:href attribute to expand_less
useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_more');
}
});
// We have a scroll indicator at the end of the page.
// Observe it. Once it gets on screen, test to see if the row is collapsed.
// If it is, expand it.
const targetElement = document.querySelector(".scroll-indicator");
const options = {
threshold: 1
};
// Create a new Intersection Observer
const observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// Refresh reference to submit row wrapper and check if it's collapsed
if (document.querySelector('.submit-row-wrapper').classList.contains('submit-row-wrapper--collapsed')) {
toggleButton.click();
}
}
});
}, options);
observer.observe(targetElement);
}
})();

View file

@ -0,0 +1,130 @@
/** An IIFE for admin in DjangoAdmin to listen to clicks on the growth report export button,
* attach the seleted start and end dates to a url that'll trigger the view, and finally
* redirect to that url.
*
* This function also sets the start and end dates to match the url params if they exist
*/
(function () {
// Function to get URL parameter value by name
function getParameterByName(name, url) {
if (!url) url = window.location.href;
name = name.replace(/[\[\]]/g, '\\$&');
var regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)'),
results = regex.exec(url);
if (!results) return null;
if (!results[2]) return '';
return decodeURIComponent(results[2].replace(/\+/g, ' '));
}
// Get the current date in the format YYYY-MM-DD
let currentDate = new Date().toISOString().split('T')[0];
// Default the value of the start date input field to the current date
let startDateInput = document.getElementById('start');
// Default the value of the end date input field to the current date
let endDateInput = document.getElementById('end');
let exportButtons = document.querySelectorAll('.exportLink');
if (exportButtons.length > 0) {
// Check if start and end dates are present in the URL
let urlStartDate = getParameterByName('start_date');
let urlEndDate = getParameterByName('end_date');
// Set input values based on URL parameters or current date
startDateInput.value = urlStartDate || currentDate;
endDateInput.value = urlEndDate || currentDate;
exportButtons.forEach((btn) => {
btn.addEventListener('click', function () {
// Get the selected start and end dates
let startDate = startDateInput.value;
let endDate = endDateInput.value;
let exportUrl = btn.dataset.exportUrl;
// Build the URL with parameters
exportUrl += "?start_date=" + startDate + "&end_date=" + endDate;
// Redirect to the export URL
window.location.href = exportUrl;
});
});
}
})();
/** An IIFE to initialize the analytics page
*/
(function () {
function createComparativeColumnChart(canvasId, title, labelOne, labelTwo) {
var canvas = document.getElementById(canvasId);
if (!canvas) {
return
}
var ctx = canvas.getContext("2d");
var listOne = JSON.parse(canvas.getAttribute('data-list-one'));
var listTwo = JSON.parse(canvas.getAttribute('data-list-two'));
var data = {
labels: ["Total", "Federal", "Interstate", "State/Territory", "Tribal", "County", "City", "Special District", "School District", "Election Board"],
datasets: [
{
label: labelOne,
backgroundColor: "rgba(255, 99, 132, 0.2)",
borderColor: "rgba(255, 99, 132, 1)",
borderWidth: 1,
data: listOne,
},
{
label: labelTwo,
backgroundColor: "rgba(75, 192, 192, 0.2)",
borderColor: "rgba(75, 192, 192, 1)",
borderWidth: 1,
data: listTwo,
},
],
};
var options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
},
title: {
display: true,
text: title
}
},
scales: {
y: {
beginAtZero: true,
},
},
};
new Chart(ctx, {
type: "bar",
data: data,
options: options,
});
}
function initComparativeColumnCharts() {
document.addEventListener("DOMContentLoaded", function () {
createComparativeColumnChart("myChart1", "Managed domains", "Start Date", "End Date");
createComparativeColumnChart("myChart2", "Unmanaged domains", "Start Date", "End Date");
createComparativeColumnChart("myChart3", "Deleted domains", "Start Date", "End Date");
createComparativeColumnChart("myChart4", "Ready domains", "Start Date", "End Date");
createComparativeColumnChart("myChart5", "Submitted requests", "Start Date", "End Date");
createComparativeColumnChart("myChart6", "All requests", "Start Date", "End Date");
});
};
initComparativeColumnCharts();
})();

View file

@ -530,7 +530,7 @@ function hideDeletedForms() {
let isDotgovDomain = document.querySelector(".dotgov-domain-form");
// The Nameservers formset features 2 required and 11 optionals
if (isNameserversForm) {
cloneIndex = 2;
// cloneIndex = 2;
formLabel = "Name server";
// DNSSEC: DS Data
} else if (isDsDataForm) {
@ -766,3 +766,21 @@ function toggleTwoDomElements(ele1, ele2, index) {
}
})();
/**
* An IIFE that disables the delete buttons on nameserver forms on page load if < 3 forms
*
*/
(function nameserversFormListener() {
let isNameserversForm = document.querySelector(".nameservers-form");
if (isNameserversForm) {
let forms = document.querySelectorAll(".repeatable-form");
if (forms.length < 3) {
// Hide the delete buttons on the 2 nameservers
forms.forEach((form) => {
Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => {
deleteButton.setAttribute("disabled", "true");
});
});
}
}
})();

View file

@ -42,6 +42,7 @@ html[data-theme="light"] {
--error-fg: #{$theme-color-error};
--message-success-bg: #{$theme-color-success-lighter};
--checkbox-green: #{$theme-color-success-light};
// $theme-color-warning-lighter - yellow-5
--message-warning-bg: #faf3d1;
--message-error-bg: #{$theme-color-error-lighter};
@ -93,6 +94,7 @@ html[data-theme="light"] {
--error-fg: #e35f5f;
--message-success-bg: #006b1b;
--checkbox-green: #006b1b;
--message-warning-bg: #583305;
--message-error-bg: #570808;
@ -112,7 +114,8 @@ html[data-theme="light"] {
.change-list .usa-table thead th,
body.dashboard,
body.change-list,
body.change-form {
body.change-form,
.analytics {
color: var(--body-fg);
}
}
@ -135,12 +138,18 @@ html[data-theme="dark"] {
color: var(--primary-fg);
}
#branding h1,
h1, h2, h3,
.module h2 {
font-weight: font-weight('bold');
}
div#content > h2 {
font-size: 1.3rem;
}
.module h3 {
padding: 0;
color: var(--link-fg);
@ -275,11 +284,6 @@ h1, h2, h3,
}
}
// Hides the "clear" button on autocomplete, as we already have one to use
.select2-selection__clear {
display: none;
}
// Fixes a display issue where the list was entirely white, or had too much whitespace
.select2-dropdown {
display: inline-grid !important;
@ -302,3 +306,314 @@ input.admin-confirm-button {
display: contents !important;
}
}
.usa-button-group {
margin-left: -0.25rem!important;
padding-left: 0!important;
.usa-button-group__item {
list-style-type: none;
line-height: normal;
}
.button {
display: inline-block;
padding: 10px 8px;
line-height: normal;
}
.usa-icon {
top: 2px;
}
a.button:active, a.button:focus {
text-decoration: none;
}
}
.module--custom {
a {
font-size: 13px;
font-weight: 600;
border: solid 1px var(--darkened-bg);
background: var(--darkened-bg);
}
}
.usa-modal--django-admin .usa-prose ul > li {
list-style-type: inherit;
// Styling based off of the <p> styling in django admin
line-height: 1.5;
margin-bottom: 0;
margin-top: 0;
max-width: 68ex;
}
.usa-summary-box__dhs-color {
color: $dhs-blue-70;
}
details.dja-detail-table {
display: inline-table;
background-color: var(--body-bg);
.dja-details-summary {
cursor: pointer;
color: var(--body-quiet-color);
}
@media (max-width: 1024px){
.dja-detail-contents {
max-width: 400px !important;
overflow-x: scroll !important;
}
}
tr {
background-color: transparent;
}
td, th {
padding-left: 12px;
border: none
}
thead > tr > th {
border-radius: 4px;
border-top: none;
border-bottom: none;
}
}
address.margin-top-neg-1__detail-list {
margin-top: -8px !important;
}
.dja-detail-list {
dl {
padding-left: 0px !important;
margin-top: 5px !important;
}
// Mimic the normal label size
address, dt {
font-size: 0.8125rem;
color: var(--body-quiet-color);
}
}
td button.usa-button__clipboard-link, address.dja-address-contact-list {
font-size: unset;
}
address.dja-address-contact-list {
color: var(--body-quiet-color);
button.usa-button__clipboard-link {
font-size: unset;
}
}
// Mimic the normal label size
@media (max-width: 1024px){
.dja-detail-list dt, .dja-detail-list address {
font-size: 0.875rem;
color: var(--body-quiet-color);
}
address button.usa-button__clipboard-link, td button.usa-button__clipboard-link {
font-size: 0.875rem !important;
}
}
.errors span.select2-selection {
border: 1px solid var(--error-fg) !important;
}
.choice-filter {
position: relative;
padding-left: 20px;
svg {
top: 4px;
}
}
.choice-filter--checked {
svg:nth-child(1) {
background: var(--checkbox-green);
fill: var(--checkbox-green);
}
svg:nth-child(2) {
color: var(--body-loud-color);
}
}
// Let's define this block of code once and use it for analysts over a certain screen size,
// superusers over another screen size.
@mixin submit-row-wrapper--collapsed-one-line(){
&.submit-row-wrapper--collapsed {
transform: translate3d(0, 42px, 0);
}
.submit-row {
clear: none;
}
}
// Sticky submit bar for domain requests on desktop
@media screen and (min-width:768px) {
.submit-row-wrapper {
position: fixed;
bottom: 0;
right: 0;
left: 338px;
background: var(--darkened-bg);
border-top-left-radius: 6px;
transition: transform .2s ease-out;
.submit-row {
margin-bottom: 0;
}
}
.submit-row-wrapper--collapsed {
// translate3d is more performant than translateY
// https://stackoverflow.com/questions/22111256/translate3d-vs-translate-performance
transform: translate3d(0, 88px, 0);
}
.submit-row-wrapper--collapsed-one-line {
@include submit-row-wrapper--collapsed-one-line();
}
.submit-row {
clear: both;
}
.submit-row-toggle{
display: inline-block;
position: absolute;
top: -30px;
right: 0;
background: var(--darkened-bg);
}
#submitRowToggle {
color: var(--body-fg);
}
.requested-domain-sticky {
max-width: 325px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: medium;
padding-top: 3px !important;
}
}
.visible-768 {
display: none;
}
@media screen and (min-width:768px) {
.visible-768 {
display: block;
padding-top: 0;
}
}
@media screen and (min-width:935px) {
// Analyst only class
.submit-row-wrapper--analyst-view {
@include submit-row-wrapper--collapsed-one-line();
}
}
@media screen and (min-width:1256px) {
.submit-row-wrapper {
@include submit-row-wrapper--collapsed-one-line();
}
}
// Collapse button styles for fieldsets
.module.collapse {
margin-top: -35px;
padding-top: 0;
border: none;
h2 {
background: none;
color: var(--body-fg)!important;
text-transform: none;
}
a {
color: var(--link-fg);
}
}
.dja-status-list {
border-top: solid 1px var(--border-color);
margin-left: 0 !important;
padding-left: 0 !important;
padding-top: 10px;
li {
line-height: 1.5;
font-family: "Source Sans Pro Web", "Helvetica Neue", Helvetica, Roboto, Arial, sans-serif !important;
padding-top: 0;
padding-bottom: 0;
}
}
// Make the clipboard button "float" inside of the input box
.admin-icon-group {
position: relative;
display: inline;
align-items: center;
input {
// Allow for padding around the copy button
padding-right: 35px !important;
// Match the height of other inputs
min-height: 2.25rem !important;
}
button {
line-height: 14px;
width: max-content;
font-size: unset;
text-decoration: none !important;
}
@media (max-width: 1000px) {
button {
display: block;
padding-top: 8px;
}
}
span {
padding-left: 0.1rem;
}
}
.admin-icon-group.admin-icon-group__clipboard-link {
position: relative;
display: inline;
align-items: center;
.usa-button__icon {
position: absolute;
right: auto;
left: 4px;
height: 100%;
top: -1px;
}
button {
font-size: unset !important;
display: inline-flex;
padding-top: 4px;
line-height: 14px;
color: var(--link-fg);
width: max-content;
font-size: unset;
text-decoration: none !important;
}
}
.no-outline-on-click:focus {
outline: none !important;
}
.usa-button__small-text {
font-size: small;
}

View file

@ -117,6 +117,10 @@ abbr[title] {
}
}
.visible-desktop {
display: none;
}
@include at-media(desktop) {
.float-right-desktop {
float: right;
@ -124,33 +128,15 @@ abbr[title] {
.float-left-desktop {
float: left;
}
.visible-desktop {
display: block;
}
}
.flex-end {
align-items: flex-end;
}
// Only apply this custom wrapping to desktop
@include at-media(desktop) {
.usa-tooltip__body {
width: 350px;
white-space: normal;
text-align: center;
}
}
@include at-media(tablet) {
.usa-tooltip__body {
width: 250px !important;
white-space: normal !important;
text-align: center !important;
}
}
@include at-media(mobile) {
.usa-tooltip__body {
width: 250px !important;
white-space: normal !important;
text-align: center !important;
}
.cursor-pointer {
cursor: pointer;
}

View file

@ -138,6 +138,13 @@ a.usa-button--unstyled:visited {
}
}
.usa-button--unstyled--white,
.usa-button--unstyled--white:hover,
.usa-button--unstyled--white:focus,
.usa-button--unstyled--white:active {
color: color('white');
}
// Cancel button used on the
// DNSSEC main page
// We want to center this button on mobile

View file

@ -38,3 +38,18 @@ legend.float-left-tablet + button.float-right-tablet {
margin-top: 1rem;
}
}
// Custom style for disabled inputs
@media (prefers-color-scheme: light) {
.usa-input:disabled, .usa-select:disabled, .usa-textarea:disabled {
background-color: #eeeeee;
color: #666666;
}
}
@media (prefers-color-scheme: dark) {
.usa-input:disabled, .usa-select:disabled, .usa-textarea:disabled {
background-color: var(--body-fg);
color: var(--close-button-hover-bg);
}
}

View file

@ -0,0 +1,26 @@
@use "uswds-core" as *;
// Only apply this custom wrapping to desktop
@include at-media(desktop) {
.usa-tooltip--registrar .usa-tooltip__body {
width: 350px;
white-space: normal;
text-align: center;
}
}
@include at-media(tablet) {
.usa-tooltip--registrar .usa-tooltip__body {
width: 250px !important;
white-space: normal !important;
text-align: center !important;
}
}
@include at-media(mobile) {
.usa-tooltip--registrar .usa-tooltip__body {
width: 250px !important;
white-space: normal !important;
text-align: center !important;
}
}

View file

@ -126,7 +126,6 @@ in the form $setting: value,
----------------------------*/
$theme-input-line-height: 5,
/*---------------------------
# Component settings
-----------------------------

View file

@ -13,6 +13,7 @@
@forward "lists";
@forward "buttons";
@forward "forms";
@forward "tooltips";
@forward "fieldsets";
@forward "alerts";
@forward "tables";

View file

@ -74,6 +74,9 @@ secret_aws_s3_key_id = secret("access_key_id", None) or secret("AWS_S3_ACCESS_KE
secret_aws_s3_key = secret("secret_access_key", None) or secret("AWS_S3_SECRET_ACCESS_KEY", None)
secret_aws_s3_bucket_name = secret("bucket", None) or secret("AWS_S3_BUCKET_NAME", None)
# Passphrase for the encrypted metadata email
secret_encrypt_metadata = secret("SECRET_ENCRYPT_METADATA", None)
secret_registry_cl_id = secret("REGISTRY_CL_ID")
secret_registry_password = secret("REGISTRY_PASSWORD")
secret_registry_cert = b64decode(secret("REGISTRY_CERT", ""))
@ -94,6 +97,7 @@ DEBUG = env_debug
# Controls production specific feature toggles
IS_PRODUCTION = env_is_production
SECRET_ENCRYPT_METADATA = secret_encrypt_metadata
# Applications are modular pieces of code.
# They are provided by Django, by third-parties, or by yourself.
@ -142,6 +146,8 @@ INSTALLED_APPS = [
# "puml_generator",
# supports necessary headers for Django cross origin
"corsheaders",
# library for multiple choice filters in django admin
"django_admin_multiple_choice_list_filter",
]
# Middleware are routines for processing web requests.
@ -326,8 +332,9 @@ CSP_FORM_ACTION = allowed_sources
# Google analytics requires that we relax our otherwise
# strict CSP by allowing scripts to run from their domain
# and inline with a nonce, as well as allowing connections back to their domain
CSP_SCRIPT_SRC_ELEM = ["'self'", "https://www.googletagmanager.com/"]
# 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
CSP_SCRIPT_SRC_ELEM = ["'self'", "https://www.googletagmanager.com/", "https://cdn.jsdelivr.net/npm/chart.js"]
CSP_CONNECT_SRC = ["'self'", "https://www.google-analytics.com/"]
CSP_INCLUDE_NONCE_IN = ["script-src-elem"]
@ -635,6 +642,8 @@ ALLOWED_HOSTS = [
"getgov-stable.app.cloud.gov",
"getgov-staging.app.cloud.gov",
"getgov-development.app.cloud.gov",
"getgov-bob.app.cloud.gov",
"getgov-meoward.app.cloud.gov",
"getgov-backup.app.cloud.gov",
"getgov-ky.app.cloud.gov",
"getgov-es.app.cloud.gov",

View file

@ -9,22 +9,29 @@ from django.urls import include, path
from django.views.generic import RedirectView
from registrar import views
from registrar.views.admin_views import (
ExportDataDomainsGrowth,
ExportDataFederal,
ExportDataFull,
ExportDataManagedDomains,
ExportDataRequestsGrowth,
ExportDataType,
ExportDataUnmanagedDomains,
AnalyticsView,
)
from registrar.views.admin_views import ExportData
from registrar.views.application import Step
from registrar.views.domain_request import Step
from registrar.views.utility import always_404
from api.views import available, get_current_federal, get_current_full
APPLICATION_NAMESPACE = views.ApplicationWizard.URL_NAMESPACE
application_urls = [
path("", views.ApplicationWizard.as_view(), name=""),
DOMAIN_REQUEST_NAMESPACE = views.DomainRequestWizard.URL_NAMESPACE
domain_request_urls = [
path("", views.DomainRequestWizard.as_view(), name=""),
path("finished/", views.Finished.as_view(), name="finished"),
]
# dynamically generate the other application_urls
# dynamically generate the other domain_request_urls
for step, view in [
# add/remove steps here
(Step.ORGANIZATION_TYPE, views.OrganizationType),
@ -43,7 +50,7 @@ for step, view in [
(Step.REQUIREMENTS, views.Requirements),
(Step.REVIEW, views.Review),
]:
application_urls.append(path(f"{step}/", view.as_view(), name=step))
domain_request_urls.append(path(f"{step}/", view.as_view(), name=step))
urlpatterns = [
@ -52,31 +59,70 @@ urlpatterns = [
"admin/logout/",
RedirectView.as_view(pattern_name="logout", permanent=False),
),
path("export_data/", ExportData.as_view(), name="admin_export_data"),
path(
"admin/analytics/export_data_type/",
ExportDataType.as_view(),
name="export_data_type",
),
path(
"admin/analytics/export_data_full/",
ExportDataFull.as_view(),
name="export_data_full",
),
path(
"admin/analytics/export_data_federal/",
ExportDataFederal.as_view(),
name="export_data_federal",
),
path(
"admin/analytics/export_domains_growth/",
ExportDataDomainsGrowth.as_view(),
name="export_domains_growth",
),
path(
"admin/analytics/export_requests_growth/",
ExportDataRequestsGrowth.as_view(),
name="export_requests_growth",
),
path(
"admin/analytics/export_managed_domains/",
ExportDataManagedDomains.as_view(),
name="export_managed_domains",
),
path(
"admin/analytics/export_unmanaged_domains/",
ExportDataUnmanagedDomains.as_view(),
name="export_unmanaged_domains",
),
path(
"admin/analytics/",
AnalyticsView.as_view(),
name="analytics",
),
path("admin/", admin.site.urls),
path(
"application/<id>/edit/",
views.ApplicationWizard.as_view(),
name=views.ApplicationWizard.EDIT_URL_NAME,
"domain-request/<id>/edit/",
views.DomainRequestWizard.as_view(),
name=views.DomainRequestWizard.EDIT_URL_NAME,
),
path(
"application/<int:pk>",
views.ApplicationStatus.as_view(),
name="application-status",
"domain-request/<int:pk>",
views.DomainRequestStatus.as_view(),
name="domain-request-status",
),
path(
"application/<int:pk>/withdraw",
views.ApplicationWithdrawConfirmation.as_view(),
name="application-withdraw-confirmation",
"domain-request/<int:pk>/withdraw",
views.DomainRequestWithdrawConfirmation.as_view(),
name="domain-request-withdraw-confirmation",
),
path(
"application/<int:pk>/withdrawconfirmed",
views.ApplicationWithdrawn.as_view(),
name="application-withdrawn",
"domain-request/<int:pk>/withdrawconfirmed",
views.DomainRequestWithdrawn.as_view(),
name="domain-request-withdrawn",
),
path("health", views.health, name="health"),
path("openid/", include("djangooidc.urls")),
path("request/", include((application_urls, APPLICATION_NAMESPACE))),
path("request/", include((domain_request_urls, DOMAIN_REQUEST_NAMESPACE))),
path("api/v1/available/", available, name="available"),
path("api/v1/get-report/current-federal", get_current_federal, name="get-current-federal"),
path("api/v1/get-report/current-full", get_current_full, name="get-current-full"),
@ -138,9 +184,9 @@ urlpatterns = [
name="invitation-delete",
),
path(
"application/<int:pk>/delete",
views.DomainApplicationDeleteView.as_view(http_method_names=["post"]),
name="application-delete",
"domain-request/<int:pk>/delete",
views.DomainRequestDeleteView.as_view(http_method_names=["post"]),
name="domain-request-delete",
),
path(
"domain/<int:pk>/users/<int:user_pk>/delete",
@ -149,6 +195,18 @@ urlpatterns = [
),
]
# Djangooidc strips out context data from that context, so we define a custom error
# view through this method.
# If Djangooidc is left to its own devices and uses reverse directly,
# then both context and session information will be obliterated due to:
# a) Djangooidc being out of scope for context_processors
# b) Potential cyclical import errors restricting what kind of data is passable.
# Rather than dealing with that, we keep everything centralized in one location.
# This way, we can share a view for djangooidc, and other pages as we see fit.
handler500 = "registrar.views.utility.error_views.custom_500_error_view"
# we normally would guard these with `if settings.DEBUG` but tests run with
# DEBUG = False even when these apps have been loaded because settings.DEBUG
# was actually True. Instead, let's add these URLs any time we are able to

View file

@ -1,10 +1,11 @@
import logging
import random
from faker import Faker
from django.db import transaction
from registrar.models import (
User,
DomainApplication,
DomainRequest,
DraftDomain,
Contact,
Website,
@ -14,9 +15,9 @@ fake = Faker()
logger = logging.getLogger(__name__)
class DomainApplicationFixture:
class DomainRequestFixture:
"""
Load domain applications into the database.
Load domain requests into the database.
Make sure this class' `load` method is called from `handle`
in management/commands/load.py, then use `./manage.py load`
@ -29,7 +30,7 @@ class DomainApplicationFixture:
# {
# "status": "started",
# "organization_name": "Example - Just started",
# "organization_type": "federal",
# "generic_org_type": "federal",
# "federal_agency": None,
# "federal_type": None,
# "address_line1": None,
@ -49,27 +50,27 @@ class DomainApplicationFixture:
# },
DA = [
{
"status": DomainApplication.ApplicationStatus.STARTED,
"status": DomainRequest.DomainRequestStatus.STARTED,
"organization_name": "Example - Finished but not submitted",
},
{
"status": DomainApplication.ApplicationStatus.SUBMITTED,
"status": DomainRequest.DomainRequestStatus.SUBMITTED,
"organization_name": "Example - Submitted but pending investigation",
},
{
"status": DomainApplication.ApplicationStatus.IN_REVIEW,
"status": DomainRequest.DomainRequestStatus.IN_REVIEW,
"organization_name": "Example - In investigation",
},
{
"status": DomainApplication.ApplicationStatus.IN_REVIEW,
"status": DomainRequest.DomainRequestStatus.IN_REVIEW,
"organization_name": "Example - Approved",
},
{
"status": DomainApplication.ApplicationStatus.WITHDRAWN,
"status": DomainRequest.DomainRequestStatus.WITHDRAWN,
"organization_name": "Example - Withdrawn",
},
{
"status": DomainApplication.ApplicationStatus.ACTION_NEEDED,
"status": DomainRequest.DomainRequestStatus.ACTION_NEEDED,
"organization_name": "Example - Action needed",
},
{
@ -94,15 +95,17 @@ class DomainApplicationFixture:
return f"{fake.slug()}.gov"
@classmethod
def _set_non_foreign_key_fields(cls, da: DomainApplication, app: dict):
def _set_non_foreign_key_fields(cls, da: DomainRequest, app: dict):
"""Helper method used by `load`."""
da.status = app["status"] if "status" in app else "started"
da.organization_type = app["organization_type"] if "organization_type" in app else "federal"
# 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.federal_agency = (
app["federal_agency"]
if "federal_agency" in app
# Random choice of agency for selects, used as placeholders for testing.
else random.choice(DomainApplication.AGENCIES) # nosec
else random.choice(DomainRequest.AGENCIES) # nosec
)
da.submission_date = fake.date()
da.federal_type = (
@ -121,7 +124,7 @@ class DomainApplicationFixture:
da.is_policy_acknowledged = app["is_policy_acknowledged"] if "is_policy_acknowledged" in app else True
@classmethod
def _set_foreign_key_fields(cls, da: DomainApplication, app: dict, user: User):
def _set_foreign_key_fields(cls, da: DomainRequest, app: dict, user: User):
"""Helper method used by `load`."""
if not da.investigator:
da.investigator = User.objects.get(username=user.username) if "investigator" in app else None
@ -145,7 +148,7 @@ class DomainApplicationFixture:
da.requested_domain = DraftDomain.objects.create(name=cls.fake_dot_gov())
@classmethod
def _set_many_to_many_relations(cls, da: DomainApplication, app: dict):
def _set_many_to_many_relations(cls, da: DomainRequest, app: dict):
"""Helper method used by `load`."""
if "other_contacts" in app:
for contact in app["other_contacts"]:
@ -176,19 +179,27 @@ class DomainApplicationFixture:
@classmethod
def load(cls):
"""Creates domain applications for each user in the database."""
logger.info("Going to load %s domain applications" % len(cls.DA))
"""Creates domain requests for each user in the database."""
logger.info("Going to load %s domain requests" % len(cls.DA))
try:
users = list(User.objects.all()) # force evaluation to catch db errors
except Exception as e:
logger.warning(e)
return
# Lumped under .atomic to ensure we don't make redundant DB calls.
# This bundles them all together, and then saves it in a single call.
with transaction.atomic():
cls._create_domain_requests(users)
@classmethod
def _create_domain_requests(cls, users):
"""Creates DomainRequests given a list of users"""
for user in users:
logger.debug("Loading domain applications for %s" % user)
logger.debug("Loading domain requests for %s" % user)
for app in cls.DA:
try:
da, _ = DomainApplication.objects.get_or_create(
da, _ = DomainRequest.objects.get_or_create(
creator=user,
organization_name=app["organization_name"],
)
@ -200,7 +211,7 @@ class DomainApplicationFixture:
logger.warning(e)
class DomainFixture(DomainApplicationFixture):
class DomainFixture(DomainRequestFixture):
"""Create one domain and permissions on it for each user."""
@classmethod
@ -211,14 +222,27 @@ class DomainFixture(DomainApplicationFixture):
logger.warning(e)
return
for user in users:
# approve one of each users in review status domains
application = DomainApplication.objects.filter(
creator=user, status=DomainApplication.ApplicationStatus.IN_REVIEW
).last()
logger.debug(f"Approving {application} for {user}")
# Lumped under .atomic to ensure we don't make redundant DB calls.
# This bundles them all together, and then saves it in a single call.
with transaction.atomic():
# approve each user associated with `in review` status domains
DomainFixture._approve_domain_requests(users)
# We don't want fixtures sending out real emails to
# fake email addresses, so we just skip that and log it instead
application.approve(send_email=False)
application.save()
@staticmethod
def _approve_domain_requests(users):
"""Approves all provided domain requests if they are in the state in_review"""
for user in users:
domain_request = DomainRequest.objects.filter(
creator=user, status=DomainRequest.DomainRequestStatus.IN_REVIEW
).last()
logger.debug(f"Approving {domain_request} for {user}")
# All approvals require an investigator, so if there is none,
# assign one.
if domain_request.investigator is None:
# All "users" in fixtures have admin perms per prior config.
# No need to check for that.
domain_request.investigator = random.choice(users) # nosec
domain_request.approve(send_email=False)
domain_request.save()

View file

@ -1,5 +1,6 @@
import logging
from faker import Faker
from django.db import transaction
from registrar.models import (
User,
@ -98,6 +99,12 @@ class UserFixture:
"last_name": "Burnett",
"email": "christina.burnett@cisa.dhs.gov",
},
"username": "012f844d-8a0f-4225-9d82-cbf87bff1d3e",
"first_name": "Riley",
"last_name": "Orr",
"email": "riley+320@truss.works",
},
]
STAFF = [
@ -174,6 +181,17 @@ class UserFixture:
"last_name": "Burnett-Analyst",
"email": "christina.burnett@gwe.cisa.dhs.gov",
},
"username": "d9839768-0c17-4fa2-9c8e-36291eef5c11",
"first_name": "Alex-Analyst",
"last_name": "Mcelya-Analyst",
"email": "ALEXANDER.MCELYA@cisa.dhs.gov",
},
{
"username": "082a066f-e0a4-45f6-8672-4343a1208a36",
"first_name": "Riley-Analyst",
"last_name": "Orr-Analyst",
"email": "riley+321@truss.works",
},
]
def load_users(cls, users, group_name):
@ -198,5 +216,12 @@ class UserFixture:
@classmethod
def load(cls):
cls.load_users(cls, cls.ADMINS, "full_access_group")
cls.load_users(cls, cls.STAFF, "cisa_analysts_group")
# Lumped under .atomic to ensure we don't make redundant DB calls.
# This bundles them all together, and then saves it in a single call.
# This is slightly different then bulk_create or bulk_update, in that
# you still get the same behaviour of .save(), but those incremental
# steps now do not need to close/reopen a db connection,
# instead they share one.
with transaction.atomic():
cls.load_users(cls, cls.ADMINS, "full_access_group")
cls.load_users(cls, cls.STAFF, "cisa_analysts_group")

View file

@ -1,4 +1,4 @@
from .application_wizard import *
from .domain_request_wizard import *
from .domain import (
DomainAddUserForm,
NameserverFormset,

View file

@ -1,10 +1,12 @@
"""Forms for domain management."""
import logging
from django import forms
from django.core.validators import MinValueValidator, MaxValueValidator, RegexValidator
from django.core.validators import MinValueValidator, MaxValueValidator, RegexValidator, MaxLengthValidator
from django.forms import formset_factory
from registrar.models import DomainRequest
from phonenumber_field.widgets import RegionalPhoneNumberWidget
from registrar.models.utility.domain_helper import DomainHelper
from registrar.utility.errors import (
NameserverError,
NameserverErrorCodes as nsErrorCodes,
@ -23,10 +25,23 @@ from .common import (
import re
logger = logging.getLogger(__name__)
class DomainAddUserForm(forms.Form):
"""Form for adding a user to a domain."""
email = forms.EmailField(label="Email")
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.",
)
],
)
def clean(self):
"""clean form data by lowercasing email"""
@ -68,25 +83,34 @@ class DomainNameserverForm(forms.Form):
# after clean_fields. it is used to determine form level errors.
# is_valid is typically called from view during a post
cleaned_data = super().clean()
self.clean_empty_strings(cleaned_data)
server = cleaned_data.get("server", "")
# remove ANY spaces in the server field
server = server.replace(" ", "")
# lowercase the server
server = server.lower()
server = server.replace(" ", "").lower()
cleaned_data["server"] = server
ip = cleaned_data.get("ip", None)
# remove ANY spaces in the ip field
ip = cleaned_data.get("ip", "")
ip = ip.replace(" ", "")
cleaned_data["ip"] = ip
domain = cleaned_data.get("domain", "")
ip_list = self.extract_ip_list(ip)
# validate if the form has a server or an ip
# Capture the server_value
server_value = self.cleaned_data.get("server")
# Validate if the form has a server or an ip
if (ip and ip_list) or server:
self.validate_nameserver_ip_combo(domain, server, ip_list)
# Re-set the server value:
# add_error which is called on validate_nameserver_ip_combo will clean-up (delete) any invalid data.
# We need that data because we need to know the total server entries (even if invalid) in the formset
# clean method where we determine whether a blank first and/or second entry should throw a required error.
self.cleaned_data["server"] = server_value
return cleaned_data
def clean_empty_strings(self, cleaned_data):
@ -134,6 +158,19 @@ class BaseNameserverFormset(forms.BaseFormSet):
"""
Check for duplicate entries in the formset.
"""
# Check if there are at least two valid servers
valid_servers_count = sum(
1 for form in self.forms if form.cleaned_data.get("server") and form.cleaned_data.get("server").strip()
)
if valid_servers_count >= 2:
# If there are, remove the "At least two name servers are required" error from each form
# This will allow for successful submissions when the first or second entries are blanked
# but there are enough entries total
for form in self.forms:
if form.errors.get("server") == ["At least two name servers are required."]:
form.errors.pop("server")
if any(self.errors):
# Don't bother validating the formset unless each form is valid on its own
return
@ -141,10 +178,13 @@ class BaseNameserverFormset(forms.BaseFormSet):
data = []
duplicates = []
for form in self.forms:
for index, form in enumerate(self.forms):
if form.cleaned_data:
value = form.cleaned_data["server"]
if value in data:
# We need to make sure not to trigger the duplicate error in case the first and second nameservers
# are empty. If there are enough records in the formset, that error is an unecessary blocker.
# If there aren't, the required error will block the submit.
if value in data and not (form.cleaned_data.get("server", "").strip() == "" and index == 1):
form.add_error(
"server",
NameserverError(code=nsErrorCodes.DUPLICATE_HOST, nameserver=value),
@ -166,6 +206,8 @@ NameserverFormset = formset_factory(
class ContactForm(forms.ModelForm):
"""Form for updating contacts."""
email = forms.EmailField(max_length=None)
class Meta:
model = Contact
fields = ["first_name", "middle_name", "last_name", "title", "email", "phone"]
@ -189,6 +231,10 @@ class ContactForm(forms.ModelForm):
# which interferes with out input_with_errors template tag
self.fields["phone"].widget.attrs.pop("maxlength", None)
# Define a custom validator for the email field with a custom error message
email_max_length_validator = MaxLengthValidator(320, message="Response must be less than 320 characters.")
self.fields["email"].validators.append(email_max_length_validator)
for field_name in self.required:
self.fields[field_name].required = True
@ -205,6 +251,13 @@ class ContactForm(forms.ModelForm):
"required": "Enter your email address in the required format, like name@example.com."
}
self.fields["phone"].error_messages["required"] = "Enter your phone number."
self.domainInfo = None
def set_domain_info(self, domainInfo):
"""Set the domain information for the form.
The form instance is associated with the contact itself. In order to access the associated
domain information object, this needs to be set in the form by the view."""
self.domainInfo = domainInfo
class AuthorizingOfficialContactForm(ContactForm):
@ -212,7 +265,7 @@ class AuthorizingOfficialContactForm(ContactForm):
JOIN = "authorizing_official"
def __init__(self, *args, **kwargs):
def __init__(self, disable_fields=False, *args, **kwargs):
super().__init__(*args, **kwargs)
# Overriding bc phone not required in this form
@ -232,20 +285,36 @@ class AuthorizingOfficialContactForm(ContactForm):
self.fields["email"].error_messages = {
"required": "Enter an email address in the required format, like name@example.com."
}
self.domainInfo = None
def set_domain_info(self, domainInfo):
"""Set the domain information for the form.
The form instance is associated with the contact itself. In order to access the associated
domain information object, this needs to be set in the form by the view."""
self.domainInfo = domainInfo
# All fields should be disabled if the domain is federal or tribal
if disable_fields:
DomainHelper.mass_disable_fields(fields=self.fields, disable_required=True, disable_maxlength=True)
def save(self, commit=True):
"""Override the save() method of the BaseModelForm."""
"""
Override the save() method of the BaseModelForm.
Used to perform checks on the underlying domain_information object.
If this doesn't exist, we just save as normal.
"""
# If the underlying Domain doesn't have a domainInfo object,
# just let the default super handle it.
if not self.domainInfo:
return super().save()
# Determine if the domain is federal or tribal
is_federal = self.domainInfo.generic_org_type == DomainRequest.OrganizationChoices.FEDERAL
is_tribal = self.domainInfo.generic_org_type == DomainRequest.OrganizationChoices.TRIBAL
# Get the Contact object from the db for the Authorizing Official
db_ao = Contact.objects.get(id=self.instance.id)
if self.domainInfo and db_ao.has_more_than_one_join("information_authorizing_official"):
if (is_federal or is_tribal) and self.has_changed():
# 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.
# This could be malicious, so lets reserve information for the backend only.
raise ValueError("Authorizing Official cannot be modified for federal or tribal domains.")
elif db_ao.has_more_than_one_join("information_authorizing_official"):
# Handle the case where the domain information object is available and the AO Contact
# has more than one joined object.
# In this case, create a new Contact, and update the new Contact with form data.
@ -254,6 +323,7 @@ class AuthorizingOfficialContactForm(ContactForm):
self.domainInfo.authorizing_official = Contact.objects.create(**data)
self.domainInfo.save()
else:
# If all checks pass, just save normally
super().save()
@ -262,10 +332,17 @@ class DomainSecurityEmailForm(forms.Form):
security_email = forms.EmailField(
label="Security email (optional)",
max_length=None,
required=False,
error_messages={
"invalid": str(SecurityEmailError(code=SecurityEmailErrorCodes.BAD_DATA)),
},
validators=[
MaxLengthValidator(
320,
message="Response must be less than 320 characters.",
)
],
)
@ -304,11 +381,11 @@ class DomainOrgNameAddressForm(forms.ModelForm):
},
}
widgets = {
# We need to set the required attributed for federal_agency and
# state/territory because for these fields we are creating an individual
# We need to set the required attributed for State/territory
# because for this fields we are creating an individual
# instance of the Select. For the other fields we use the for loop to set
# the class's required attribute to true.
"federal_agency": forms.Select(attrs={"required": True}, choices=DomainInformation.AGENCY_CHOICES),
"federal_agency": forms.TextInput,
"organization_name": forms.TextInput,
"address_line1": forms.TextInput,
"address_line2": forms.TextInput,
@ -334,6 +411,46 @@ class DomainOrgNameAddressForm(forms.ModelForm):
self.fields["state_territory"].widget.attrs.pop("maxlength", None)
self.fields["zipcode"].widget.attrs.pop("maxlength", None)
self.is_federal = self.instance.generic_org_type == DomainRequest.OrganizationChoices.FEDERAL
self.is_tribal = self.instance.generic_org_type == DomainRequest.OrganizationChoices.TRIBAL
field_to_disable = None
if self.is_federal:
field_to_disable = "federal_agency"
elif self.is_tribal:
field_to_disable = "organization_name"
# Disable any field that should be disabled, if applicable
if field_to_disable is not None:
DomainHelper.disable_field(self.fields[field_to_disable], disable_required=True)
def save(self, commit=True):
"""Override the save() method of the BaseModelForm."""
if self.has_changed():
# 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.
# This could be malicious, so lets reserve information for the backend only.
if self.is_federal and not self._field_unchanged("federal_agency"):
raise ValueError("federal_agency cannot be modified when the generic_org_type is federal")
elif self.is_tribal and not self._field_unchanged("organization_name"):
raise ValueError("organization_name cannot be modified when the generic_org_type is tribal")
else:
super().save()
def _field_unchanged(self, field_name) -> bool:
"""
Checks if a specified field has not changed between the old value
and the new value.
The old value is grabbed from self.initial.
The new value is grabbed from self.cleaned_data.
"""
old_value = self.initial.get(field_name, None)
new_value = self.cleaned_data.get(field_name, None)
return old_value == new_value
class DomainDnssecForm(forms.Form):
"""Form for enabling and disabling dnssec"""

View file

@ -10,7 +10,7 @@ from django.core.validators import RegexValidator, MaxLengthValidator
from django.utils.safestring import mark_safe
from django.db.models.fields.related import ForeignObjectRel
from registrar.models import Contact, DomainApplication, DraftDomain, Domain
from registrar.models import Contact, DomainRequest, DraftDomain, Domain
from registrar.templatetags.url_helpers import public_site_url
from registrar.utility.enums import ValidationReturnType
@ -21,7 +21,7 @@ class RegistrarForm(forms.Form):
"""
A common set of methods and configuration.
The registrar's domain application is several pages of "steps".
The registrar's domain request is several pages of "steps".
Each step is an HTML form containing one or more Django "forms".
Subclass this class to create new forms.
@ -29,11 +29,11 @@ class RegistrarForm(forms.Form):
def __init__(self, *args, **kwargs):
kwargs.setdefault("label_suffix", "")
# save a reference to an application object
self.application = kwargs.pop("application", None)
# save a reference to a domain request object
self.domain_request = kwargs.pop("domain_request", None)
super(RegistrarForm, self).__init__(*args, **kwargs)
def to_database(self, obj: DomainApplication | Contact):
def to_database(self, obj: DomainRequest | Contact):
"""
Adds this form's cleaned data to `obj` and saves `obj`.
@ -46,7 +46,7 @@ class RegistrarForm(forms.Form):
obj.save()
@classmethod
def from_database(cls, obj: DomainApplication | Contact | None):
def from_database(cls, obj: DomainRequest | Contact | None):
"""Returns a dict of form field values gotten from `obj`."""
if obj is None:
return {}
@ -61,8 +61,8 @@ class RegistrarFormSet(forms.BaseFormSet):
"""
def __init__(self, *args, **kwargs):
# save a reference to an application object
self.application = kwargs.pop("application", None)
# save a reference to an domain_request object
self.domain_request = kwargs.pop("domain_request", None)
super(RegistrarFormSet, self).__init__(*args, **kwargs)
# quick workaround to ensure that the HTML `required`
# attribute shows up on required fields for any forms
@ -85,7 +85,7 @@ class RegistrarFormSet(forms.BaseFormSet):
"""Code to run before an item in the formset is created in the database."""
return cleaned
def to_database(self, obj: DomainApplication):
def to_database(self, obj: DomainRequest):
"""
Adds this form's cleaned data to `obj` and saves `obj`.
@ -97,7 +97,7 @@ class RegistrarFormSet(forms.BaseFormSet):
def _to_database(
self,
obj: DomainApplication,
obj: DomainRequest,
join: str,
should_delete: Callable,
pre_update: Callable,
@ -137,14 +137,14 @@ class RegistrarFormSet(forms.BaseFormSet):
if should_delete(cleaned):
if hasattr(db_obj, "has_more_than_one_join") and db_obj.has_more_than_one_join(related_name):
# Remove the specific relationship without deleting the object
getattr(db_obj, related_name).remove(self.application)
getattr(db_obj, related_name).remove(self.domain_request)
else:
# If there are no other relationships, delete the object
db_obj.delete()
else:
if hasattr(db_obj, "has_more_than_one_join") and db_obj.has_more_than_one_join(related_name):
# create a new db_obj and disconnect existing one
getattr(db_obj, related_name).remove(self.application)
getattr(db_obj, related_name).remove(self.domain_request)
kwargs = pre_create(db_obj, cleaned)
getattr(obj, join).create(**kwargs)
else:
@ -163,15 +163,15 @@ class RegistrarFormSet(forms.BaseFormSet):
return query.values()
@classmethod
def from_database(cls, obj: DomainApplication, join: str, on_fetch: Callable):
def from_database(cls, obj: DomainRequest, join: str, on_fetch: Callable):
"""Returns a dict of form field values gotten from `obj`."""
return on_fetch(getattr(obj, join).order_by("created_at")) # order matters
class OrganizationTypeForm(RegistrarForm):
organization_type = forms.ChoiceField(
# use the long names in the application form
choices=DomainApplication.OrganizationChoicesVerbose.choices,
generic_org_type = forms.ChoiceField(
# use the long names in the domain request form
choices=DomainRequest.OrganizationChoicesVerbose.choices,
widget=forms.RadioSelect,
error_messages={"required": "Select the type of organization you represent."},
)
@ -201,7 +201,7 @@ class TribalGovernmentForm(RegistrarForm):
# into a link. There should be no user-facing input in the
# HTML indicated here.
mark_safe( # nosec
"You cant complete this application yet. "
"You cant complete this domain request yet. "
"Only tribes recognized by the U.S. federal government "
"or by a U.S. state government are eligible for .gov "
'domains. Use our <a href="{}">contact form</a> to '
@ -215,7 +215,7 @@ class TribalGovernmentForm(RegistrarForm):
class OrganizationFederalForm(RegistrarForm):
federal_type = forms.ChoiceField(
choices=DomainApplication.BranchChoices.choices,
choices=DomainRequest.BranchChoices.choices,
widget=forms.RadioSelect,
error_messages={"required": ("Select the part of the federal government your organization is in.")},
)
@ -251,7 +251,7 @@ class OrganizationContactForm(RegistrarForm):
# it is a federal agency. Use clean to check programatically
# if it has been filled in when required.
required=False,
choices=[("", "--Select--")] + DomainApplication.AGENCY_CHOICES,
choices=[("", "--Select--")] + DomainRequest.AGENCY_CHOICES,
)
organization_name = forms.CharField(
label="Organization name",
@ -271,7 +271,7 @@ class OrganizationContactForm(RegistrarForm):
)
state_territory = forms.ChoiceField(
label="State, territory, or military post",
choices=[("", "--Select--")] + DomainApplication.StateTerritoryChoices.choices,
choices=[("", "--Select--")] + DomainRequest.StateTerritoryChoices.choices,
error_messages={
"required": ("Select the state, territory, or military post where your organization is located.")
},
@ -294,16 +294,16 @@ class OrganizationContactForm(RegistrarForm):
def clean_federal_agency(self):
"""Require something to be selected when this is a federal agency."""
federal_agency = self.cleaned_data.get("federal_agency", None)
# need the application object to know if this is federal
if self.application is None:
# hmm, no saved application object?, default require the agency
# need the domain request object to know if this is federal
if self.domain_request is None:
# hmm, no saved domain request object?, default require the agency
if not federal_agency:
# no answer was selected
raise forms.ValidationError(
"Select the federal agency your organization is in.",
code="required",
)
if self.application.is_federal():
if self.domain_request.is_federal():
if not federal_agency:
# no answer was selected
raise forms.ValidationError(
@ -319,8 +319,8 @@ class AboutYourOrganizationForm(RegistrarForm):
widget=forms.Textarea(),
validators=[
MaxLengthValidator(
1000,
message="Response must be less than 1000 characters.",
2000,
message="Response must be less than 2000 characters.",
)
],
error_messages={"required": ("Enter more information about your organization.")},
@ -369,7 +369,14 @@ class AuthorizingOfficialForm(RegistrarForm):
)
email = forms.EmailField(
label="Email",
max_length=None,
error_messages={"invalid": ("Enter an email address in the required format, like name@example.com.")},
validators=[
MaxLengthValidator(
320,
message="Response must be less than 320 characters.",
)
],
)
@ -390,7 +397,7 @@ class BaseCurrentSitesFormSet(RegistrarFormSet):
website = cleaned.get("website", "")
return website.strip() == ""
def to_database(self, obj: DomainApplication):
def to_database(self, obj: DomainRequest):
# If we want to test against multiple joins for a website object, replace the empty array
# and change the JOIN in the models to allow for reverse references
self._to_database(obj, self.JOIN, self.should_delete, self.pre_update, self.pre_create)
@ -444,7 +451,7 @@ class BaseAlternativeDomainFormSet(RegistrarFormSet):
else:
return {}
def to_database(self, obj: DomainApplication):
def to_database(self, obj: DomainRequest):
# If we want to test against multiple joins for a website object, replace the empty array and
# change the JOIN in the models to allow for reverse references
self._to_database(obj, self.JOIN, self.should_delete, self.pre_update, self.pre_create)
@ -515,8 +522,8 @@ class PurposeForm(RegistrarForm):
widget=forms.Textarea(),
validators=[
MaxLengthValidator(
1000,
message="Response must be less than 1000 characters.",
2000,
message="Response must be less than 2000 characters.",
)
],
error_messages={"required": "Describe how youll use the .gov domain youre requesting."},
@ -530,7 +537,7 @@ class YourContactForm(RegistrarForm):
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_applications"):
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:
@ -566,7 +573,14 @@ class YourContactForm(RegistrarForm):
)
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",
@ -578,13 +592,13 @@ class OtherContactsYesNoForm(RegistrarForm):
def __init__(self, *args, **kwargs):
"""Extend the initialization of the form from RegistrarForm __init__"""
super().__init__(*args, **kwargs)
# set the initial value based on attributes of application
if self.application and self.application.has_other_contacts():
# set the initial value based on attributes of domain request
if self.domain_request and self.domain_request.has_other_contacts():
initial_value = True
elif self.application and self.application.has_rationale():
elif self.domain_request and self.domain_request.has_rationale():
initial_value = False
else:
# No pre-selection for new applications
# No pre-selection for new domain requests
initial_value = None
self.fields["has_other_contacts"] = forms.TypedChoiceField(
@ -621,10 +635,17 @@ class OtherContactsForm(RegistrarForm):
)
email = forms.EmailField(
label="Email",
max_length=None,
error_messages={
"required": ("Enter an email address in the required format, like name@example.com."),
"invalid": ("Enter an email address in the required format, like name@example.com."),
},
validators=[
MaxLengthValidator(
320,
message="Response must be less than 320 characters.",
)
],
)
phone = PhoneNumberField(
label="Phone",
@ -687,7 +708,7 @@ class BaseOtherContactsFormSet(RegistrarFormSet):
this case, all forms in formset are marked for deletion. Both of these conditions
must co-exist.
Also, other_contacts have db relationships to multiple db objects. When attempting
to delete an other_contact from an application, those db relationships must be
to delete an other_contact from a domain request, those db relationships must be
tested and handled.
"""
@ -701,7 +722,7 @@ class BaseOtherContactsFormSet(RegistrarFormSet):
Override __init__ for RegistrarFormSet.
"""
self.formset_data_marked_for_deletion = False
self.application = kwargs.pop("application", None)
self.domain_request = kwargs.pop("domain_request", None)
super(RegistrarFormSet, self).__init__(*args, **kwargs)
# quick workaround to ensure that the HTML `required`
# attribute shows up on required fields for the first form
@ -722,7 +743,7 @@ class BaseOtherContactsFormSet(RegistrarFormSet):
cleaned.pop("DELETE")
return cleaned
def to_database(self, obj: DomainApplication):
def to_database(self, obj: DomainRequest):
self._to_database(obj, self.JOIN, self.should_delete, self.pre_update, self.pre_create)
@classmethod
@ -830,8 +851,8 @@ class AnythingElseForm(RegistrarForm):
widget=forms.Textarea(),
validators=[
MaxLengthValidator(
1000,
message="Response must be less than 1000 characters.",
2000,
message="Response must be less than 2000 characters.",
)
],
)

View file

@ -0,0 +1,105 @@
"""Generates current-metadata.csv then uploads to S3 + sends email"""
import logging
import os
import pyzipper
from datetime import datetime
from django.core.management import BaseCommand
from django.conf import settings
from registrar.utility import csv_export
from registrar.utility.s3_bucket import S3ClientHelper
from ...utility.email import send_templated_email
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = (
"Generates and uploads a domain-metadata.csv file to our S3 bucket "
"which is based off of all existing Domains."
)
def add_arguments(self, parser):
"""Add our two filename arguments."""
parser.add_argument("--directory", default="migrationdata", help="Desired directory")
parser.add_argument(
"--checkpath",
default=True,
help="Flag that determines if we do a check for os.path.exists. Used for test cases",
)
def handle(self, **options):
"""Grabs the directory then creates domain-metadata.csv in that directory"""
file_name = "domain-metadata.csv"
# Ensures a slash is added
directory = os.path.join(options.get("directory"), "")
check_path = options.get("checkpath")
logger.info("Generating report...")
try:
self.email_current_metadata_report(directory, file_name, check_path)
except Exception as err:
# TODO - #1317: Notify operations when auto report generation fails
raise err
else:
logger.info(f"Success! Created {file_name} and successfully sent out an email!")
def email_current_metadata_report(self, directory, file_name, check_path):
"""Creates a current-metadata.csv file under the specified directory,
then uploads it to a AWS S3 bucket. This is done for resiliency
reasons in the event our application goes down and/or the email
cannot send -- we'll still be able to grab info from the S3
instance"""
s3_client = S3ClientHelper()
file_path = os.path.join(directory, file_name)
# Generate a file locally for upload
with open(file_path, "w") as file:
csv_export.export_data_type_to_csv(file)
if check_path and not os.path.exists(file_path):
raise FileNotFoundError(f"Could not find newly created file at '{file_path}'")
s3_client.upload_file(file_path, file_name)
# Set zip file name
current_date = datetime.now().strftime("%m%d%Y")
current_filename = f"domain-metadata-{current_date}.zip"
# Pre-set zip file name
encrypted_metadata_output = current_filename
# Set context for the subject
current_date_str = datetime.now().strftime("%Y-%m-%d")
# Encrypt the metadata
encrypted_metadata_in_bytes = self._encrypt_metadata(
s3_client.get_file(file_name), encrypted_metadata_output, str.encode(settings.SECRET_ENCRYPT_METADATA)
)
# Send the metadata file that is zipped
send_templated_email(
template_name="emails/metadata_body.txt",
subject_template_name="emails/metadata_subject.txt",
to_address=settings.DEFAULT_FROM_EMAIL,
context={"current_date_str": current_date_str},
attachment_file=encrypted_metadata_in_bytes,
)
def _encrypt_metadata(self, input_file, output_file, password):
"""Helper function for encrypting the attachment file"""
current_date = datetime.now().strftime("%m%d%Y")
current_filename = f"domain-metadata-{current_date}.csv"
# Using ZIP_DEFLATED bc it's a more common compression method supported by most zip utilities and faster
# We could also use compression=pyzipper.ZIP_LZMA if we are looking for smaller file size
with pyzipper.AESZipFile(
output_file, "w", compression=pyzipper.ZIP_DEFLATED, encryption=pyzipper.WZ_AES
) as f_out:
f_out.setpassword(password)
f_out.writestr(current_filename, input_file)
with open(output_file, "rb") as file_data:
attachment_in_bytes = file_data.read()
return attachment_in_bytes

View file

@ -5,7 +5,7 @@ from auditlog.context import disable_auditlog # type: ignore
from registrar.fixtures_users import UserFixture
from registrar.fixtures_applications import DomainApplicationFixture, DomainFixture
from registrar.fixtures_domain_requests import DomainRequestFixture, DomainFixture
logger = logging.getLogger(__name__)
@ -16,6 +16,6 @@ class Command(BaseCommand):
# https://github.com/jazzband/django-auditlog/issues/17
with disable_auditlog():
UserFixture.load()
DomainApplicationFixture.load()
DomainRequestFixture.load()
DomainFixture.load()
logger.info("All fixtures loaded.")

View file

@ -0,0 +1,237 @@
import argparse
import logging
import os
from typing import List
from django.core.management import BaseCommand
from registrar.management.commands.utility.terminal_helper import TerminalColors, TerminalHelper, ScriptDataHelper
from registrar.models import DomainInformation, DomainRequest
from registrar.models.utility.generic_helper import CreateOrUpdateOrganizationTypeHelper
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = (
"Loops through each valid DomainInformation and DomainRequest object and updates its organization_type value. "
"A valid DomainInformation/DomainRequest in this sense is one that has the value None for organization_type. "
"In other words, we populate the organization_type field if it is not already populated."
)
def __init__(self):
super().__init__()
# Get lists for DomainRequest
self.request_to_update: List[DomainRequest] = []
self.request_failed_to_update: List[DomainRequest] = []
self.request_skipped: List[DomainRequest] = []
# Get lists for DomainInformation
self.di_to_update: List[DomainInformation] = []
self.di_failed_to_update: List[DomainInformation] = []
self.di_skipped: List[DomainInformation] = []
# Define a global variable for all domains with election offices
self.domains_with_election_boards_set = set()
def add_arguments(self, parser):
"""Adds command line arguments"""
parser.add_argument(
"domain_election_board_filename",
help=("A file that contains" " all the domains that are election offices."),
)
def handle(self, domain_election_board_filename, **kwargs):
"""Loops through each valid Domain object and updates its first_created value"""
# Check if the provided file path is valid
if not os.path.isfile(domain_election_board_filename):
raise argparse.ArgumentTypeError(f"Invalid file path '{domain_election_board_filename}'")
# Read the election office csv
self.read_election_board_file(domain_election_board_filename)
domain_requests = DomainRequest.objects.filter(organization_type__isnull=True)
# Code execution will stop here if the user prompts "N"
TerminalHelper.prompt_for_execution(
system_exit_on_terminate=True,
info_to_inspect=f"""
==Proposed Changes==
Number of DomainRequest objects to change: {len(domain_requests)}
Organization_type data will be added for all of these fields.
""",
prompt_title="Do you wish to process DomainRequest?",
)
logger.info("Updating DomainRequest(s)...")
self.update_domain_requests(domain_requests)
# We should actually be targeting all fields with no value for organization type,
# but do have a value for generic_org_type. This is because there is data that we can infer.
domain_infos = DomainInformation.objects.filter(organization_type__isnull=True)
# Code execution will stop here if the user prompts "N"
TerminalHelper.prompt_for_execution(
system_exit_on_terminate=True,
info_to_inspect=f"""
==Proposed Changes==
Number of DomainInformation objects to change: {len(domain_infos)}
Organization_type data will be added for all of these fields.
""",
prompt_title="Do you wish to process DomainInformation?",
)
logger.info("Updating DomainInformation(s)...")
self.update_domain_informations(domain_infos)
def read_election_board_file(self, domain_election_board_filename):
"""
Reads the election board file and adds each parsed domain to self.domains_with_election_boards_set.
As previously implied, this file contains information about Domains which have election boards.
The file must adhere to this format:
```
domain1.gov
domain2.gov
domain3.gov
```
(and so on)
"""
with open(domain_election_board_filename, "r") as file:
for line in file:
# Remove any leading/trailing whitespace
domain = line.strip()
if domain not in self.domains_with_election_boards_set:
self.domains_with_election_boards_set.add(domain)
def update_domain_requests(self, domain_requests):
"""
Updates the organization_type for a list of DomainRequest objects using the `sync_organization_type` function.
Results are then logged.
This function updates the following variables:
- self.request_to_update list is appended to if the field was updated successfully.
- self.request_skipped list is appended to if the field has `None` for `request.generic_org_type`.
- self.request_failed_to_update list is appended to if an exception is caught during update.
"""
for request in domain_requests:
try:
if request.generic_org_type is not None:
domain_name = None
if request.requested_domain is not None and request.requested_domain.name is not None:
domain_name = request.requested_domain.name
request_is_approved = request.status == DomainRequest.DomainRequestStatus.APPROVED
if request_is_approved and domain_name is not None and not request.is_election_board:
request.is_election_board = domain_name in self.domains_with_election_boards_set
self.sync_organization_type(DomainRequest, request)
self.request_to_update.append(request)
logger.info(f"Updating {request} => {request.organization_type}")
else:
self.request_skipped.append(request)
logger.warning(f"Skipped updating {request}. No generic_org_type was found.")
except Exception as err:
self.request_failed_to_update.append(request)
logger.error(err)
logger.error(f"{TerminalColors.FAIL}" f"Failed to update {request}" f"{TerminalColors.ENDC}")
# Do a bulk update on the organization_type field
ScriptDataHelper.bulk_update_fields(
DomainRequest, self.request_to_update, ["organization_type", "is_election_board", "generic_org_type"]
)
# Log what happened
log_header = "============= FINISHED UPDATE FOR DOMAINREQUEST ==============="
TerminalHelper.log_script_run_summary(
self.request_to_update, self.request_failed_to_update, self.request_skipped, True, log_header
)
update_skipped_count = len(self.request_to_update)
if update_skipped_count > 0:
logger.warning(
f"""{TerminalColors.MAGENTA}
Note: Entries are skipped when generic_org_type is None
{TerminalColors.ENDC}
"""
)
def update_domain_informations(self, domain_informations):
"""
Updates the organization_type for a list of DomainInformation objects
and updates is_election_board if the domain is in the provided csv.
Results are then logged.
This function updates the following variables:
- self.di_to_update list is appended to if the field was updated successfully.
- self.di_skipped list is appended to if the field has `None` for `request.generic_org_type`.
- self.di_failed_to_update list is appended to if an exception is caught during update.
"""
for info in domain_informations:
try:
if info.generic_org_type is not None:
domain_name = info.domain.name
if not info.is_election_board:
info.is_election_board = domain_name in self.domains_with_election_boards_set
self.sync_organization_type(DomainInformation, info)
self.di_to_update.append(info)
logger.info(f"Updating {info} => {info.organization_type}")
else:
self.di_skipped.append(info)
logger.warning(f"Skipped updating {info}. No generic_org_type was found.")
except Exception as err:
self.di_failed_to_update.append(info)
logger.error(err)
logger.error(f"{TerminalColors.FAIL}" f"Failed to update {info}" f"{TerminalColors.ENDC}")
# Do a bulk update on the organization_type field
ScriptDataHelper.bulk_update_fields(
DomainInformation, self.di_to_update, ["organization_type", "is_election_board", "generic_org_type"]
)
# Log what happened
log_header = "============= FINISHED UPDATE FOR DOMAININFORMATION ==============="
TerminalHelper.log_script_run_summary(
self.di_to_update, self.di_failed_to_update, self.di_skipped, True, log_header
)
update_skipped_count = len(self.di_skipped)
if update_skipped_count > 0:
logger.warning(
f"""{TerminalColors.MAGENTA}
Note: Entries are skipped when generic_org_type is None
{TerminalColors.ENDC}
"""
)
def sync_organization_type(self, sender, instance):
"""
Updates the organization_type (without saving) to match
the is_election_board and generic_organization_type fields.
"""
# Define mappings between generic org and election org.
# These have to be defined here, as you'd get a cyclical import error
# otherwise.
# For any given organization type, return the "_ELECTION" enum equivalent.
# For example: STATE_OR_TERRITORY => STATE_OR_TERRITORY_ELECTION
generic_org_map = DomainRequest.OrgChoicesElectionOffice.get_org_generic_to_org_election()
# For any given "_election" variant, return the base org type.
# For example: STATE_OR_TERRITORY_ELECTION => STATE_OR_TERRITORY
election_org_map = DomainRequest.OrgChoicesElectionOffice.get_org_election_to_org_generic()
# Manages the "organization_type" variable and keeps in sync with
# "is_election_board" and "generic_organization_type"
org_type_helper = CreateOrUpdateOrganizationTypeHelper(
sender=sender,
instance=instance,
generic_org_to_org_map=generic_org_map,
election_org_to_generic_org_map=election_org_map,
)
org_type_helper.create_or_update_organization_type(force_update=True)

View file

@ -15,7 +15,7 @@ from registrar.management.commands.utility.terminal_helper import (
TerminalHelper,
)
from registrar.models.contact import Contact
from registrar.models.domain_application import DomainApplication
from registrar.models.domain_request import DomainRequest
from registrar.models.domain_information import DomainInformation
from registrar.models.user import User
@ -332,7 +332,7 @@ class Command(BaseCommand):
updated = False
fields_to_update = [
"organization_type",
"generic_org_type",
"federal_type",
"federal_agency",
"organization_name",
@ -400,7 +400,7 @@ class Command(BaseCommand):
if debug_on:
logger.info(f"Contact created: {contact}")
org_type_current = transition_domain.organization_type
org_type_current = transition_domain.generic_org_type
match org_type_current:
case "Federal":
org_type = ("federal", "Federal")
@ -431,7 +431,7 @@ class Command(BaseCommand):
}
if valid_org_type:
new_domain_info_data["organization_type"] = org_type[0]
new_domain_info_data["generic_org_type"] = org_type[0]
elif debug_on:
logger.debug(f"No org type found on {domain.name}")
@ -817,9 +817,9 @@ class Command(BaseCommand):
raise Exception(f"Domain {existing_domain} wants to be added" "but doesn't exist in the DB")
invitation.save()
valid_org_choices = [(name, value) for name, value in DomainApplication.OrganizationChoices.choices]
valid_fed_choices = [value for name, value in DomainApplication.BranchChoices.choices]
valid_agency_choices = DomainApplication.AGENCIES
valid_org_choices = [(name, value) for name, value in DomainRequest.OrganizationChoices.choices]
valid_fed_choices = [value for name, value in DomainRequest.BranchChoices.choices]
valid_agency_choices = DomainRequest.AGENCIES
# ======================================================
# ================= DOMAIN INFORMATION =================
logger.info(

View file

@ -200,7 +200,7 @@ class LoadExtraTransitionDomain:
updated_fields = [
"organization_name",
"organization_type",
"generic_org_type",
"federal_type",
"federal_agency",
"first_name",
@ -412,7 +412,7 @@ class LoadExtraTransitionDomain:
return transition_domain
def parse_domain_type_data(self, domain_name, transition_domain: TransitionDomain) -> TransitionDomain:
"""Grabs organization_type and federal_type from the parsed files
"""Grabs generic_org_type and federal_type from the parsed files
and associates it with a transition_domain object, then returns that object."""
if not isinstance(transition_domain, TransitionDomain):
raise ValueError("Not a valid object, must be TransitionDomain")
@ -439,7 +439,7 @@ class LoadExtraTransitionDomain:
raise ValueError("Found invalid data on DOMAIN_ADHOC")
# Then, just grab the organization type.
new_organization_type = domain_type[0].strip()
new_generic_org_type = domain_type[0].strip()
# Check if this domain_type is active or not.
# If not, we don't want to add this.
@ -455,8 +455,8 @@ class LoadExtraTransitionDomain:
# Are we updating data that already exists,
# or are we adding new data in its place?
organization_type_exists = (
transition_domain.organization_type is not None and transition_domain.organization_type.strip() != ""
generic_org_type_exists = (
transition_domain.generic_org_type is not None and transition_domain.generic_org_type.strip() != ""
)
federal_type_exists = (
transition_domain.federal_type is not None and transition_domain.federal_type.strip() != ""
@ -467,20 +467,20 @@ class LoadExtraTransitionDomain:
is_federal = domain_type_length == 2
if is_federal:
new_federal_type = domain_type[1].strip()
transition_domain.organization_type = new_organization_type
transition_domain.generic_org_type = new_generic_org_type
transition_domain.federal_type = new_federal_type
else:
transition_domain.organization_type = new_organization_type
transition_domain.generic_org_type = new_generic_org_type
transition_domain.federal_type = None
# Logs if we either added to this property,
# or modified it.
self._add_or_change_message(
EnumFilenames.DOMAIN_ADHOC,
"organization_type",
transition_domain.organization_type,
"generic_org_type",
transition_domain.generic_org_type,
domain_name,
organization_type_exists,
generic_org_type_exists,
)
self._add_or_change_message(

View file

@ -49,6 +49,7 @@ class ScriptDataHelper:
Usage:
bulk_update_fields(Domain, page.object_list, ["first_ready"])
"""
logger.info(f"{TerminalColors.YELLOW} Bulk updating fields... {TerminalColors.ENDC}")
# Create a Paginator object. Bulk_update on the full dataset
# is too memory intensive for our current app config, so we can chunk this data instead.
paginator = Paginator(update_list, batch_size)
@ -59,13 +60,16 @@ class ScriptDataHelper:
class TerminalHelper:
@staticmethod
def log_script_run_summary(to_update, failed_to_update, skipped, debug: bool):
def log_script_run_summary(to_update, failed_to_update, skipped, debug: bool, log_header=None):
"""Prints success, failed, and skipped counts, as well as
all affected objects."""
update_success_count = len(to_update)
update_failed_count = len(failed_to_update)
update_skipped_count = len(skipped)
if log_header is None:
log_header = "============= FINISHED ==============="
# Prepare debug messages
debug_messages = {
"success": (f"{TerminalColors.OKCYAN}Updated: {to_update}{TerminalColors.ENDC}\n"),
@ -85,7 +89,7 @@ class TerminalHelper:
if update_failed_count == 0 and update_skipped_count == 0:
logger.info(
f"""{TerminalColors.OKGREEN}
============= FINISHED ===============
{log_header}
Updated {update_success_count} entries
{TerminalColors.ENDC}
"""
@ -93,7 +97,7 @@ class TerminalHelper:
elif update_failed_count == 0:
logger.warning(
f"""{TerminalColors.YELLOW}
============= FINISHED ===============
{log_header}
Updated {update_success_count} entries
----- SOME DATA WAS INVALID (NEEDS MANUAL PATCHING) -----
Skipped updating {update_skipped_count} entries
@ -103,7 +107,7 @@ class TerminalHelper:
else:
logger.error(
f"""{TerminalColors.FAIL}
============= FINISHED ===============
{log_header}
Updated {update_success_count} entries
----- UPDATE FAILED -----
Failed to update {update_failed_count} entries,

View file

@ -0,0 +1,110 @@
# Generated by Django 4.2.10 on 2024-03-12 16:50
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("registrar", "0072_alter_publiccontact_fax_alter_publiccontact_voice"),
]
operations = [
migrations.AlterField(
model_name="domainapplication",
name="approved_domain",
field=models.OneToOneField(
blank=True,
help_text="The approved domain",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="domain_request",
to="registrar.domain",
),
),
migrations.AlterField(
model_name="domainapplication",
name="creator",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="domain_requests_created",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="domainapplication",
name="investigator",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="domain_requests_investigating",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="domainapplication",
name="other_contacts",
field=models.ManyToManyField(
blank=True, related_name="contact_domain_requests", to="registrar.contact", verbose_name="contacts"
),
),
migrations.AlterField(
model_name="domainapplication",
name="requested_domain",
field=models.OneToOneField(
blank=True,
help_text="The requested domain",
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="domain_request",
to="registrar.draftdomain",
),
),
migrations.AlterField(
model_name="domainapplication",
name="submitter",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="submitted_domain_requests",
to="registrar.contact",
),
),
migrations.AlterField(
model_name="domaininformation",
name="domain_application",
field=models.OneToOneField(
blank=True,
help_text="Associated domain request",
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="DomainRequest_info",
to="registrar.domainapplication",
),
),
migrations.AlterField(
model_name="domaininformation",
name="other_contacts",
field=models.ManyToManyField(
blank=True,
related_name="contact_domain_requests_information",
to="registrar.contact",
verbose_name="contacts",
),
),
migrations.AlterField(
model_name="domaininformation",
name="submitter",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="submitted_domain_requests_information",
to="registrar.contact",
),
),
]

View file

@ -0,0 +1,22 @@
# Generated by Django 4.2.10 on 2024-03-12 16:53
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("registrar", "0073_alter_domainapplication_approved_domain_and_more"),
]
operations = [
migrations.RenameModel(
old_name="DomainApplication",
new_name="DomainRequest",
),
migrations.RenameField(
model_name="domaininformation",
old_name="domain_application",
new_name="domain_request",
),
]

View file

@ -0,0 +1,37 @@
# This migration creates the create_full_access_group and create_cisa_analyst_group groups
# It is dependent on 0035 (which populates ContentType and Permissions)
# 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", "0074_rename_domainapplication_domainrequest_and_more"),
]
operations = [
migrations.RunPython(
create_groups,
reverse_code=migrations.RunPython.noop,
atomic=True,
),
]

View file

@ -0,0 +1,40 @@
# Generated by Django 4.2.10 on 2024-03-13 21:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0075_create_groups_v08"),
]
operations = [
migrations.AlterField(
model_name="domainrequest",
name="current_websites",
field=models.ManyToManyField(
blank=True, related_name="current+", to="registrar.website", verbose_name="Current websites"
),
),
migrations.AlterField(
model_name="domainrequest",
name="other_contacts",
field=models.ManyToManyField(
blank=True,
related_name="contact_domain_requests",
to="registrar.contact",
verbose_name="Other employees",
),
),
migrations.AlterField(
model_name="domaininformation",
name="other_contacts",
field=models.ManyToManyField(
blank=True,
related_name="contact_domain_requests_information",
to="registrar.contact",
verbose_name="Other employees",
),
),
]

View file

@ -0,0 +1,35 @@
# Generated by Django 4.2.10 on 2024-03-15 18:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0076_alter_domainrequest_current_websites_and_more"),
]
operations = [
migrations.AlterField(
model_name="publiccontact",
name="fax",
field=models.CharField(
blank=True, help_text="Contact's fax number (null ok). Must be in ITU.E164.2005 format.", null=True
),
),
migrations.AlterField(
model_name="publiccontact",
name="org",
field=models.CharField(blank=True, help_text="Contact's organization (null ok)", null=True),
),
migrations.AlterField(
model_name="publiccontact",
name="street2",
field=models.CharField(blank=True, help_text="Contact's street (null ok)", null=True),
),
migrations.AlterField(
model_name="publiccontact",
name="street3",
field=models.CharField(blank=True, help_text="Contact's street (null ok)", null=True),
),
]

View file

@ -0,0 +1,28 @@
# Generated by Django 4.2.10 on 2024-03-20 21:14
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("registrar", "0077_alter_publiccontact_fax_alter_publiccontact_org_and_more"),
]
operations = [
migrations.RenameField(
model_name="domaininformation",
old_name="organization_type",
new_name="generic_org_type",
),
migrations.RenameField(
model_name="domainrequest",
old_name="organization_type",
new_name="generic_org_type",
),
migrations.RenameField(
model_name="transitiondomain",
old_name="organization_type",
new_name="generic_org_type",
),
]

View file

@ -0,0 +1,38 @@
# Generated by Django 4.2.10 on 2024-03-22 22:18
from django.db import migrations, models
from registrar.models import FederalAgency
from typing import Any
# For linting: RunPython expects a function reference.
def create_federal_agencies(apps, schema_editor) -> Any:
FederalAgency.create_federal_agencies(apps, schema_editor)
class Migration(migrations.Migration):
dependencies = [
("registrar", "0078_rename_organization_type_domaininformation_generic_org_type_and_more"),
]
operations = [
migrations.CreateModel(
name="FederalAgency",
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)),
("agency", models.CharField(blank=True, help_text="Federal agency", null=True)),
],
options={
"verbose_name": "Federal agency",
"verbose_name_plural": "Federal agencies",
},
),
migrations.RunPython(
create_federal_agencies,
reverse_code=migrations.RunPython.noop,
atomic=True,
),
]

View 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", "0079_create_federal_agencies_v01"),
]
operations = [
migrations.RunPython(
create_groups,
reverse_code=migrations.RunPython.noop,
atomic=True,
),
]

View 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", "0080_create_groups_v09"),
]
operations = [
migrations.RunPython(
create_groups,
reverse_code=migrations.RunPython.noop,
atomic=True,
),
]

View file

@ -0,0 +1,83 @@
# Generated by Django 4.2.10 on 2024-04-01 15:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0081_create_groups_v10"),
]
operations = [
migrations.AddField(
model_name="domaininformation",
name="organization_type",
field=models.CharField(
blank=True,
choices=[
("federal", "Federal"),
("interstate", "Interstate"),
("state_or_territory", "State or territory"),
("tribal", "Tribal"),
("county", "County"),
("city", "City"),
("special_district", "Special district"),
("school_district", "School district"),
("state_or_territory_election", "State or territory - Election"),
("tribal_election", "Tribal - Election"),
("county_election", "County - Election"),
("city_election", "City - Election"),
("special_district_election", "Special district - Election"),
],
help_text="Type of organization - Election office",
max_length=255,
null=True,
),
),
migrations.AddField(
model_name="domainrequest",
name="organization_type",
field=models.CharField(
blank=True,
choices=[
("federal", "Federal"),
("interstate", "Interstate"),
("state_or_territory", "State or territory"),
("tribal", "Tribal"),
("county", "County"),
("city", "City"),
("special_district", "Special district"),
("school_district", "School district"),
("state_or_territory_election", "State or territory - Election"),
("tribal_election", "Tribal - Election"),
("county_election", "County - Election"),
("city_election", "City - Election"),
("special_district_election", "Special district - Election"),
],
help_text="Type of organization - Election office",
max_length=255,
null=True,
),
),
migrations.AlterField(
model_name="domaininformation",
name="generic_org_type",
field=models.CharField(
blank=True,
choices=[
("federal", "Federal"),
("interstate", "Interstate"),
("state_or_territory", "State or territory"),
("tribal", "Tribal"),
("county", "County"),
("city", "City"),
("special_district", "Special district"),
("school_district", "School district"),
],
help_text="Type of organization",
max_length=255,
null=True,
),
),
]

View file

@ -0,0 +1,23 @@
# Generated by Django 4.2.10 on 2024-04-09 16:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0082_domaininformation_organization_type_and_more"),
]
operations = [
migrations.AlterField(
model_name="contact",
name="email",
field=models.EmailField(blank=True, db_index=True, max_length=320, null=True),
),
migrations.AlterField(
model_name="publiccontact",
name="email",
field=models.EmailField(help_text="Contact's email address", max_length=320),
),
]

View 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", "0083_alter_contact_email_alter_publiccontact_email"),
]
operations = [
migrations.RunPython(
create_groups,
reverse_code=migrations.RunPython.noop,
atomic=True,
),
]

View file

@ -0,0 +1,382 @@
# Generated by Django 4.2.10 on 2024-04-18 18:01
import django.core.validators
from django.db import migrations, models
import django_fsm
import registrar.models.utility.domain_field
class Migration(migrations.Migration):
dependencies = [
("registrar", "0084_create_groups_v11"),
]
operations = [
migrations.AlterField(
model_name="contact",
name="first_name",
field=models.CharField(blank=True, db_index=True, null=True, verbose_name="first name"),
),
migrations.AlterField(
model_name="contact",
name="last_name",
field=models.CharField(blank=True, db_index=True, null=True, verbose_name="last name"),
),
migrations.AlterField(
model_name="contact",
name="title",
field=models.CharField(blank=True, null=True, verbose_name="title / role"),
),
migrations.AlterField(
model_name="domain",
name="deleted",
field=models.DateField(editable=False, help_text="Deleted at date", null=True, verbose_name="deleted on"),
),
migrations.AlterField(
model_name="domain",
name="first_ready",
field=models.DateField(
editable=False,
help_text="The last time this domain moved into the READY state",
null=True,
verbose_name="first ready on",
),
),
migrations.AlterField(
model_name="domain",
name="name",
field=registrar.models.utility.domain_field.DomainField(
default=None,
help_text="Fully qualified domain name",
max_length=253,
unique=True,
verbose_name="domain",
),
),
migrations.AlterField(
model_name="domain",
name="state",
field=django_fsm.FSMField(
choices=[
("unknown", "Unknown"),
("dns needed", "Dns needed"),
("ready", "Ready"),
("on hold", "On hold"),
("deleted", "Deleted"),
],
default="unknown",
help_text="Very basic info about the lifecycle of this domain object",
max_length=21,
protected=True,
verbose_name="domain state",
),
),
migrations.AlterField(
model_name="domaininformation",
name="address_line1",
field=models.CharField(blank=True, help_text="Street address", null=True, verbose_name="address line 1"),
),
migrations.AlterField(
model_name="domaininformation",
name="address_line2",
field=models.CharField(
blank=True, help_text="Street address line 2 (optional)", null=True, verbose_name="address line 2"
),
),
migrations.AlterField(
model_name="domaininformation",
name="is_election_board",
field=models.BooleanField(
blank=True,
help_text="Is your organization an election office?",
null=True,
verbose_name="election office",
),
),
migrations.AlterField(
model_name="domaininformation",
name="state_territory",
field=models.CharField(
blank=True,
choices=[
("AL", "Alabama (AL)"),
("AK", "Alaska (AK)"),
("AS", "American Samoa (AS)"),
("AZ", "Arizona (AZ)"),
("AR", "Arkansas (AR)"),
("CA", "California (CA)"),
("CO", "Colorado (CO)"),
("CT", "Connecticut (CT)"),
("DE", "Delaware (DE)"),
("DC", "District of Columbia (DC)"),
("FL", "Florida (FL)"),
("GA", "Georgia (GA)"),
("GU", "Guam (GU)"),
("HI", "Hawaii (HI)"),
("ID", "Idaho (ID)"),
("IL", "Illinois (IL)"),
("IN", "Indiana (IN)"),
("IA", "Iowa (IA)"),
("KS", "Kansas (KS)"),
("KY", "Kentucky (KY)"),
("LA", "Louisiana (LA)"),
("ME", "Maine (ME)"),
("MD", "Maryland (MD)"),
("MA", "Massachusetts (MA)"),
("MI", "Michigan (MI)"),
("MN", "Minnesota (MN)"),
("MS", "Mississippi (MS)"),
("MO", "Missouri (MO)"),
("MT", "Montana (MT)"),
("NE", "Nebraska (NE)"),
("NV", "Nevada (NV)"),
("NH", "New Hampshire (NH)"),
("NJ", "New Jersey (NJ)"),
("NM", "New Mexico (NM)"),
("NY", "New York (NY)"),
("NC", "North Carolina (NC)"),
("ND", "North Dakota (ND)"),
("MP", "Northern Mariana Islands (MP)"),
("OH", "Ohio (OH)"),
("OK", "Oklahoma (OK)"),
("OR", "Oregon (OR)"),
("PA", "Pennsylvania (PA)"),
("PR", "Puerto Rico (PR)"),
("RI", "Rhode Island (RI)"),
("SC", "South Carolina (SC)"),
("SD", "South Dakota (SD)"),
("TN", "Tennessee (TN)"),
("TX", "Texas (TX)"),
("UM", "United States Minor Outlying Islands (UM)"),
("UT", "Utah (UT)"),
("VT", "Vermont (VT)"),
("VI", "Virgin Islands (VI)"),
("VA", "Virginia (VA)"),
("WA", "Washington (WA)"),
("WV", "West Virginia (WV)"),
("WI", "Wisconsin (WI)"),
("WY", "Wyoming (WY)"),
("AA", "Armed Forces Americas (AA)"),
("AE", "Armed Forces Africa, Canada, Europe, Middle East (AE)"),
("AP", "Armed Forces Pacific (AP)"),
],
help_text="State, territory, or military post",
max_length=2,
null=True,
verbose_name="state / territory",
),
),
migrations.AlterField(
model_name="domaininformation",
name="urbanization",
field=models.CharField(
blank=True,
help_text="Urbanization (required for Puerto Rico only)",
null=True,
verbose_name="urbanization",
),
),
migrations.AlterField(
model_name="domaininformation",
name="zipcode",
field=models.CharField(
blank=True, db_index=True, help_text="Zip code", max_length=10, null=True, verbose_name="zip code"
),
),
migrations.AlterField(
model_name="domainrequest",
name="is_election_board",
field=models.BooleanField(
blank=True,
help_text="Is your organization an election office?",
null=True,
verbose_name="election office",
),
),
migrations.AlterField(
model_name="domainrequest",
name="state_territory",
field=models.CharField(
blank=True,
choices=[
("AL", "Alabama (AL)"),
("AK", "Alaska (AK)"),
("AS", "American Samoa (AS)"),
("AZ", "Arizona (AZ)"),
("AR", "Arkansas (AR)"),
("CA", "California (CA)"),
("CO", "Colorado (CO)"),
("CT", "Connecticut (CT)"),
("DE", "Delaware (DE)"),
("DC", "District of Columbia (DC)"),
("FL", "Florida (FL)"),
("GA", "Georgia (GA)"),
("GU", "Guam (GU)"),
("HI", "Hawaii (HI)"),
("ID", "Idaho (ID)"),
("IL", "Illinois (IL)"),
("IN", "Indiana (IN)"),
("IA", "Iowa (IA)"),
("KS", "Kansas (KS)"),
("KY", "Kentucky (KY)"),
("LA", "Louisiana (LA)"),
("ME", "Maine (ME)"),
("MD", "Maryland (MD)"),
("MA", "Massachusetts (MA)"),
("MI", "Michigan (MI)"),
("MN", "Minnesota (MN)"),
("MS", "Mississippi (MS)"),
("MO", "Missouri (MO)"),
("MT", "Montana (MT)"),
("NE", "Nebraska (NE)"),
("NV", "Nevada (NV)"),
("NH", "New Hampshire (NH)"),
("NJ", "New Jersey (NJ)"),
("NM", "New Mexico (NM)"),
("NY", "New York (NY)"),
("NC", "North Carolina (NC)"),
("ND", "North Dakota (ND)"),
("MP", "Northern Mariana Islands (MP)"),
("OH", "Ohio (OH)"),
("OK", "Oklahoma (OK)"),
("OR", "Oregon (OR)"),
("PA", "Pennsylvania (PA)"),
("PR", "Puerto Rico (PR)"),
("RI", "Rhode Island (RI)"),
("SC", "South Carolina (SC)"),
("SD", "South Dakota (SD)"),
("TN", "Tennessee (TN)"),
("TX", "Texas (TX)"),
("UM", "United States Minor Outlying Islands (UM)"),
("UT", "Utah (UT)"),
("VT", "Vermont (VT)"),
("VI", "Virgin Islands (VI)"),
("VA", "Virginia (VA)"),
("WA", "Washington (WA)"),
("WV", "West Virginia (WV)"),
("WI", "Wisconsin (WI)"),
("WY", "Wyoming (WY)"),
("AA", "Armed Forces Americas (AA)"),
("AE", "Armed Forces Africa, Canada, Europe, Middle East (AE)"),
("AP", "Armed Forces Pacific (AP)"),
],
help_text="State, territory, or military post",
max_length=2,
null=True,
verbose_name="state / territory",
),
),
migrations.AlterField(
model_name="domainrequest",
name="submission_date",
field=models.DateField(
blank=True, default=None, help_text="Date submitted", null=True, verbose_name="submitted at"
),
),
migrations.AlterField(
model_name="domainrequest",
name="zipcode",
field=models.CharField(
blank=True, db_index=True, help_text="Zip code", max_length=10, null=True, verbose_name="zip code"
),
),
migrations.AlterField(
model_name="draftdomain",
name="name",
field=models.CharField(
default=None, help_text="Fully qualified domain name", max_length=253, verbose_name="requested domain"
),
),
migrations.AlterField(
model_name="host",
name="name",
field=models.CharField(
default=None, help_text="Fully qualified domain name", max_length=253, verbose_name="host name"
),
),
migrations.AlterField(
model_name="hostip",
name="address",
field=models.CharField(
default=None,
help_text="IP address",
max_length=46,
validators=[django.core.validators.validate_ipv46_address],
verbose_name="IP address",
),
),
migrations.AlterField(
model_name="transitiondomain",
name="domain_name",
field=models.CharField(blank=True, null=True, verbose_name="domain"),
),
migrations.AlterField(
model_name="transitiondomain",
name="first_name",
field=models.CharField(
blank=True, db_index=True, help_text="First name / given name", null=True, verbose_name="first name"
),
),
migrations.AlterField(
model_name="transitiondomain",
name="processed",
field=models.BooleanField(
default=True,
help_text="Indicates whether this TransitionDomain was already processed",
verbose_name="processed",
),
),
migrations.AlterField(
model_name="transitiondomain",
name="state_territory",
field=models.CharField(
blank=True,
help_text="State, territory, or military post",
max_length=2,
null=True,
verbose_name="state / territory",
),
),
migrations.AlterField(
model_name="transitiondomain",
name="status",
field=models.CharField(
blank=True,
choices=[("ready", "Ready"), ("on hold", "On hold"), ("unknown", "Unknown")],
default="ready",
help_text="domain status during the transfer",
max_length=255,
verbose_name="status",
),
),
migrations.AlterField(
model_name="transitiondomain",
name="title",
field=models.CharField(blank=True, help_text="Title", null=True, verbose_name="title / role"),
),
migrations.AlterField(
model_name="transitiondomain",
name="username",
field=models.CharField(help_text="Username - this will be an email address", verbose_name="username"),
),
migrations.AlterField(
model_name="transitiondomain",
name="zipcode",
field=models.CharField(
blank=True, db_index=True, help_text="Zip code", max_length=10, null=True, verbose_name="zip code"
),
),
migrations.AlterField(
model_name="user",
name="status",
field=models.CharField(
blank=True,
choices=[("restricted", "restricted")],
default=None,
max_length=10,
null=True,
verbose_name="user status",
),
),
]

View file

@ -0,0 +1,36 @@
# Generated by Django 4.2.10 on 2024-04-18 22:45
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("registrar", "0085_alter_contact_first_name_alter_contact_last_name_and_more"),
]
operations = [
migrations.AddField(
model_name="domaininformation",
name="updated_federal_agency",
field=models.ForeignKey(
blank=True,
help_text="Associated federal agency",
null=True,
on_delete=django.db.models.deletion.PROTECT,
to="registrar.federalagency",
),
),
migrations.AddField(
model_name="domainrequest",
name="updated_federal_agency",
field=models.ForeignKey(
blank=True,
help_text="Associated federal agency",
null=True,
on_delete=django.db.models.deletion.PROTECT,
to="registrar.federalagency",
),
),
]

View file

@ -1,9 +1,10 @@
from auditlog.registry import auditlog # type: ignore
from .contact import Contact
from .domain_application import DomainApplication
from .domain_request import DomainRequest
from .domain_information import DomainInformation
from .domain import Domain
from .draft_domain import DraftDomain
from .federal_agency import FederalAgency
from .host_ip import HostIP
from .host import Host
from .domain_invitation import DomainInvitation
@ -17,11 +18,12 @@ from .verified_by_staff import VerifiedByStaff
__all__ = [
"Contact",
"DomainApplication",
"DomainRequest",
"DomainInformation",
"Domain",
"DraftDomain",
"DomainInvitation",
"FederalAgency",
"HostIP",
"Host",
"UserDomainRole",
@ -34,11 +36,12 @@ __all__ = [
]
auditlog.register(Contact)
auditlog.register(DomainApplication)
auditlog.register(DomainRequest)
auditlog.register(Domain)
auditlog.register(DraftDomain)
auditlog.register(DomainInvitation)
auditlog.register(DomainInformation)
auditlog.register(FederalAgency)
auditlog.register(HostIP)
auditlog.register(Host)
auditlog.register(UserDomainRole)

View file

@ -18,7 +18,7 @@ class Contact(TimeStampedModel):
first_name = models.CharField(
null=True,
blank=True,
verbose_name="first name / given name",
verbose_name="first name",
db_index=True,
)
middle_name = models.CharField(
@ -28,18 +28,19 @@ class Contact(TimeStampedModel):
last_name = models.CharField(
null=True,
blank=True,
verbose_name="last name / family name",
verbose_name="last name",
db_index=True,
)
title = models.CharField(
null=True,
blank=True,
verbose_name="title or role in your organization",
verbose_name="title / role",
)
email = models.EmailField(
null=True,
blank=True,
db_index=True,
max_length=320,
)
phone = PhoneNumberField(
null=True,
@ -93,6 +94,9 @@ class Contact(TimeStampedModel):
names = [n for n in [self.first_name, self.middle_name, self.last_name] if n]
return " ".join(names) if names else "Unknown"
def has_contact_info(self):
return bool(self.title or self.email or self.phone)
def save(self, *args, **kwargs):
# Call the parent class's save method to perform the actual save
super().save(*args, **kwargs)

View file

@ -159,6 +159,31 @@ class Domain(TimeStampedModel, DomainHelper):
return help_texts.get(state, "")
@classmethod
def get_admin_help_text(cls, state):
"""Returns a help message for a desired state for /admin. If none is found, an empty string is returned"""
admin_help_texts = {
cls.UNKNOWN: (
"The creator of the associated domain request has not logged in to "
"manage the domain since it was approved. "
'The state will switch to "DNS needed" after they access the domain in the registrar.'
),
cls.DNS_NEEDED: (
"Before this domain can be used, name server addresses need to be added within the registrar."
),
cls.READY: "This domain has name servers and is ready for use.",
cls.ON_HOLD: (
"While on hold, this domain won't resolve in DNS and "
"any infrastructure (like websites) will be offline."
),
cls.DELETED: (
"This domain was permanently removed from the registry. "
"The domain no longer resolves in DNS and any infrastructure (like websites) is offline."
),
}
return admin_help_texts.get(state, "")
class Cache(property):
"""
Python descriptor to turn class methods into properties.
@ -897,8 +922,8 @@ class Domain(TimeStampedModel, DomainHelper):
def security_contact(self, contact: PublicContact):
"""makes the contact in the registry,
for security the public contact should have the org or registrant information
from domain information (not domain application)
and should have the security email from DomainApplication"""
from domain information (not domain request)
and should have the security email from DomainRequest"""
logger.info("making security contact in registry")
self._set_singleton_contact(contact, expectedType=contact.ContactTypeChoices.SECURITY)
@ -993,19 +1018,24 @@ class Domain(TimeStampedModel, DomainHelper):
default=None, # prevent saving without a value
unique=True,
help_text="Fully qualified domain name",
verbose_name="domain",
)
state = FSMField(
max_length=21,
choices=State.choices,
default=State.UNKNOWN,
protected=True, # cannot change state directly, particularly in Django admin
help_text="Very basic info about the lifecycle of this domain object",
# cannot change state directly, particularly in Django admin
protected=True,
# This must be defined for custom state help messages,
# as otherwise the view will purge the help field as it does not exist.
help_text=" ",
verbose_name="domain state",
)
expiration_date = DateField(
null=True,
help_text=("Duplication of registry's expiration date saved for ease of reporting"),
help_text=("Date the domain expires in the registry"),
)
security_contact_registry_id = TextField(
@ -1017,13 +1047,15 @@ class Domain(TimeStampedModel, DomainHelper):
deleted = DateField(
null=True,
editable=False,
help_text="Deleted at date",
help_text='Will appear blank unless the domain is in "deleted" state',
verbose_name="deleted on",
)
first_ready = DateField(
null=True,
editable=False,
help_text="The last time this domain moved into the READY state",
help_text='Date when this domain first moved into "ready" state; date will never change',
verbose_name="first ready on",
)
def isActive(self):
@ -1685,6 +1717,59 @@ class Domain(TimeStampedModel, DomainHelper):
else:
logger.error("Error _delete_hosts_if_not_used, code was %s error was %s" % (e.code, e))
def _fix_unknown_state(self, cleaned):
"""
_fix_unknown_state: Calls _add_missing_contacts_if_unknown
to add contacts in as needed (or return an error). Otherwise
if we are able to add contacts and the state is out of UNKNOWN
and (and should be into DNS_NEEDED), we double check the
current state and # of nameservers and update the state from there
"""
try:
self._add_missing_contacts_if_unknown(cleaned)
except Exception as e:
logger.error(
"%s couldn't _add_missing_contacts_if_unknown, error was %s."
"Domain will still be in UNKNOWN state." % (self.name, e)
)
if len(self.nameservers) >= 2 and (self.state != self.State.READY):
self.ready()
self.save()
@transition(field="state", source=State.UNKNOWN, target=State.DNS_NEEDED)
def _add_missing_contacts_if_unknown(self, cleaned):
"""
_add_missing_contacts_if_unknown: Add contacts (SECURITY, TECHNICAL, and/or ADMINISTRATIVE)
if they are missing, AND switch the state to DNS_NEEDED from UNKNOWN (if it
is in an UNKNOWN state, that is an error state)
Note: The transition state change happens at the end of the function
"""
missingAdmin = True
missingSecurity = True
missingTech = True
if len(cleaned.get("_contacts")) < 3:
for contact in cleaned.get("_contacts"):
if contact.type == PublicContact.ContactTypeChoices.ADMINISTRATIVE:
missingAdmin = False
if contact.type == PublicContact.ContactTypeChoices.SECURITY:
missingSecurity = False
if contact.type == PublicContact.ContactTypeChoices.TECHNICAL:
missingTech = False
# We are only creating if it doesn't exist so we don't overwrite
if missingAdmin:
administrative_contact = self.get_default_administrative_contact()
administrative_contact.save()
if missingSecurity:
security_contact = self.get_default_security_contact()
security_contact.save()
if missingTech:
technical_contact = self.get_default_technical_contact()
technical_contact.save()
def _fetch_cache(self, fetch_hosts=False, fetch_contacts=False):
"""Contact registry for info about a domain."""
try:
@ -1692,6 +1777,9 @@ class Domain(TimeStampedModel, DomainHelper):
cache = self._extract_data_from_response(data_response)
cleaned = self._clean_cache(cache, data_response)
self._update_hosts_and_contacts(cleaned, fetch_hosts, fetch_contacts)
if self.state == self.State.UNKNOWN:
self._fix_unknown_state(cleaned)
if fetch_hosts:
self._update_hosts_and_ips_in_db(cleaned)
if fetch_contacts:

View file

@ -2,7 +2,8 @@ from __future__ import annotations
from django.db import transaction
from registrar.models.utility.domain_helper import DomainHelper
from .domain_application import DomainApplication
from registrar.models.utility.generic_helper import CreateOrUpdateOrganizationTypeHelper
from .domain_request import DomainRequest
from .utility.time_stamped_model import TimeStampedModel
import logging
@ -15,69 +16,92 @@ logger = logging.getLogger(__name__)
class DomainInformation(TimeStampedModel):
"""A registrant's domain information for that domain, exported from
DomainApplication. We use these field from DomainApplication with few exceptions
DomainRequest. We use these field from DomainRequest with few exceptions
which are 'removed' via pop at the bottom of this file. Most of design for domain
management's user information are based on application, but we cannot change
the application once approved, so copying them that way we can make changes
after its approved. Most fields here are copied from Application."""
management's user information are based on domain_request, but we cannot change
the domain request once approved, so copying them that way we can make changes
after its approved. Most fields here are copied from DomainRequest."""
StateTerritoryChoices = DomainApplication.StateTerritoryChoices
StateTerritoryChoices = DomainRequest.StateTerritoryChoices
# use the short names in Django admin
OrganizationChoices = DomainApplication.OrganizationChoices
OrganizationChoices = DomainRequest.OrganizationChoices
BranchChoices = DomainApplication.BranchChoices
BranchChoices = DomainRequest.BranchChoices
AGENCY_CHOICES = DomainApplication.AGENCY_CHOICES
# TODO for #1975: Delete this after we run the new migration
AGENCY_CHOICES = DomainRequest.AGENCY_CHOICES
# This is the application user who created this application. The contact
updated_federal_agency = models.ForeignKey(
"registrar.FederalAgency",
on_delete=models.PROTECT,
help_text="Associated federal agency",
unique=False,
blank=True,
null=True,
)
# This is the domain request user who created this domain request. The contact
# information that they gave is in the `submitter` field
creator = models.ForeignKey(
"registrar.User",
on_delete=models.PROTECT,
related_name="information_created",
help_text="Person who submitted the domain request",
)
domain_application = models.OneToOneField(
"registrar.DomainApplication",
domain_request = models.OneToOneField(
"registrar.DomainRequest",
on_delete=models.PROTECT,
blank=True,
null=True,
related_name="domainapplication_info",
help_text="Associated domain application",
related_name="DomainRequest_info",
help_text="Request associated with this domain",
unique=True,
)
# ##### data fields from the initial form #####
organization_type = models.CharField(
generic_org_type = models.CharField(
max_length=255,
choices=OrganizationChoices.choices,
null=True,
blank=True,
help_text="Type of Organization",
help_text="Type of organization",
)
# TODO - Ticket #1911: stub this data from DomainRequest
is_election_board = models.BooleanField(
null=True,
blank=True,
verbose_name="election office",
)
# TODO - Ticket #1911: stub this data from DomainRequest
organization_type = models.CharField(
max_length=255,
choices=DomainRequest.OrgChoicesElectionOffice.choices,
null=True,
blank=True,
help_text='"Election" appears after the org type if it\'s an election office.',
)
federally_recognized_tribe = models.BooleanField(
null=True,
help_text="Is the tribe federally recognized",
)
state_recognized_tribe = models.BooleanField(
null=True,
help_text="Is the tribe recognized by a state",
)
tribe_name = models.CharField(
null=True,
blank=True,
help_text="Name of tribe",
)
federal_agency = models.CharField(
choices=AGENCY_CHOICES,
null=True,
blank=True,
help_text="Federal agency",
)
federal_type = models.CharField(
@ -85,64 +109,57 @@ class DomainInformation(TimeStampedModel):
choices=BranchChoices.choices,
null=True,
blank=True,
help_text="Federal government branch",
)
is_election_board = models.BooleanField(
null=True,
blank=True,
help_text="Is your organization an election office?",
verbose_name="election office",
)
organization_name = models.CharField(
null=True,
blank=True,
help_text="Organization name",
db_index=True,
)
address_line1 = models.CharField(
null=True,
blank=True,
help_text="Street address",
verbose_name="Street address",
verbose_name="address line 1",
)
address_line2 = models.CharField(
null=True,
blank=True,
help_text="Street address line 2 (optional)",
verbose_name="Street address line 2 (optional)",
verbose_name="address line 2",
)
city = models.CharField(
null=True,
blank=True,
help_text="City",
)
state_territory = models.CharField(
max_length=2,
choices=StateTerritoryChoices.choices,
null=True,
blank=True,
help_text="State, territory, or military post",
verbose_name="State, territory, or military post",
verbose_name="state / territory",
)
zipcode = models.CharField(
max_length=10,
null=True,
blank=True,
help_text="Zip code",
db_index=True,
verbose_name="zip code",
)
urbanization = models.CharField(
null=True,
blank=True,
help_text="Urbanization (required for Puerto Rico only)",
verbose_name="Urbanization (required for Puerto Rico only)",
help_text="Required for Puerto Rico only",
verbose_name="urbanization",
)
about_your_organization = models.TextField(
null=True,
blank=True,
help_text="Information about your organization",
)
authorizing_official = models.ForeignKey(
@ -160,17 +177,17 @@ class DomainInformation(TimeStampedModel):
null=True,
# Access this information via Domain as "domain.domain_info"
related_name="domain_info",
help_text="Domain to which this information belongs",
)
# This is the contact information provided by the applicant. The
# application user who created it is in the `creator` field.
# 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_applications_information",
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(
@ -182,20 +199,19 @@ class DomainInformation(TimeStampedModel):
other_contacts = models.ManyToManyField(
"registrar.Contact",
blank=True,
related_name="contact_applications_information",
verbose_name="contacts",
related_name="contact_domain_requests_information",
verbose_name="Other employees",
)
no_other_contacts_rationale = models.TextField(
null=True,
blank=True,
help_text="Reason for listing no additional contacts",
help_text="Required if creator does not list other employees",
)
anything_else = models.TextField(
null=True,
blank=True,
help_text="Anything else?",
)
is_policy_acknowledged = models.BooleanField(
@ -207,7 +223,6 @@ class DomainInformation(TimeStampedModel):
notes = models.TextField(
null=True,
blank=True,
help_text="Notes about the request",
)
def __str__(self):
@ -219,26 +234,63 @@ class DomainInformation(TimeStampedModel):
except Exception:
return ""
def sync_organization_type(self):
"""
Updates the organization_type (without saving) to match
the is_election_board and generic_organization_type fields.
"""
# Define mappings between generic org and election org.
# These have to be defined here, as you'd get a cyclical import error
# otherwise.
# For any given organization type, return the "_ELECTION" enum equivalent.
# For example: STATE_OR_TERRITORY => STATE_OR_TERRITORY_ELECTION
generic_org_map = DomainRequest.OrgChoicesElectionOffice.get_org_generic_to_org_election()
# For any given "_election" variant, return the base org type.
# For example: STATE_OR_TERRITORY_ELECTION => STATE_OR_TERRITORY
election_org_map = DomainRequest.OrgChoicesElectionOffice.get_org_election_to_org_generic()
# Manages the "organization_type" variable and keeps in sync with
# "is_election_office" and "generic_organization_type"
org_type_helper = CreateOrUpdateOrganizationTypeHelper(
sender=self.__class__,
instance=self,
generic_org_to_org_map=generic_org_map,
election_org_to_generic_org_map=election_org_map,
)
# Actually updates the organization_type field
org_type_helper.create_or_update_organization_type()
return self
def save(self, *args, **kwargs):
"""Save override for custom properties"""
self.sync_organization_type()
super().save(*args, **kwargs)
@classmethod
def create_from_da(cls, domain_application: DomainApplication, domain=None):
"""Takes in a DomainApplication and converts it into DomainInformation"""
def create_from_da(cls, domain_request: DomainRequest, domain=None):
"""Takes in a DomainRequest and converts it into DomainInformation"""
# Throw an error if we get None - we can't create something from nothing
if domain_application is None:
raise ValueError("The provided DomainApplication is None")
if domain_request is None:
raise ValueError("The provided DomainRequest is None")
# Throw an error if the da doesn't have an id
if not hasattr(domain_application, "id"):
raise ValueError("The provided DomainApplication has no id")
if not hasattr(domain_request, "id"):
raise ValueError("The provided DomainRequest has no id")
# check if we have a record that corresponds with the domain
# application, if so short circuit the create
existing_domain_info = cls.objects.filter(domain_application__id=domain_application.id).first()
# domain_request, if so short circuit the create
existing_domain_info = cls.objects.filter(domain_request__id=domain_request.id).first()
if existing_domain_info:
return existing_domain_info
# Get the fields that exist on both DomainApplication and DomainInformation
common_fields = DomainHelper.get_common_fields(DomainApplication, DomainInformation)
# Get the fields that exist on both DomainRequest and DomainInformation
common_fields = DomainHelper.get_common_fields(DomainRequest, DomainInformation)
# Get a list of all many_to_many relations on DomainInformation (needs to be saved differently)
info_many_to_many_fields = DomainInformation._get_many_to_many_fields()
@ -249,11 +301,11 @@ class DomainInformation(TimeStampedModel):
for field in common_fields:
# If the field isn't many_to_many, populate the da_dict.
# If it is, populate da_many_to_many_dict as we need to save this later.
if hasattr(domain_application, field):
if hasattr(domain_request, field):
if field not in info_many_to_many_fields:
da_dict[field] = getattr(domain_application, field)
da_dict[field] = getattr(domain_request, field)
else:
da_many_to_many_dict[field] = getattr(domain_application, field).all()
da_many_to_many_dict[field] = getattr(domain_request, field).all()
# This will not happen in normal code flow, but having some redundancy doesn't hurt.
# da_dict should not have "id" under any circumstances.
@ -266,8 +318,8 @@ class DomainInformation(TimeStampedModel):
# Create a placeholder DomainInformation object
domain_info = DomainInformation(**da_dict)
# Add the domain_application and domain fields
domain_info.domain_application = domain_application
# Add the domain_request and domain fields
domain_info.domain_request = domain_request
if domain:
domain_info.domain = domain

View file

@ -9,6 +9,8 @@ from django.db import models
from django_fsm import FSMField, transition # type: ignore
from django.utils import timezone
from registrar.models.domain import Domain
from registrar.models.utility.generic_helper import CreateOrUpdateOrganizationTypeHelper
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
from .utility.time_stamped_model import TimeStampedModel
from ..utility.email import send_templated_email, EmailSendingError
@ -17,11 +19,11 @@ from itertools import chain
logger = logging.getLogger(__name__)
class DomainApplication(TimeStampedModel):
"""A registrant's application for a new domain."""
class DomainRequest(TimeStampedModel):
"""A registrant's domain request for a new domain."""
# Constants for choice fields
class ApplicationStatus(models.TextChoices):
class DomainRequestStatus(models.TextChoices):
STARTED = "started", "Started"
SUBMITTED = "submitted", "Submitted"
IN_REVIEW = "in review", "In review"
@ -99,8 +101,8 @@ class DomainApplication(TimeStampedModel):
class OrganizationChoices(models.TextChoices):
"""
Primary organization choices:
For use in django admin
Keys need to match OrganizationChoicesVerbose
For use in the domain request experience
Keys need to match OrgChoicesElectionOffice and OrganizationChoicesVerbose
"""
FEDERAL = "federal", "Federal"
@ -112,10 +114,78 @@ class DomainApplication(TimeStampedModel):
SPECIAL_DISTRICT = "special_district", "Special district"
SCHOOL_DISTRICT = "school_district", "School district"
class OrgChoicesElectionOffice(models.TextChoices):
"""
Primary organization choices for Django admin:
Keys need to match OrganizationChoices and OrganizationChoicesVerbose.
The enums here come in two variants:
Regular (matches the choices from OrganizationChoices)
Election (Appends " - Election" to the string)
When adding the election variant, you must append "_election" to the end of the string.
"""
# We can't inherit OrganizationChoices due to models.TextChoices being an enum.
# We can redefine these values instead.
FEDERAL = "federal", "Federal"
INTERSTATE = "interstate", "Interstate"
STATE_OR_TERRITORY = "state_or_territory", "State or territory"
TRIBAL = "tribal", "Tribal"
COUNTY = "county", "County"
CITY = "city", "City"
SPECIAL_DISTRICT = "special_district", "Special district"
SCHOOL_DISTRICT = "school_district", "School district"
# Election variants
STATE_OR_TERRITORY_ELECTION = "state_or_territory_election", "State or territory - Election"
TRIBAL_ELECTION = "tribal_election", "Tribal - Election"
COUNTY_ELECTION = "county_election", "County - Election"
CITY_ELECTION = "city_election", "City - Election"
SPECIAL_DISTRICT_ELECTION = "special_district_election", "Special district - Election"
@classmethod
def get_org_election_to_org_generic(cls):
"""
Creates and returns a dictionary mapping from election-specific organization
choice enums to their corresponding general organization choice enums.
If no such mapping exists, it is simple excluded from the map.
"""
# This can be mapped automatically but its harder to read.
# For clarity reasons, we manually define this.
org_election_map = {
cls.STATE_OR_TERRITORY_ELECTION: cls.STATE_OR_TERRITORY,
cls.TRIBAL_ELECTION: cls.TRIBAL,
cls.COUNTY_ELECTION: cls.COUNTY,
cls.CITY_ELECTION: cls.CITY,
cls.SPECIAL_DISTRICT_ELECTION: cls.SPECIAL_DISTRICT,
}
return org_election_map
@classmethod
def get_org_generic_to_org_election(cls):
"""
Creates and returns a dictionary mapping from general organization
choice enums to their corresponding election-specific organization enums.
If no such mapping exists, it is simple excluded from the map.
"""
# This can be mapped automatically but its harder to read.
# For clarity reasons, we manually define this.
org_election_map = {
cls.STATE_OR_TERRITORY: cls.STATE_OR_TERRITORY_ELECTION,
cls.TRIBAL: cls.TRIBAL_ELECTION,
cls.COUNTY: cls.COUNTY_ELECTION,
cls.CITY: cls.CITY_ELECTION,
cls.SPECIAL_DISTRICT: cls.SPECIAL_DISTRICT_ELECTION,
}
return org_election_map
class OrganizationChoicesVerbose(models.TextChoices):
"""
Secondary organization choices
For use in the application form and on the templates
Tertiary organization choices
For use in the domain request form and on the templates
Keys need to match OrganizationChoices
"""
@ -366,10 +436,10 @@ class DomainApplication(TimeStampedModel):
NAMING_REQUIREMENTS = "naming_not_met", "Naming requirements not met"
OTHER = "other", "Other/Unspecified"
# #### Internal fields about the application #####
# #### Internal fields about the domain request #####
status = FSMField(
choices=ApplicationStatus.choices, # possible states as an array of constants
default=ApplicationStatus.STARTED, # sensible default
choices=DomainRequestStatus.choices, # possible states as an array of constants
default=DomainRequestStatus.STARTED, # sensible default
protected=False, # can change state directly, particularly in Django admin
)
@ -379,12 +449,22 @@ class DomainApplication(TimeStampedModel):
blank=True,
)
# This is the application user who created this application. The contact
updated_federal_agency = models.ForeignKey(
"registrar.FederalAgency",
on_delete=models.PROTECT,
help_text="Associated federal agency",
unique=False,
blank=True,
null=True,
)
# This is the domain request user who created this domain request. The contact
# information that they gave is in the `submitter` field
creator = models.ForeignKey(
"registrar.User",
on_delete=models.PROTECT,
related_name="applications_created",
related_name="domain_requests_created",
help_text="Person who submitted the domain request; will not receive email updates",
)
investigator = models.ForeignKey(
@ -392,40 +472,50 @@ class DomainApplication(TimeStampedModel):
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="applications_investigating",
related_name="domain_requests_investigating",
)
# ##### data fields from the initial form #####
organization_type = models.CharField(
generic_org_type = models.CharField(
max_length=255,
# use the short names in Django admin
choices=OrganizationChoices.choices,
null=True,
blank=True,
help_text="Type of organization",
)
is_election_board = models.BooleanField(
null=True,
blank=True,
verbose_name="election office",
)
# TODO - Ticket #1911: stub this data from DomainRequest
organization_type = models.CharField(
max_length=255,
choices=OrgChoicesElectionOffice.choices,
null=True,
blank=True,
help_text='"Election" appears after the org type if it\'s an election office.',
)
federally_recognized_tribe = models.BooleanField(
null=True,
help_text="Is the tribe federally recognized",
)
state_recognized_tribe = models.BooleanField(
null=True,
help_text="Is the tribe recognized by a state",
)
tribe_name = models.CharField(
null=True,
blank=True,
help_text="Name of tribe",
)
federal_agency = models.CharField(
choices=AGENCY_CHOICES,
null=True,
blank=True,
help_text="Federal agency",
)
federal_type = models.CharField(
@ -433,62 +523,51 @@ class DomainApplication(TimeStampedModel):
choices=BranchChoices.choices,
null=True,
blank=True,
help_text="Federal government branch",
)
is_election_board = models.BooleanField(
null=True,
blank=True,
help_text="Is your organization an election office?",
)
organization_name = models.CharField(
null=True,
blank=True,
help_text="Organization name",
db_index=True,
)
address_line1 = models.CharField(
null=True,
blank=True,
help_text="Street address",
verbose_name="Address line 1",
)
address_line2 = models.CharField(
null=True,
blank=True,
help_text="Street address line 2 (optional)",
verbose_name="Address line 2",
)
city = models.CharField(
null=True,
blank=True,
help_text="City",
)
state_territory = models.CharField(
max_length=2,
choices=StateTerritoryChoices.choices,
null=True,
blank=True,
help_text="State, territory, or military post",
verbose_name="state / territory",
)
zipcode = models.CharField(
max_length=10,
null=True,
blank=True,
help_text="Zip code",
verbose_name="zip code",
db_index=True,
)
urbanization = models.CharField(
null=True,
blank=True,
help_text="Urbanization (required for Puerto Rico only)",
help_text="Required for Puerto Rico only",
)
about_your_organization = models.TextField(
null=True,
blank=True,
help_text="Information about your organization",
)
authorizing_official = models.ForeignKey(
@ -499,20 +578,20 @@ class DomainApplication(TimeStampedModel):
on_delete=models.PROTECT,
)
# "+" means no reverse relation to lookup applications from Website
# "+" means no reverse relation to lookup domain requests from Website
current_websites = models.ManyToManyField(
"registrar.Website",
blank=True,
related_name="current+",
verbose_name="websites",
verbose_name="Current websites",
)
approved_domain = models.OneToOneField(
"Domain",
null=True,
blank=True,
help_text="The approved domain",
related_name="domain_application",
help_text="Domain associated with this request; will be blank until request is approved",
related_name="domain_request",
on_delete=models.SET_NULL,
)
@ -520,49 +599,49 @@ class DomainApplication(TimeStampedModel):
"DraftDomain",
null=True,
blank=True,
help_text="The requested domain",
related_name="domain_application",
related_name="domain_request",
on_delete=models.PROTECT,
)
alternative_domains = models.ManyToManyField(
"registrar.Website",
blank=True,
related_name="alternatives+",
help_text="Other domain names the creator provided for consideration",
)
# This is the contact information provided by the applicant. The
# application user who created it is in the `creator` field.
# 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_applications",
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(
null=True,
blank=True,
help_text="Purpose of your domain",
)
other_contacts = models.ManyToManyField(
"registrar.Contact",
blank=True,
related_name="contact_applications",
verbose_name="contacts",
related_name="contact_domain_requests",
verbose_name="Other employees",
)
no_other_contacts_rationale = models.TextField(
null=True,
blank=True,
help_text="Reason for listing no additional contacts",
help_text="Required if creator does not list other employees",
)
anything_else = models.TextField(
null=True,
blank=True,
help_text="Anything else?",
)
is_policy_acknowledged = models.BooleanField(
@ -571,26 +650,60 @@ class DomainApplication(TimeStampedModel):
help_text="Acknowledged .gov acceptable use policy",
)
# submission date records when application is submitted
# submission date records when domain request is submitted
submission_date = models.DateField(
null=True,
blank=True,
default=None,
verbose_name="submitted at",
help_text="Date submitted",
)
notes = models.TextField(
null=True,
blank=True,
help_text="Notes about this request",
)
def sync_organization_type(self):
"""
Updates the organization_type (without saving) to match
the is_election_board and generic_organization_type fields.
"""
# Define mappings between generic org and election org.
# These have to be defined here, as you'd get a cyclical import error
# otherwise.
# For any given organization type, return the "_ELECTION" enum equivalent.
# For example: STATE_OR_TERRITORY => STATE_OR_TERRITORY_ELECTION
generic_org_map = self.OrgChoicesElectionOffice.get_org_generic_to_org_election()
# For any given "_election" variant, return the base org type.
# For example: STATE_OR_TERRITORY_ELECTION => STATE_OR_TERRITORY
election_org_map = self.OrgChoicesElectionOffice.get_org_election_to_org_generic()
# Manages the "organization_type" variable and keeps in sync with
# "is_election_office" and "generic_organization_type"
org_type_helper = CreateOrUpdateOrganizationTypeHelper(
sender=self.__class__,
instance=self,
generic_org_to_org_map=generic_org_map,
election_org_to_generic_org_map=election_org_map,
)
# Actually updates the organization_type field
org_type_helper.create_or_update_organization_type()
def save(self, *args, **kwargs):
"""Save override for custom properties"""
self.sync_organization_type()
super().save(*args, **kwargs)
def __str__(self):
try:
if self.requested_domain and self.requested_domain.name:
return self.requested_domain.name
else:
return f"{self.status} application created by {self.creator}"
return f"{self.status} domain request created by {self.creator}"
except Exception:
return ""
@ -638,25 +751,33 @@ class DomainApplication(TimeStampedModel):
email_template,
email_template_subject,
self.submitter.email,
context={"application": self},
context={"domain_request": self},
bcc_address=bcc_address,
)
logger.info(f"The {new_status} email sent to: {self.submitter.email}")
except EmailSendingError:
logger.warning("Failed to send confirmation email", exc_info=True)
def investigator_exists_and_is_staff(self):
"""Checks if the current investigator is in a valid state for a state transition"""
is_valid = True
# Check if an investigator is assigned. No approval is possible without one.
if self.investigator is None or not self.investigator.is_staff:
is_valid = False
return is_valid
@transition(
field="status",
source=[
ApplicationStatus.STARTED,
ApplicationStatus.IN_REVIEW,
ApplicationStatus.ACTION_NEEDED,
ApplicationStatus.WITHDRAWN,
DomainRequestStatus.STARTED,
DomainRequestStatus.IN_REVIEW,
DomainRequestStatus.ACTION_NEEDED,
DomainRequestStatus.WITHDRAWN,
],
target=ApplicationStatus.SUBMITTED,
target=DomainRequestStatus.SUBMITTED,
)
def submit(self):
"""Submit an application that is started.
"""Submit an domain request that is started.
As a side effect, an email notification is sent."""
@ -664,10 +785,7 @@ class DomainApplication(TimeStampedModel):
# can raise more informative exceptions
# requested_domain could be None here
if not hasattr(self, "requested_domain"):
raise ValueError("Requested domain is missing.")
if self.requested_domain is None:
if not hasattr(self, "requested_domain") or self.requested_domain is None:
raise ValueError("Requested domain is missing.")
DraftDomain = apps.get_model("registrar.DraftDomain")
@ -679,7 +797,7 @@ class DomainApplication(TimeStampedModel):
self.save()
# Limit email notifications to transitions from Started and Withdrawn
limited_statuses = [self.ApplicationStatus.STARTED, self.ApplicationStatus.WITHDRAWN]
limited_statuses = [self.DomainRequestStatus.STARTED, self.DomainRequestStatus.WITHDRAWN]
bcc_address = ""
if settings.IS_PRODUCTION:
@ -697,17 +815,17 @@ class DomainApplication(TimeStampedModel):
@transition(
field="status",
source=[
ApplicationStatus.SUBMITTED,
ApplicationStatus.ACTION_NEEDED,
ApplicationStatus.APPROVED,
ApplicationStatus.REJECTED,
ApplicationStatus.INELIGIBLE,
DomainRequestStatus.SUBMITTED,
DomainRequestStatus.ACTION_NEEDED,
DomainRequestStatus.APPROVED,
DomainRequestStatus.REJECTED,
DomainRequestStatus.INELIGIBLE,
],
target=ApplicationStatus.IN_REVIEW,
conditions=[domain_is_not_active],
target=DomainRequestStatus.IN_REVIEW,
conditions=[domain_is_not_active, investigator_exists_and_is_staff],
)
def in_review(self):
"""Investigate an application that has been submitted.
"""Investigate an domain request that has been submitted.
This action is logged.
@ -716,13 +834,13 @@ class DomainApplication(TimeStampedModel):
As side effects this will delete the domain and domain_information
(will cascade) when they exist."""
if self.status == self.ApplicationStatus.APPROVED:
if self.status == self.DomainRequestStatus.APPROVED:
self.delete_and_clean_up_domain("in_review")
if self.status == self.ApplicationStatus.REJECTED:
if self.status == self.DomainRequestStatus.REJECTED:
self.rejection_reason = None
literal = DomainApplication.ApplicationStatus.IN_REVIEW
literal = DomainRequest.DomainRequestStatus.IN_REVIEW
# Check if the tuple exists, then grab its value
in_review = literal if literal is not None else "In Review"
logger.info(f"A status change occurred. {self} was changed to '{in_review}'")
@ -730,16 +848,16 @@ class DomainApplication(TimeStampedModel):
@transition(
field="status",
source=[
ApplicationStatus.IN_REVIEW,
ApplicationStatus.APPROVED,
ApplicationStatus.REJECTED,
ApplicationStatus.INELIGIBLE,
DomainRequestStatus.IN_REVIEW,
DomainRequestStatus.APPROVED,
DomainRequestStatus.REJECTED,
DomainRequestStatus.INELIGIBLE,
],
target=ApplicationStatus.ACTION_NEEDED,
conditions=[domain_is_not_active],
target=DomainRequestStatus.ACTION_NEEDED,
conditions=[domain_is_not_active, investigator_exists_and_is_staff],
)
def action_needed(self):
"""Send back an application that is under investigation or rejected.
"""Send back an domain request that is under investigation or rejected.
This action is logged.
@ -748,13 +866,13 @@ class DomainApplication(TimeStampedModel):
As side effects this will delete the domain and domain_information
(will cascade) when they exist."""
if self.status == self.ApplicationStatus.APPROVED:
if self.status == self.DomainRequestStatus.APPROVED:
self.delete_and_clean_up_domain("reject_with_prejudice")
if self.status == self.ApplicationStatus.REJECTED:
if self.status == self.DomainRequestStatus.REJECTED:
self.rejection_reason = None
literal = DomainApplication.ApplicationStatus.ACTION_NEEDED
literal = DomainRequest.DomainRequestStatus.ACTION_NEEDED
# Check if the tuple is setup correctly, then grab its value
action_needed = literal if literal is not None else "Action Needed"
logger.info(f"A status change occurred. {self} was changed to '{action_needed}'")
@ -762,33 +880,38 @@ class DomainApplication(TimeStampedModel):
@transition(
field="status",
source=[
ApplicationStatus.SUBMITTED,
ApplicationStatus.IN_REVIEW,
ApplicationStatus.ACTION_NEEDED,
ApplicationStatus.REJECTED,
DomainRequestStatus.SUBMITTED,
DomainRequestStatus.IN_REVIEW,
DomainRequestStatus.ACTION_NEEDED,
DomainRequestStatus.REJECTED,
],
target=ApplicationStatus.APPROVED,
target=DomainRequestStatus.APPROVED,
conditions=[investigator_exists_and_is_staff],
)
def approve(self, send_email=True):
"""Approve an application that has been submitted.
"""Approve an domain request that has been submitted.
This action cleans up the rejection status if moving away from rejected.
This has substantial side-effects because it creates another database
object for the approved Domain and makes the user who created the
application into an admin on that domain. It also triggers an email
domain request into an admin on that domain. It also triggers an email
notification."""
# create the domain
Domain = apps.get_model("registrar.Domain")
# == Check that the domain_request is valid == #
if Domain.objects.filter(name=self.requested_domain.name).exists():
raise ValueError("Cannot approve. Requested domain is already in use.")
raise FSMDomainRequestError(code=FSMErrorCodes.APPROVE_DOMAIN_IN_USE)
# == Create the domain and related components == #
created_domain = Domain.objects.create(name=self.requested_domain.name)
self.approved_domain = created_domain
# copy the information from domainapplication into domaininformation
# copy the information from DomainRequest into domaininformation
DomainInformation = apps.get_model("registrar.DomainInformation")
DomainInformation.create_from_da(domain_application=self, domain=created_domain)
DomainInformation.create_from_da(domain_request=self, domain=created_domain)
# create the permission for the user
UserDomainRole = apps.get_model("registrar.UserDomainRole")
@ -796,11 +919,12 @@ class DomainApplication(TimeStampedModel):
user=self.creator, domain=created_domain, role=UserDomainRole.Roles.MANAGER
)
if self.status == self.ApplicationStatus.REJECTED:
if self.status == self.DomainRequestStatus.REJECTED:
self.rejection_reason = None
# == Send out an email == #
self._send_status_update_email(
"application approved",
"domain request approved",
"emails/status_change_approved.txt",
"emails/status_change_approved_subject.txt",
send_email,
@ -808,11 +932,11 @@ class DomainApplication(TimeStampedModel):
@transition(
field="status",
source=[ApplicationStatus.SUBMITTED, ApplicationStatus.IN_REVIEW, ApplicationStatus.ACTION_NEEDED],
target=ApplicationStatus.WITHDRAWN,
source=[DomainRequestStatus.SUBMITTED, DomainRequestStatus.IN_REVIEW, DomainRequestStatus.ACTION_NEEDED],
target=DomainRequestStatus.WITHDRAWN,
)
def withdraw(self):
"""Withdraw an application that has been submitted."""
"""Withdraw an domain request that has been submitted."""
self._send_status_update_email(
"withdraw",
@ -822,17 +946,17 @@ class DomainApplication(TimeStampedModel):
@transition(
field="status",
source=[ApplicationStatus.IN_REVIEW, ApplicationStatus.ACTION_NEEDED, ApplicationStatus.APPROVED],
target=ApplicationStatus.REJECTED,
conditions=[domain_is_not_active],
source=[DomainRequestStatus.IN_REVIEW, DomainRequestStatus.ACTION_NEEDED, DomainRequestStatus.APPROVED],
target=DomainRequestStatus.REJECTED,
conditions=[domain_is_not_active, investigator_exists_and_is_staff],
)
def reject(self):
"""Reject an application that has been submitted.
"""Reject an domain request that has been submitted.
As side effects this will delete the domain and domain_information
(will cascade), and send an email notification."""
if self.status == self.ApplicationStatus.APPROVED:
if self.status == self.DomainRequestStatus.APPROVED:
self.delete_and_clean_up_domain("reject")
self._send_status_update_email(
@ -844,24 +968,24 @@ class DomainApplication(TimeStampedModel):
@transition(
field="status",
source=[
ApplicationStatus.IN_REVIEW,
ApplicationStatus.ACTION_NEEDED,
ApplicationStatus.APPROVED,
ApplicationStatus.REJECTED,
DomainRequestStatus.IN_REVIEW,
DomainRequestStatus.ACTION_NEEDED,
DomainRequestStatus.APPROVED,
DomainRequestStatus.REJECTED,
],
target=ApplicationStatus.INELIGIBLE,
conditions=[domain_is_not_active],
target=DomainRequestStatus.INELIGIBLE,
conditions=[domain_is_not_active, investigator_exists_and_is_staff],
)
def reject_with_prejudice(self):
"""The applicant is a bad actor, reject with prejudice.
No email As a side effect, but we block the applicant from editing
any existing domains/applications and from submitting new aplications.
any existing domains/domain requests and from submitting new aplications.
We do this by setting an ineligible status on the user, which the
permissions classes test against. This will also delete the domain
and domain_information (will cascade) when they exist."""
if self.status == self.ApplicationStatus.APPROVED:
if self.status == self.DomainRequestStatus.APPROVED:
self.delete_and_clean_up_domain("reject_with_prejudice")
self.creator.restrict_user()
@ -869,18 +993,18 @@ class DomainApplication(TimeStampedModel):
# ## Form policies ###
#
# These methods control what questions need to be answered by applicants
# during the application flow. They are policies about the application so
# during the domain request flow. They are policies about the domain request so
# they appear here.
def show_organization_federal(self) -> bool:
"""Show this step if the answer to the first question was "federal"."""
user_choice = self.organization_type
return user_choice == DomainApplication.OrganizationChoices.FEDERAL
user_choice = self.generic_org_type
return user_choice == DomainRequest.OrganizationChoices.FEDERAL
def show_tribal_government(self) -> bool:
"""Show this step if the answer to the first question was "tribal"."""
user_choice = self.organization_type
return user_choice == DomainApplication.OrganizationChoices.TRIBAL
user_choice = self.generic_org_type
return user_choice == DomainRequest.OrganizationChoices.TRIBAL
def show_organization_election(self) -> bool:
"""Show this step if the answer to the first question implies it.
@ -888,39 +1012,39 @@ class DomainApplication(TimeStampedModel):
This shows for answers that aren't "Federal" or "Interstate".
This also doesnt show if user selected "School District" as well (#524)
"""
user_choice = self.organization_type
user_choice = self.generic_org_type
excluded = [
DomainApplication.OrganizationChoices.FEDERAL,
DomainApplication.OrganizationChoices.INTERSTATE,
DomainApplication.OrganizationChoices.SCHOOL_DISTRICT,
DomainRequest.OrganizationChoices.FEDERAL,
DomainRequest.OrganizationChoices.INTERSTATE,
DomainRequest.OrganizationChoices.SCHOOL_DISTRICT,
]
return bool(user_choice and user_choice not in excluded)
def show_about_your_organization(self) -> bool:
"""Show this step if this is a special district or interstate."""
user_choice = self.organization_type
user_choice = self.generic_org_type
return user_choice in [
DomainApplication.OrganizationChoices.SPECIAL_DISTRICT,
DomainApplication.OrganizationChoices.INTERSTATE,
DomainRequest.OrganizationChoices.SPECIAL_DISTRICT,
DomainRequest.OrganizationChoices.INTERSTATE,
]
def has_rationale(self) -> bool:
"""Does this application have no_other_contacts_rationale?"""
"""Does this domain request have no_other_contacts_rationale?"""
return bool(self.no_other_contacts_rationale)
def has_other_contacts(self) -> bool:
"""Does this application have other contacts listed?"""
"""Does this domain request have other contacts listed?"""
return self.other_contacts.exists()
def is_federal(self) -> Union[bool, None]:
"""Is this application for a federal agency?
"""Is this domain request for a federal agency?
organization_type can be both null and blank,
generic_org_type can be both null and blank,
"""
if not self.organization_type:
# organization_type is either blank or None, can't answer
if not self.generic_org_type:
# generic_org_type is either blank or None, can't answer
return None
if self.organization_type == DomainApplication.OrganizationChoices.FEDERAL:
if self.generic_org_type == DomainRequest.OrganizationChoices.FEDERAL:
return True
return False

View file

@ -18,5 +18,6 @@ class DraftDomain(TimeStampedModel, DomainHelper):
max_length=253,
blank=False,
default=None, # prevent saving without a value
verbose_name="requested domain",
help_text="Fully qualified domain name",
)

View file

@ -0,0 +1,223 @@
from .utility.time_stamped_model import TimeStampedModel
from django.db import models
import logging
logger = logging.getLogger(__name__)
class FederalAgency(TimeStampedModel):
class Meta:
verbose_name = "Federal agency"
verbose_name_plural = "Federal agencies"
agency = models.CharField(
null=True,
blank=True,
help_text="Federal agency",
)
def __str__(self) -> str:
return f"{self.agency}"
def create_federal_agencies(apps, schema_editor):
"""This method gets run from a data migration to prepopulate data
regarding federal agencies."""
# Hard to pass self to these methods as the calls from migrations
# are only expecting apps and schema_editor, so we'll just define
# apps, schema_editor in the local scope instead
AGENCIES = [
"Administrative Conference of the United States",
"Advisory Council on Historic Preservation",
"American Battle Monuments Commission",
"AMTRAK",
"Appalachian Regional Commission",
("Appraisal Subcommittee of the Federal Financial " "Institutions Examination Council"),
"Architect of the Capitol",
"Armed Forces Retirement Home",
"Barry Goldwater Scholarship and Excellence in Education Foundation",
"Central Intelligence Agency",
"Christopher Columbus Fellowship Foundation",
"Civil Rights Cold Case Records Review Board",
"Commission for the Preservation of America's Heritage Abroad",
"Commission of Fine Arts",
"Committee for Purchase From People Who Are Blind or Severely Disabled",
"Commodity Futures Trading Commission",
"Congressional Budget Office",
"Consumer Financial Protection Bureau",
"Consumer Product Safety Commission",
"Corporation for National and Community Service",
"Council of Inspectors General on Integrity and Efficiency",
"Court Services and Offender Supervision",
"Cyberspace Solarium Commission",
"DC Court Services and Offender Supervision Agency",
"DC Pre-trial Services",
"Defense Nuclear Facilities Safety Board",
"Delta Regional Authority",
"Denali Commission",
"Department of Agriculture",
"Department of Commerce",
"Department of Defense",
"Department of Education",
"Department of Energy",
"Department of Health and Human Services",
"Department of Homeland Security",
"Department of Housing and Urban Development",
"Department of Justice",
"Department of Labor",
"Department of State",
"Department of the Interior",
"Department of the Treasury",
"Department of Transportation",
"Department of Veterans Affairs",
"Director of National Intelligence",
"Dwight D. Eisenhower Memorial Commission",
"Election Assistance Commission",
"Environmental Protection Agency",
"Equal Employment Opportunity Commission",
"Executive Office of the President",
"Export-Import Bank of the United States",
"Farm Credit Administration",
"Farm Credit System Insurance Corporation",
"Federal Communications Commission",
"Federal Deposit Insurance Corporation",
"Federal Election Commission",
"Federal Energy Regulatory Commission",
"Federal Financial Institutions Examination Council",
"Federal Housing Finance Agency",
"Federal Judiciary",
"Federal Labor Relations Authority",
"Federal Maritime Commission",
"Federal Mediation and Conciliation Service",
"Federal Mine Safety and Health Review Commission",
"Federal Permitting Improvement Steering Council",
"Federal Reserve Board of Governors",
"Federal Trade Commission",
"General Services Administration",
"gov Administration",
"Government Accountability Office",
"Government Publishing Office",
"Gulf Coast Ecosystem Restoration Council",
"Harry S. Truman Scholarship Foundation",
"Institute of Museum and Library Services",
"Institute of Peace",
"Inter-American Foundation",
"International Boundary and Water Commission: United States and Mexico",
"International Boundary Commission: United States and Canada",
"International Joint Commission: United States and Canada",
"James Madison Memorial Fellowship Foundation",
"Japan-U.S. Friendship Commission",
"John F. Kennedy Center for the Performing Arts",
"Legal Services Corporation",
"Legislative Branch",
"Library of Congress",
"Marine Mammal Commission",
"Medicaid and CHIP Payment and Access Commission",
"Medicare Payment Advisory Commission",
"Merit Systems Protection Board",
"Millennium Challenge Corporation",
"Morris K. Udall and Stewart L. Udall Foundation",
"National Aeronautics and Space Administration",
"National Archives and Records Administration",
"National Capital Planning Commission",
"National Council on Disability",
"National Credit Union Administration",
"National Endowment for the Arts",
"National Endowment for the Humanities",
"National Foundation on the Arts and the Humanities",
"National Gallery of Art",
"National Indian Gaming Commission",
"National Labor Relations Board",
"National Mediation Board",
"National Science Foundation",
"National Security Commission on Artificial Intelligence",
"National Transportation Safety Board",
"Networking Information Technology Research and Development",
"Non-Federal Agency",
"Northern Border Regional Commission",
"Nuclear Regulatory Commission",
"Nuclear Safety Oversight Committee",
"Occupational Safety and Health Review Commission",
"Office of Compliance",
"Office of Congressional Workplace Rights",
"Office of Government Ethics",
"Office of Navajo and Hopi Indian Relocation",
"Office of Personnel Management",
"Open World Leadership Center",
"Overseas Private Investment Corporation",
"Peace Corps",
"Pension Benefit Guaranty Corporation",
"Postal Regulatory Commission",
"Presidio Trust",
"Privacy and Civil Liberties Oversight Board",
"Public Buildings Reform Board",
"Public Defender Service for the District of Columbia",
"Railroad Retirement Board",
"Securities and Exchange Commission",
"Selective Service System",
"Small Business Administration",
"Smithsonian Institution",
"Social Security Administration",
"Social Security Advisory Board",
"Southeast Crescent Regional Commission",
"Southwest Border Regional Commission",
"State Justice Institute",
"Stennis Center for Public Service",
"Surface Transportation Board",
"Tennessee Valley Authority",
"The Executive Office of the President",
"The Intelligence Community",
"The Legislative Branch",
"The Supreme Court",
"The United States World War One Centennial Commission",
"U.S. Access Board",
"U.S. Agency for Global Media",
"U.S. Agency for International Development",
"U.S. Capitol Police",
"U.S. Chemical Safety Board",
"U.S. China Economic and Security Review Commission",
"U.S. Commission for the Preservation of Americas Heritage Abroad",
"U.S. Commission of Fine Arts",
"U.S. Commission on Civil Rights",
"U.S. Commission on International Religious Freedom",
"U.S. Courts",
"U.S. Department of Agriculture",
"U.S. Interagency Council on Homelessness",
"U.S. International Trade Commission",
"U.S. Nuclear Waste Technical Review Board",
"U.S. Office of Special Counsel",
"U.S. Postal Service",
"U.S. Semiquincentennial Commission",
"U.S. Trade and Development Agency",
"U.S.-China Economic and Security Review Commission",
"Udall Foundation",
"United States AbilityOne",
"United States Access Board",
"United States African Development Foundation",
"United States Agency for Global Media",
"United States Arctic Research Commission",
"United States Global Change Research Program",
"United States Holocaust Memorial Museum",
"United States Institute of Peace",
"United States Interagency Council on Homelessness",
"United States International Development Finance Corporation",
"United States International Trade Commission",
"United States Postal Service",
"United States Senate",
"United States Trade and Development Agency",
"Utah Reclamation Mitigation and Conservation Commission",
"Vietnam Education Foundation",
"Western Hemisphere Drug Policy Commission",
"Woodrow Wilson International Center for Scholars",
"World War I Centennial Commission",
]
FederalAgency = apps.get_model("registrar", "FederalAgency")
logger.info("Creating federal agency table.")
try:
agencies = [FederalAgency(agency=agency) for agency in AGENCIES]
FederalAgency.objects.bulk_create(agencies)
except Exception as e:
logger.error(f"Error creating federal agencies: {e}")

View file

@ -21,14 +21,14 @@ class Host(TimeStampedModel):
blank=False,
default=None, # prevent saving without a value
unique=False,
help_text="Fully qualified domain name",
verbose_name="host name",
)
domain = models.ForeignKey(
"registrar.Domain",
on_delete=models.PROTECT,
related_name="host", # access this Host via the Domain as `domain.host`
help_text="Domain to which this host belongs",
help_text="Domain associated with this host",
)
def __str__(self):

View file

@ -20,12 +20,12 @@ class HostIP(TimeStampedModel):
blank=False,
default=None, # prevent saving without a value
validators=[validate_ipv46_address],
help_text="IP address",
verbose_name="IP address",
)
host = models.ForeignKey(
"registrar.Host",
on_delete=models.PROTECT,
related_name="ip", # access this HostIP via the Host as `host.ip`
help_text="Host to which this IP address belongs",
help_text="IP associated with this host",
)

View file

@ -60,18 +60,19 @@ class PublicContact(TimeStampedModel):
)
name = models.CharField(null=False, help_text="Contact's full name")
org = models.CharField(null=True, help_text="Contact's organization (null ok)")
org = models.CharField(null=True, blank=True, help_text="Contact's organization (null ok)")
street1 = models.CharField(null=False, help_text="Contact's street")
street2 = models.CharField(null=True, help_text="Contact's street (null ok)")
street3 = models.CharField(null=True, help_text="Contact's street (null ok)")
street2 = models.CharField(null=True, blank=True, help_text="Contact's street (null ok)")
street3 = models.CharField(null=True, blank=True, help_text="Contact's street (null ok)")
city = models.CharField(null=False, help_text="Contact's city")
sp = models.CharField(null=False, help_text="Contact's state or province")
pc = models.CharField(null=False, help_text="Contact's postal code")
cc = models.CharField(null=False, help_text="Contact's country code")
email = models.EmailField(null=False, help_text="Contact's email address")
email = models.EmailField(null=False, help_text="Contact's email address", max_length=320)
voice = models.CharField(null=False, help_text="Contact's phone number. Must be in ITU.E164.2005 format")
fax = models.CharField(
null=True,
blank=True,
help_text="Contact's fax number (null ok). Must be in ITU.E164.2005 format.",
)
pw = models.CharField(null=False, help_text="Contact's authorization code. 16 characters minimum.")

View file

@ -20,13 +20,13 @@ class TransitionDomain(TimeStampedModel):
username = models.CharField(
null=False,
blank=False,
verbose_name="Username",
verbose_name="username",
help_text="Username - this will be an email address",
)
domain_name = models.CharField(
null=True,
blank=True,
verbose_name="Domain name",
verbose_name="domain",
)
status = models.CharField(
max_length=255,
@ -34,7 +34,7 @@ class TransitionDomain(TimeStampedModel):
blank=True,
default=StatusChoices.READY,
choices=StatusChoices.choices,
verbose_name="Status",
verbose_name="status",
help_text="domain status during the transfer",
)
email_sent = models.BooleanField(
@ -46,10 +46,10 @@ class TransitionDomain(TimeStampedModel):
processed = models.BooleanField(
null=False,
default=True,
verbose_name="Processed",
verbose_name="processed",
help_text="Indicates whether this TransitionDomain was already processed",
)
organization_type = models.CharField(
generic_org_type = models.CharField(
max_length=255,
null=True,
blank=True,
@ -83,8 +83,8 @@ class TransitionDomain(TimeStampedModel):
first_name = models.CharField(
null=True,
blank=True,
help_text="First name",
verbose_name="first name / given name",
help_text="First name / given name",
verbose_name="first name",
db_index=True,
)
middle_name = models.CharField(
@ -100,6 +100,7 @@ class TransitionDomain(TimeStampedModel):
title = models.CharField(
null=True,
blank=True,
verbose_name="title / role",
help_text="Title",
)
email = models.EmailField(
@ -126,12 +127,14 @@ class TransitionDomain(TimeStampedModel):
max_length=2,
null=True,
blank=True,
verbose_name="state / territory",
help_text="State, territory, or military post",
)
zipcode = models.CharField(
max_length=10,
null=True,
blank=True,
verbose_name="zip code",
help_text="Zip code",
db_index=True,
)
@ -147,7 +150,7 @@ class TransitionDomain(TimeStampedModel):
f"username: {self.username}, \n"
f"status: {self.status}, \n"
f"email sent: {self.email_sent}, \n"
f"organization type: {self.organization_type}, \n"
f"organization type: {self.generic_org_type}, \n"
f"organization_name: {self.organization_name}, \n"
f"federal_type: {self.federal_type}, \n"
f"federal_agency: {self.federal_agency}, \n"

View file

@ -9,6 +9,7 @@ from .domain_invitation import DomainInvitation
from .transition_domain import TransitionDomain
from .verified_by_staff import VerifiedByStaff
from .domain import Domain
from .domain_request import DomainRequest
from phonenumber_field.modelfields import PhoneNumberField # type: ignore
@ -32,6 +33,8 @@ class User(AbstractUser):
default=None, # Set the default value to None
null=True, # Allow the field to be null
blank=True, # Allow the field to be blank
verbose_name="user status",
help_text='Users in "restricted" status cannot make updates in the registrar or start a new request.',
)
domains = models.ManyToManyField(
@ -67,6 +70,33 @@ class User(AbstractUser):
def is_restricted(self):
return self.status == self.RESTRICTED
def get_approved_domains_count(self):
"""Return count of approved domains"""
allowed_states = [Domain.State.UNKNOWN, Domain.State.DNS_NEEDED, Domain.State.READY, Domain.State.ON_HOLD]
approved_domains_count = self.domains.filter(state__in=allowed_states).count()
return approved_domains_count
def get_active_requests_count(self):
"""Return count of active requests"""
allowed_states = [
DomainRequest.DomainRequestStatus.SUBMITTED,
DomainRequest.DomainRequestStatus.IN_REVIEW,
DomainRequest.DomainRequestStatus.ACTION_NEEDED,
]
active_requests_count = self.domain_requests_created.filter(status__in=allowed_states).count()
return active_requests_count
def get_rejected_requests_count(self):
"""Return count of rejected requests"""
return self.domain_requests_created.filter(status=DomainRequest.DomainRequestStatus.REJECTED).count()
def get_ineligible_requests_count(self):
"""Return count of ineligible requests"""
return self.domain_requests_created.filter(status=DomainRequest.DomainRequestStatus.INELIGIBLE).count()
def has_contact_info(self):
return bool(self.contact.title or self.contact.email or self.contact.phone)
@classmethod
def needs_identity_verification(cls, email, uuid):
"""A method used by our oidc classes to test whether a user needs email/uuid verification

View file

@ -5,6 +5,11 @@ logger = logging.getLogger(__name__)
class UserGroup(Group):
"""
UserGroup sets read and write permissions for superusers (who have full access)
and analysts. For more details, see the dev docs for user-permissions.
"""
class Meta:
verbose_name = "User group"
verbose_name_plural = "User groups"
@ -28,24 +33,14 @@ class UserGroup(Group):
},
{
"app_label": "registrar",
"model": "domaininformation",
"permissions": ["change_domaininformation"],
},
{
"app_label": "registrar",
"model": "domainapplication",
"permissions": ["change_domainapplication"],
"model": "domainrequest",
"permissions": ["change_domainrequest"],
},
{
"app_label": "registrar",
"model": "domain",
"permissions": ["view_domain"],
},
{
"app_label": "registrar",
"model": "draftdomain",
"permissions": ["change_draftdomain"],
},
{
"app_label": "registrar",
"model": "user",
@ -56,11 +51,6 @@ class UserGroup(Group):
"model": "domaininvitation",
"permissions": ["add_domaininvitation", "view_domaininvitation"],
},
{
"app_label": "registrar",
"model": "website",
"permissions": ["change_website"],
},
{
"app_label": "registrar",
"model": "userdomainrole",
@ -71,6 +61,11 @@ class UserGroup(Group):
"model": "verifiedbystaff",
"permissions": ["add_verifiedbystaff", "change_verifiedbystaff", "delete_verifiedbystaff"],
},
{
"app_label": "registrar",
"model": "federalagency",
"permissions": ["add_federalagency", "change_federalagency", "delete_federalagency"],
},
]
# Avoid error: You can't execute queries until the end

View file

@ -184,7 +184,37 @@ class DomainHelper:
model_1_fields = set(field.name for field in model_1._meta.get_fields() if field.name != "id")
model_2_fields = set(field.name for field in model_2._meta.get_fields() if field.name != "id")
# Get the fields that exist on both DomainApplication and DomainInformation
# Get the fields that exist on both DomainRequest and DomainInformation
common_fields = model_1_fields & model_2_fields
return common_fields
@staticmethod
def mass_disable_fields(fields, disable_required=False, disable_maxlength=False):
"""
Given some fields, invoke .disabled = True on them.
disable_required: bool -> invokes .required = False on each field.
disable_maxlength: bool -> pops "maxlength" from each field.
"""
for field in fields.values():
field = DomainHelper.disable_field(field, disable_required, disable_maxlength)
return fields
@staticmethod
def disable_field(field, disable_required=False, disable_maxlength=False):
"""
Given a fields, invoke .disabled = True on it.
disable_required: bool -> invokes .required = False for the field.
disable_maxlength: bool -> pops "maxlength" for the field.
"""
field.disabled = True
if disable_required:
# if a field is disabled, it can't be required
field.required = False
if disable_maxlength:
# Remove the maxlength dialog
if "maxlength" in field.widget.attrs:
field.widget.attrs.pop("maxlength", None)
return field

View file

@ -35,3 +35,229 @@ class Timer:
self.end = time.time()
self.duration = self.end - self.start
logger.info(f"Execution time: {self.duration} seconds")
class CreateOrUpdateOrganizationTypeHelper:
"""
A helper that manages the "organization_type" field in DomainRequest and DomainInformation
"""
def __init__(self, sender, instance, generic_org_to_org_map, election_org_to_generic_org_map):
# The "model type"
self.sender = sender
self.instance = instance
self.generic_org_to_org_map = generic_org_to_org_map
self.election_org_to_generic_org_map = election_org_to_generic_org_map
def create_or_update_organization_type(self, force_update=False):
"""The organization_type field on DomainRequest and DomainInformation is consituted from the
generic_org_type and is_election_board fields. To keep the organization_type
field up to date, we need to update it before save based off of those field
values.
If the instance is marked as an election board and the generic_org_type is not
one of the excluded types (FEDERAL, INTERSTATE, SCHOOL_DISTRICT), the
organization_type is set to a corresponding election variant. Otherwise, it directly
mirrors the generic_org_type value.
args:
force_update (bool): If an existing instance has no values to change,
try to update the organization_type field (or related fields) anyway.
This is done by invoking the new instance handler.
Use to force org type to be updated to the correct value even
if no other changes were made (including is_election).
"""
# A new record is added with organization_type not defined.
# This happens from the regular domain request flow.
is_new_instance = self.instance.id is None
if is_new_instance:
self._handle_new_instance()
else:
self._handle_existing_instance(force_update)
return self.instance
def _handle_new_instance(self):
# == Check for invalid conditions before proceeding == #
should_proceed = self._validate_new_instance()
if not should_proceed:
return None
# == Program flow will halt here if there is no reason to update == #
# == Update the linked values == #
organization_type_needs_update = self.instance.organization_type is None
generic_org_type_needs_update = self.instance.generic_org_type is None
# If a field is none, it indicates (per prior checks) that the
# related field (generic org type <-> org type) has data and we should update according to that.
if organization_type_needs_update:
self._update_org_type_from_generic_org_and_election()
elif generic_org_type_needs_update:
self._update_generic_org_and_election_from_org_type()
# Update the field
self._update_fields(organization_type_needs_update, generic_org_type_needs_update)
def _handle_existing_instance(self, force_update_when_no_are_changes_found=False):
# == Init variables == #
# Instance is already in the database, fetch its current state
current_instance = self.sender.objects.get(id=self.instance.id)
# Check the new and old values
generic_org_type_changed = self.instance.generic_org_type != current_instance.generic_org_type
is_election_board_changed = self.instance.is_election_board != current_instance.is_election_board
organization_type_changed = self.instance.organization_type != current_instance.organization_type
# == Check for invalid conditions before proceeding == #
if organization_type_changed and (generic_org_type_changed or is_election_board_changed):
# Since organization type is linked with generic_org_type and election board,
# we have to update one or the other, not both.
# This will not happen in normal flow as it is not possible otherwise.
raise ValueError("Cannot update organization_type and generic_org_type simultaneously.")
elif not organization_type_changed and (not generic_org_type_changed and not is_election_board_changed):
# No changes found
if force_update_when_no_are_changes_found:
# If we want to force an update anyway, we can treat this record like
# its a new one in that we check for "None" values rather than changes.
self._handle_new_instance()
else:
# == Update the linked values == #
# Find out which field needs updating
organization_type_needs_update = generic_org_type_changed or is_election_board_changed
generic_org_type_needs_update = organization_type_changed
# Update the field
self._update_fields(organization_type_needs_update, generic_org_type_needs_update)
def _update_fields(self, organization_type_needs_update, generic_org_type_needs_update):
"""
Validates the conditions for updating organization and generic organization types.
Raises:
ValueError: If both organization_type_needs_update and generic_org_type_needs_update are True,
indicating an attempt to update both fields simultaneously, which is not allowed.
"""
# We shouldn't update both of these at the same time.
# It is more useful to have these as seperate variables, but it does impose
# this restraint.
if organization_type_needs_update and generic_org_type_needs_update:
raise ValueError("Cannot update both org type and generic org type at the same time.")
if organization_type_needs_update:
self._update_org_type_from_generic_org_and_election()
elif generic_org_type_needs_update:
self._update_generic_org_and_election_from_org_type()
def _update_org_type_from_generic_org_and_election(self):
"""Given a field values for generic_org_type and is_election_board, update the
organization_type field."""
# We convert to a string because the enum types are different.
generic_org_type = str(self.instance.generic_org_type)
if generic_org_type not in self.generic_org_to_org_map:
# Election board should always be reset to None if the record
# can't have one. For example, federal.
if self.instance.is_election_board is not None:
# This maintains data consistency.
# There is no avenue for this to occur in the UI,
# as such - this can only occur if the object is initialized in this way.
# Or if there are pre-existing data.
logger.debug(
"create_or_update_organization_type() -> is_election_board "
f"cannot exist for {generic_org_type}. Setting to None."
)
self.instance.is_election_board = None
self.instance.organization_type = generic_org_type
else:
# This can only happen with manual data tinkering, which causes these to be out of sync.
if self.instance.is_election_board is None:
logger.warning(
"create_or_update_organization_type() -> is_election_board is out of sync. Updating value."
)
self.instance.is_election_board = False
if self.instance.is_election_board:
self.instance.organization_type = self.generic_org_to_org_map[generic_org_type]
else:
self.instance.organization_type = generic_org_type
def _update_generic_org_and_election_from_org_type(self):
"""Given the field value for organization_type, update the
generic_org_type and is_election_board field."""
# We convert to a string because the enum types are different
# between OrgChoicesElectionOffice and OrganizationChoices.
# But their names are the same (for the most part).
current_org_type = str(self.instance.organization_type)
election_org_map = self.election_org_to_generic_org_map
generic_org_map = self.generic_org_to_org_map
# This essentially means: "_election" in current_org_type.
if current_org_type in election_org_map:
new_org = election_org_map[current_org_type]
self.instance.generic_org_type = new_org
self.instance.is_election_board = True
elif self.instance.organization_type is not None:
self.instance.generic_org_type = current_org_type
# This basically checks if the given org type
# can even have an election board in the first place.
# For instance, federal cannot so is_election_board = None
if current_org_type in generic_org_map:
self.instance.is_election_board = False
else:
# This maintains data consistency.
# There is no avenue for this to occur in the UI,
# as such - this can only occur if the object is initialized in this way.
# Or if there are pre-existing data.
logger.warning(
"create_or_update_organization_type() -> is_election_board "
f"cannot exist for {current_org_type}. Setting to None."
)
self.instance.is_election_board = None
else:
# if self.instance.organization_type is set to None, then this means
# we should clear the related fields.
# This will not occur if it just is None (i.e. default), only if it is set to be so.
self.instance.is_election_board = None
self.instance.generic_org_type = None
def _validate_new_instance(self):
"""
Validates whether a new instance of DomainRequest or DomainInformation can proceed with the update
based on the consistency between organization_type, generic_org_type, and is_election_board.
Returns a boolean determining if execution should proceed or not.
"""
# We conditionally accept both of these values to exist simultaneously, as long as
# those values do not intefere with eachother.
# Because this condition can only be triggered through a dev (no user flow),
# we throw an error if an invalid state is found here.
if self.instance.organization_type and self.instance.generic_org_type:
generic_org_type = str(self.instance.generic_org_type)
organization_type = str(self.instance.organization_type)
# Strip "_election" if it exists
mapped_org_type = self.election_org_to_generic_org_map.get(organization_type)
# Do tests on the org update for election board changes.
is_election_type = "_election" in organization_type
can_have_election_board = organization_type in self.generic_org_to_org_map
election_board_mismatch = (is_election_type != self.instance.is_election_board) and can_have_election_board
org_type_mismatch = mapped_org_type is not None and (generic_org_type != mapped_org_type)
if election_board_mismatch or org_type_mismatch:
message = (
"Cannot add organization_type and generic_org_type simultaneously "
"when generic_org_type, is_election_board, and organization_type values do not match."
)
raise ValueError(message)
return True
elif not self.instance.organization_type and not self.instance.generic_org_type:
return False
else:
return True

View file

@ -9,7 +9,6 @@ class VerifiedByStaff(TimeStampedModel):
email = models.EmailField(
null=False,
blank=False,
help_text="Email",
db_index=True,
)
@ -19,12 +18,12 @@ class VerifiedByStaff(TimeStampedModel):
blank=True,
on_delete=models.SET_NULL,
related_name="verifiedby_user",
help_text="Person who verified this user",
)
notes = models.TextField(
null=False,
blank=False,
help_text="Notes",
)
class Meta:

View file

@ -4,7 +4,7 @@ from .utility.time_stamped_model import TimeStampedModel
class Website(TimeStampedModel):
"""Keep domain names in their own table so that applications can refer to
"""Keep domain names in their own table so that domain requests can refer to
many of them."""
# domain names have strictly limited lengths, 255 characters is more than
@ -12,7 +12,7 @@ class Website(TimeStampedModel):
website = models.CharField(
max_length=255,
null=False,
help_text="",
help_text="An alternative domain or current website listed on a domain request",
)
def __str__(self) -> str:

Some files were not shown because too many files have changed in this diff Show more