merged with main and resolved merge conflicts

This commit is contained in:
Alysia Broddrick 2023-05-31 15:10:54 -07:00
commit de24db0658
No known key found for this signature in database
GPG key ID: 03917052CD0F06B7
43 changed files with 1541 additions and 333 deletions

6
.github/CODEOWNERS vendored
View file

@ -1,3 +1,7 @@
# For more information on CODEOWNERS, see the documentation: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
docs/* @dotgov
# Default owners for all files
* @abroddrick
# Owners for documentation
docs/* @cisagov/dotgov

59
.github/ISSUE_TEMPLATE/bug.yml vendored Normal file
View file

@ -0,0 +1,59 @@
name: Bug
description: Report a bug
title: "[Bug]: "
labels: ["bug"]
body:
- type: markdown
id: help
attributes:
value: |
> **Note**
> GitHub Issues use [GitHub Flavored Markdown](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax) for formatting.
- type: textarea
id: current-behavior
attributes:
label: Current Behavior
description: "Please add a concise description of the issue"
placeholder: "Example: The application shows an internal server error when attempting to apply for a new domain"
validations:
required: true
- type: textarea
id: expected-behavior
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"
validations:
required: true
- type: textarea
id: steps-to-reproduce
attributes:
label: Steps to Reproduce
description: |
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
3. See the error
value: |
1.
validations:
required: true
- type: textarea
id: environment
attributes:
label: Environment (optional)
description: |
Where is this issue occurring? If related to development environment, list the relevant tool versions.
Example:
- Environment: Sandbox
- Browser: Chrome x.y.z
- Python: x.y.z
- type: textarea
id: additional-context
attributes:
label: Additional Context (optional)
description: "Please include additional references, screenshots, documentation, etc. that are relevant"

View file

@ -34,6 +34,8 @@ cf login -a api.fr.cloud.gov --sso
**Note:** As mentioned in the [Login documentation](https://developers.login.gov/testing/), the sandbox Login account is different account from your regular, production Login account. If you have not created a Login account for the sandbox before, you will need to create a new account first.
- [ ] Optional- add yourself as a codeowner if desired. See the [Developer readme](https://github.com/cisagov/getgov/blob/main/docs/developer/README.md) for how to do this and what it does.
### Steps for the onboarder
- [ ] Add the onboardee to cloud.gov org (cisa-getgov-prototyping)
- [ ] Setup a [developer specific space for the new developer](#setting-up-developer-sandbox)

View file

@ -1,32 +1,32 @@
name: Standard Issue Tempalte
description: Create an Issue
title: "[Issue]:"
labels: ["research"]
assignees:
- kitsushadow
name: Issue
description: Provide a title for the issue you are describing
title: "Please provide a clear title"
labels: ["review"]
# assignees:
# - kitsushadow
body:
- type: markdown
attributes:
value: |
Please fill this out best you can
Describe the ticket your are capturing in further detail.
- type: textarea
id: why
attributes:
label: Why or Acceptance Criteria
description: What is the reason we determined we need an issue.
placeholder: This issue is a product of...
value: "While working on X or researching Y it was discovered that Z was an issue that need captured."
label: Ticket Description
description: Please provide details to accurately reflect why this ticket is being captured and also what is necessary to resolve.
placeholder: Provide details describing your lead up to needing this issue as well as any resolution or requirements for resolving or working on this more.
value: "While (working on or discussing) (issue #000 or domain request validation) I discovered there was (a missing workflow, an improvement, a missing feature). To resolve this (more research, a new feature, a new field, an interview) is required. Provide any links, screenshots, or mockups which would further detail the description."
validations:
required: true
- type: dropdown
id: type
attributes:
label: Issue Type
description: What type of issue is this
description: Does this work require
options:
- research (Default)
- dev
- product
- discovery (Default)
- development
- design review
validations:
required: true
- type: textarea
@ -34,4 +34,4 @@ body:
attributes:
label: Link dependent issues
description: If this ticket is dependent on another issue or blocks a current issue, please link.
render: shell
render: shell

62
.github/ISSUE_TEMPLATE/user-story.yml vendored Normal file
View file

@ -0,0 +1,62 @@
name: User Story
description: Capture actionable sprint work
title: "[Story]: "
labels: ["story"]
body:
- type: markdown
id: help
attributes:
value: |
> **Note**
> GitHub Issues use [GitHub Flavored Markdown](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax) for formatting.
- type: textarea
id: story
attributes:
label: User Story
description: |
Please add the "as a, I want, so that" details that describe the user story.
If more than one "as a, I want, so that" describes the story, add multiple.
Example:
As an administrator
I want the ability to approve a domain application
so that a request can be fulfilled and a new .gov domain can be provisioned
value: |
As a
I want
so that
validations:
required: true
- type: textarea
id: acceptance-criteria
attributes:
label: Acceptance Criteria
description: |
Please add the acceptance criteria using one or more "given, when, then" formulae
Example:
Given that I am an administrator who has finished reviewing a domain application
When I click to approve a domain application
Then the domain provisioning process should be initiated, and the applicant should receive an email update.
value: |
Given
When
Then
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: Additional Context (optional)
description: "Please include additional references, screenshots, documentation, etc. that are relevant"
- type: textarea
id: issue-links
attributes:
label: Issue Links (optional)
description: |
What other issues does this story relate to and how?
Example:
- 🚧 Blocked by: #123
- 🔄 Relates to: #234

5
.github/SECURITY.md vendored Normal file
View file

@ -0,0 +1,5 @@
* If you've found a security or privacy issue on the **.gov top-level domain infrastructure**, submit it to our [vulnerabilty disclosure form](https://forms.office.com/Pages/ResponsePage.aspx?id=bOfNPG2UEkq7evydCEI1SqHke9Gh6wJEl3kQ5EjWUKlUMTZZS1lBVkxHUzZURFpLTkE2NEJFVlhVRi4u) or email dotgov@cisa.dhs.gov.
* If you see a security or privacy issue on **an individual .gov domain**, check [current-full.csv](https://flatgithub.com/cisagov/dotgov-data/blob/main/?filename=current-full.csv) or [Whois](https://domains.dotgov.gov/dotgov-web/registration/whois.xhtml) (same data) to check whether the domain has a security contact to report your finding directly. You are welcome to Cc dotgov@cisa.dhs.gov on the email.
* If you are unable to find a contact or receive no response from the security contact, email dotgov@cisa.dhs.gov.
Note that most federal (executive branch) agencies maintain a [vulnerability disclosure policy](https://github.com/cisagov/vdp-in-fceb/).

View file

@ -0,0 +1,158 @@
# Data Model Timeline
This diagram connects the data models along with various workflow stages.
1. The applicant starts the process at `/register` interacting with the
`DomainApplication` object.
2. The analyst approves the application using the `DomainApplication`'s
`approve()` method which creates many related objects: `UserDomainRole`,
`Domain`, and `DomainInformation`.
3. After the domain is approved, users interact with various
`/domain/<id>/...` views which make changes to the `Domain`,
`DomainInformation`, and `UserDomainRole` models. For inviting new users,
there is a `DomainInvitation` model that allows people to be added to
domains who are not already users.
A more complete diagram of the data models, their fields, and their
relationships are in [models_diagram.md](./models_diagram.md), created with
the `django-model2puml` plugin.
![Data model timeline diagram](./model_timeline.svg)
<details>
<summary>PlantUML source code</summary>
To regenerate this image using Docker, run
```bash
$ docker run -v $(pwd):$(pwd) -w $(pwd) -it plantuml/plantuml -tsvg model_timeline.md
```
```plantuml
@startuml
allowmixing
left to right direction
class DomainApplication {
Application for a domain
--
creator (User)
investigator (User)
authorizing_official (Contact)
submitter (Contact)
other_contacts (Contacts)
approved_domain (Domain)
requested_domain (DraftDomain)
current_websites (Websites)
alternative_domains (Websites)
--
Request information...
}
class User {
Django's user class
--
...
--
}
note left of User
Created by DjangoOIDC
when users arrive back
from Login.gov
<b>username</b> is the Login UUID
end note
DomainApplication -l- User : creator, investigator
class Contact {
Contact info for a person
--
first_name
middle_name
last_name
title
email
phone
--
}
DomainApplication *-r-* Contact : authorizing_official, submitter, other_contacts
class DraftDomain {
Requested domain
--
name
--
}
DomainApplication -l- DraftDomain : requested_domain
class Domain {
Approved domain
--
name
--
<b>EPP methods</b>
}
DomainApplication .right[#blue].> Domain : approve()
class DomainInformation {
Registrar information on a domain
--
domain (Domain)
domain_application (DomainApplication)
security_email
--
Request information...
}
DomainInformation -- Domain
DomainInformation -- DomainApplication
DomainApplication .[#blue].> DomainInformation : approve()
class UserDomainRole {
Permissions
--
domain (Domain)
user (User)
role="ADMIN"
--
}
UserDomainRole -- User
UserDomainRole -- Domain
DomainApplication .[#blue].> UserDomainRole : approve()
class DomainInvitation {
Email invitations sent
--
email
domain (Domain)
status
--
}
DomainInvitation -- Domain
DomainInvitation .[#green].> UserDomainRole : User.first_login()
actor applicant #Red
applicant -d-> DomainApplication : **/register**
actor analyst #Blue
analyst -[#blue]-> DomainApplication : **approve()**
actor user1 #Green
user1 -[#green]-> Domain : **/domain/<id>/nameservers**
actor user2 #Green
user2 -[#green]-> DomainInformation : **/domain/<id>/?????**
actor user3 #Green
user3 -right[#green]-> UserDomainRole : **/domain/<id>/users/add**
user3 -right[#green]-> DomainInvitation : **/domain/<id>/users/add**
@enduml
```
</details>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 30 KiB

View file

@ -0,0 +1,291 @@
# Complete model documentation
This is an auto-generated diagram of our data models generated with the
[django-model2puml](https://github.com/sen-den/django-model2puml) library
using the command
```bash
$ docker compose app ./manage.py generate_puml --include registrar
```
![Complete data models diagram](./models_diagram.svg)
<details>
<summary>PlantUML source code</summary>
To regenerate this image using Docker, run
```bash
$ docker run -v $(pwd):$(pwd) -w $(pwd) -it plantuml/plantuml -tsvg models_diagram.md
```
```plantuml
@startuml
class "registrar.Contact <Registrar>" as registrar.Contact #d6f4e9 {
contact
--
+ id (BigAutoField)
+ created_at (DateTimeField)
+ updated_at (DateTimeField)
~ user (OneToOneField)
+ first_name (TextField)
+ middle_name (TextField)
+ last_name (TextField)
+ title (TextField)
+ email (TextField)
+ phone (PhoneNumberField)
--
}
registrar.Contact -- registrar.User
class "registrar.DomainApplication <Registrar>" as registrar.DomainApplication #d6f4e9 {
domain application
--
+ id (BigAutoField)
+ created_at (DateTimeField)
+ updated_at (DateTimeField)
+ status (FSMField)
~ creator (ForeignKey)
~ investigator (ForeignKey)
+ organization_type (CharField)
+ federally_recognized_tribe (BooleanField)
+ state_recognized_tribe (BooleanField)
+ tribe_name (TextField)
+ federal_agency (TextField)
+ federal_type (CharField)
+ is_election_board (BooleanField)
+ organization_name (TextField)
+ address_line1 (TextField)
+ address_line2 (CharField)
+ city (TextField)
+ state_territory (CharField)
+ zipcode (CharField)
+ urbanization (TextField)
+ type_of_work (TextField)
+ more_organization_information (TextField)
~ authorizing_official (ForeignKey)
~ approved_domain (OneToOneField)
~ requested_domain (OneToOneField)
~ submitter (ForeignKey)
+ purpose (TextField)
+ no_other_contacts_rationale (TextField)
+ anything_else (TextField)
+ is_policy_acknowledged (BooleanField)
# current_websites (ManyToManyField)
# alternative_domains (ManyToManyField)
# 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
class "registrar.DomainInformation <Registrar>" as registrar.DomainInformation #d6f4e9 {
domain information
--
+ id (BigAutoField)
+ created_at (DateTimeField)
+ updated_at (DateTimeField)
~ creator (ForeignKey)
~ domain_application (OneToOneField)
+ organization_type (CharField)
+ federally_recognized_tribe (BooleanField)
+ state_recognized_tribe (BooleanField)
+ tribe_name (TextField)
+ federal_agency (TextField)
+ federal_type (CharField)
+ is_election_board (BooleanField)
+ organization_name (TextField)
+ address_line1 (TextField)
+ address_line2 (CharField)
+ city (TextField)
+ state_territory (CharField)
+ zipcode (CharField)
+ urbanization (TextField)
+ type_of_work (TextField)
+ more_organization_information (TextField)
~ authorizing_official (ForeignKey)
~ domain (OneToOneField)
~ submitter (ForeignKey)
+ purpose (TextField)
+ no_other_contacts_rationale (TextField)
+ anything_else (TextField)
+ is_policy_acknowledged (BooleanField)
+ security_email (EmailField)
# other_contacts (ManyToManyField)
--
}
registrar.DomainInformation -- registrar.User
registrar.DomainInformation -- registrar.DomainApplication
registrar.DomainInformation -- registrar.Contact
registrar.DomainInformation -- registrar.Domain
registrar.DomainInformation -- registrar.Contact
registrar.DomainInformation *--* registrar.Contact
class "registrar.DraftDomain <Registrar>" as registrar.DraftDomain #d6f4e9 {
draft domain
--
+ id (BigAutoField)
+ created_at (DateTimeField)
+ updated_at (DateTimeField)
+ name (CharField)
--
}
class "registrar.Domain <Registrar>" as registrar.Domain #d6f4e9 {
domain
--
+ id (BigAutoField)
+ created_at (DateTimeField)
+ updated_at (DateTimeField)
+ name (CharField)
--
}
class "registrar.HostIP <Registrar>" as registrar.HostIP #d6f4e9 {
host ip
--
+ id (BigAutoField)
+ created_at (DateTimeField)
+ updated_at (DateTimeField)
+ address (CharField)
~ host (ForeignKey)
--
}
registrar.HostIP -- registrar.Host
class "registrar.Host <Registrar>" as registrar.Host #d6f4e9 {
host
--
+ id (BigAutoField)
+ created_at (DateTimeField)
+ updated_at (DateTimeField)
+ name (CharField)
~ domain (ForeignKey)
--
}
registrar.Host -- registrar.Domain
class "registrar.UserDomainRole <Registrar>" as registrar.UserDomainRole #d6f4e9 {
user domain role
--
+ id (BigAutoField)
+ created_at (DateTimeField)
+ updated_at (DateTimeField)
~ user (ForeignKey)
~ domain (ForeignKey)
+ role (TextField)
--
}
registrar.UserDomainRole -- registrar.User
registrar.UserDomainRole -- registrar.Domain
class "registrar.DomainInvitation <Registrar>" as registrar.DomainInvitation #d6f4e9 {
domain invitation
--
+ id (BigAutoField)
+ created_at (DateTimeField)
+ updated_at (DateTimeField)
+ email (EmailField)
~ domain (ForeignKey)
+ status (FSMField)
--
}
registrar.DomainInvitation -- registrar.Domain
class "registrar.Nameserver <Registrar>" as registrar.Nameserver #d6f4e9 {
nameserver
--
+ id (BigAutoField)
+ created_at (DateTimeField)
+ updated_at (DateTimeField)
+ name (CharField)
~ domain (ForeignKey)
~ host_ptr (OneToOneField)
--
}
registrar.Nameserver -- registrar.Domain
registrar.Nameserver -- registrar.Host
class "registrar.PublicContact <Registrar>" as registrar.PublicContact #d6f4e9 {
public contact
--
+ id (BigAutoField)
+ created_at (DateTimeField)
+ updated_at (DateTimeField)
+ contact_type (CharField)
+ registry_id (CharField)
~ domain (ForeignKey)
+ name (TextField)
+ org (TextField)
+ street1 (TextField)
+ street2 (TextField)
+ street3 (TextField)
+ city (TextField)
+ sp (TextField)
+ pc (TextField)
+ cc (TextField)
+ email (TextField)
+ voice (TextField)
+ fax (TextField)
+ pw (TextField)
--
}
registrar.PublicContact -- registrar.Domain
class "registrar.User <Registrar>" as registrar.User #d6f4e9 {
user
--
+ id (BigAutoField)
+ password (CharField)
+ last_login (DateTimeField)
+ is_superuser (BooleanField)
+ username (CharField)
+ first_name (CharField)
+ last_name (CharField)
+ email (EmailField)
+ is_staff (BooleanField)
+ is_active (BooleanField)
+ date_joined (DateTimeField)
+ phone (PhoneNumberField)
# groups (ManyToManyField)
# user_permissions (ManyToManyField)
# domains (ManyToManyField)
--
}
registrar.User *--* registrar.Domain
class "registrar.Website <Registrar>" as registrar.Website #d6f4e9 {
website
--
+ id (BigAutoField)
+ created_at (DateTimeField)
+ updated_at (DateTimeField)
+ website (CharField)
--
}
@enduml
```
</details>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 72 KiB

View file

@ -81,6 +81,22 @@ The endpoint /admin can be used to view and manage site content, including but n
5. In the browser, navigate to /admins. To verify that all is working correctly, under "domain applications" you should see fake domains with various fake statuses.
## 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.
1. Go to [.github\CODEOWNERS](../../.github/CODEOWNERS)
2. Following the [CODEOWNERS documentation](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners), add yourself as owner to files that you wish to be automatically requested as reviewer for.
For example, if you wish to add yourself as a default reviewer for all pull requests, add your GitHub username to the same line as the `*` designator:
```diff
- * @abroddrick
+ * @abroddrick @YourGitHubUser
```
3. Create a pull request to finalize your changes
## Viewing Logs
If you run via `docker-compose up`, you'll see the logs in your terminal.

View file

@ -0,0 +1,5 @@
# How to change the default contact data
The defaults are located in [src/registrar/models/public_contact.py](../../src/registrar/models/public_contact.py). Change them in the source code and re-deploy.
The choice of which fields to disclose is hardcoded in [src/registrar/models/domain.py](../../src/registrar/models/domain.py) (May 23) but in the future this may become customizable by registrants.

View file

@ -37,3 +37,4 @@ django-webtest = "*"
types-cachetools = "*"
boto3-mocking = "*"
boto3-stubs = "*"
django-model2puml = "*"

171
src/Pipfile.lock generated
View file

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "ebec8b958bcfde525ad74aa1e777b55855e86b2d63264612bc2855bf167070b1"
"sha256": "b6c1a957da6c715c734906059a81da21cb0eb4c4ab04f204eb58a48ddb8f7234"
},
"pipfile-spec": 6,
"requires": {},
@ -24,19 +24,19 @@
},
"boto3": {
"hashes": [
"sha256:38ca632be379963f2a2749b5f63a81fe1679913b954914f470ad282c77674bbc",
"sha256:4d575c180312bec6108852bae12e6396b9d1bb404154d652c57ee849c62fbb83"
"sha256:62285ecee7629a4388d55ae369536f759622d68d5b9a0ced7c58a0c1a409c0f7",
"sha256:8ff0af0b25266a01616396abc19eb34dc3d44bd867fa4158985924128b9034fb"
],
"index": "pypi",
"version": "==1.26.122"
"version": "==1.26.133"
},
"botocore": {
"hashes": [
"sha256:9e4984a9e9777c6b949aa1e98323fa35480d9f99d447af7e179ae611f7ed5af9",
"sha256:c3b41078d235761b9c5dc22f534a76952622ef96787b96bbd10242ec4d73f2a5"
"sha256:7b38e540f73c921d8cb0ac72794072000af9e10758c04ba7f53d5629cc52fa87",
"sha256:b266185d7414a559952569005009a400de50af91fd3da44f05cf05b00946c4a7"
],
"markers": "python_version >= '3.7'",
"version": "==1.29.122"
"version": "==1.29.133"
},
"cachetools": {
"hashes": [
@ -48,11 +48,11 @@
},
"certifi": {
"hashes": [
"sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3",
"sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"
"sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7",
"sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716"
],
"markers": "python_version >= '3.6'",
"version": "==2022.12.7"
"version": "==2023.5.7"
},
"cfenv": {
"hashes": [
@ -261,11 +261,11 @@
},
"django": {
"hashes": [
"sha256:ad33ed68db9398f5dfb33282704925bce044bef4261cd4fb59e4e7f9ae505a78",
"sha256:c36e2ab12824e2ac36afa8b2515a70c53c7742f0d6eaefa7311ec379558db997"
"sha256:066b6debb5ac335458d2a713ed995570536c8b59a580005acb0732378d5eb1ee",
"sha256:7efa6b1f781a6119a10ac94b4794ded90db8accbe7802281cd26f8664ffed59c"
],
"index": "pypi",
"version": "==4.2"
"version": "==4.2.1"
},
"django-allow-cidr": {
"hashes": [
@ -338,11 +338,11 @@
},
"faker": {
"hashes": [
"sha256:49060d40e6659e116f53353c5771ad2f2cbcd12b15771f49e3000a3a451f13ec",
"sha256:ac903ba8cb5adbce2cdd15e5536118d484bbe01126f3c774dd9f6df77b61232d"
"sha256:38dbc3b80e655d7301e190426ab30f04b6b7f6ca4764c5dd02772ffde0fa6dcd",
"sha256:f02c6d3fdb5bc781f80b440cf2bdec336ed47ecfb8d620b20c3d4188ed051831"
],
"index": "pypi",
"version": "==18.6.0"
"version": "==18.7.0"
},
"furl": {
"hashes": [
@ -623,19 +623,19 @@
},
"requests": {
"hashes": [
"sha256:e8f3c9be120d3333921d213eef078af392fba3933ab7ed2d1cba3b56f2568c3b",
"sha256:f2e34a75f4749019bb0e3effb66683630e4ffeaf75819fb51bebef1bf5aef059"
"sha256:10e94cc4f3121ee6da529d358cdaeaff2f1c409cd377dbc72b825852f2f7e294",
"sha256:239d7d4458afcb28a692cdd298d87542235f4ca8d36d03a15bfc128a6559a2f4"
],
"index": "pypi",
"version": "==2.29.0"
"version": "==2.30.0"
},
"s3transfer": {
"hashes": [
"sha256:06176b74f3a15f61f1b4f25a1fc29a4429040b7647133a463da8fa5bd28d5ecd",
"sha256:2ed07d3866f523cc561bf4a00fc5535827981b117dd7876f036b0c1aca42c947"
"sha256:3c0da2d074bf35d6870ef157158641178a4204a6e689e82546083e31e0311346",
"sha256:640bb492711f4c0c0905e1f62b6aaeb771881935ad27884852411f8e9cacbca9"
],
"markers": "python_version >= '3.7'",
"version": "==0.6.0"
"version": "==0.6.1"
},
"setuptools": {
"hashes": [
@ -752,11 +752,11 @@
},
"boto3": {
"hashes": [
"sha256:38ca632be379963f2a2749b5f63a81fe1679913b954914f470ad282c77674bbc",
"sha256:4d575c180312bec6108852bae12e6396b9d1bb404154d652c57ee849c62fbb83"
"sha256:62285ecee7629a4388d55ae369536f759622d68d5b9a0ced7c58a0c1a409c0f7",
"sha256:8ff0af0b25266a01616396abc19eb34dc3d44bd867fa4158985924128b9034fb"
],
"index": "pypi",
"version": "==1.26.122"
"version": "==1.26.133"
},
"boto3-mocking": {
"hashes": [
@ -768,27 +768,27 @@
},
"boto3-stubs": {
"hashes": [
"sha256:401e7fe51d88a51b527d883d195ed20c7f57aeb2c0aea24bbb3e911b6d2ad3aa",
"sha256:743a37bfd7d1eed4d67cdf825283abc1d93b7900b81d7426aab7e691e075c897"
"sha256:a921814574761842073822dc5e9fc7ca4f1c5fdeaa53d83cd8831e060dae09c8",
"sha256:cc6a662700e755c1e3dec2383c146b89cd8c70b5921033504bfb8367d03a538f"
],
"index": "pypi",
"version": "==1.26.122"
"version": "==1.26.133"
},
"botocore": {
"hashes": [
"sha256:9e4984a9e9777c6b949aa1e98323fa35480d9f99d447af7e179ae611f7ed5af9",
"sha256:c3b41078d235761b9c5dc22f534a76952622ef96787b96bbd10242ec4d73f2a5"
"sha256:7b38e540f73c921d8cb0ac72794072000af9e10758c04ba7f53d5629cc52fa87",
"sha256:b266185d7414a559952569005009a400de50af91fd3da44f05cf05b00946c4a7"
],
"markers": "python_version >= '3.7'",
"version": "==1.29.122"
"version": "==1.29.133"
},
"botocore-stubs": {
"hashes": [
"sha256:59873a3b535ec3ff0b6bf5f41c9f8a0f8c48032a871bea4d6e4faebbbfc68e8b",
"sha256:e6e6c527a6cac0ec69dd1b755d530c9b2dab01d423ce47bdc636dd01ebb01b1b"
"sha256:5f6f1967d23c45834858a055cbf65b66863f9f28d05f32f57bf52864a13512d9",
"sha256:622c4a5cd740498439008d81c5ded612146f4f0d575341c12591f978edbbe733"
],
"markers": "python_version >= '3.7' and python_version < '4.0'",
"version": "==1.29.122"
"version": "==1.29.130"
},
"click": {
"hashes": [
@ -800,11 +800,11 @@
},
"django": {
"hashes": [
"sha256:ad33ed68db9398f5dfb33282704925bce044bef4261cd4fb59e4e7f9ae505a78",
"sha256:c36e2ab12824e2ac36afa8b2515a70c53c7742f0d6eaefa7311ec379558db997"
"sha256:066b6debb5ac335458d2a713ed995570536c8b59a580005acb0732378d5eb1ee",
"sha256:7efa6b1f781a6119a10ac94b4794ded90db8accbe7802281cd26f8664ffed59c"
],
"index": "pypi",
"version": "==4.2"
"version": "==4.2.1"
},
"django-debug-toolbar": {
"hashes": [
@ -814,6 +814,13 @@
"index": "pypi",
"version": "==4.0.0"
},
"django-model2puml": {
"hashes": [
"sha256:6e773d742e556020a04d3216ce5dee5d3551da162e2d42a997f85b4ed1854771"
],
"index": "pypi",
"version": "==0.4.1"
},
"django-stubs": {
"hashes": [
"sha256:93baff824f0a056e71036b423b942a74f07b909e45e3fa38185b910f597c5c08",
@ -896,35 +903,35 @@
},
"mypy": {
"hashes": [
"sha256:023fe9e618182ca6317ae89833ba422c411469156b690fde6a315ad10695a521",
"sha256:031fc69c9a7e12bcc5660b74122ed84b3f1c505e762cc4296884096c6d8ee140",
"sha256:2de7babe398cb7a85ac7f1fd5c42f396c215ab3eff731b4d761d68d0f6a80f48",
"sha256:2e93a8a553e0394b26c4ca683923b85a69f7ccdc0139e6acd1354cc884fe0128",
"sha256:390bc685ec209ada4e9d35068ac6988c60160b2b703072d2850457b62499e336",
"sha256:3a2d219775a120581a0ae8ca392b31f238d452729adbcb6892fa89688cb8306a",
"sha256:3efde4af6f2d3ccf58ae825495dbb8d74abd6d176ee686ce2ab19bd025273f41",
"sha256:4a99fe1768925e4a139aace8f3fb66db3576ee1c30b9c0f70f744ead7e329c9f",
"sha256:4b41412df69ec06ab141808d12e0bf2823717b1c363bd77b4c0820feaa37249e",
"sha256:4c8d8c6b80aa4a1689f2a179d31d86ae1367ea4a12855cc13aa3ba24bb36b2d8",
"sha256:4d19f1a239d59f10fdc31263d48b7937c585810288376671eaf75380b074f238",
"sha256:4e4a682b3f2489d218751981639cffc4e281d548f9d517addfd5a2917ac78119",
"sha256:695c45cea7e8abb6f088a34a6034b1d273122e5530aeebb9c09626cea6dca4cb",
"sha256:701189408b460a2ff42b984e6bd45c3f41f0ac9f5f58b8873bbedc511900086d",
"sha256:70894c5345bea98321a2fe84df35f43ee7bb0feec117a71420c60459fc3e1eed",
"sha256:8293a216e902ac12779eb7a08f2bc39ec6c878d7c6025aa59464e0c4c16f7eb9",
"sha256:8d26b513225ffd3eacece727f4387bdce6469192ef029ca9dd469940158bc89e",
"sha256:a197ad3a774f8e74f21e428f0de7f60ad26a8d23437b69638aac2764d1e06a6a",
"sha256:bea55fc25b96c53affab852ad94bf111a3083bc1d8b0c76a61dd101d8a388cf5",
"sha256:c9a084bce1061e55cdc0493a2ad890375af359c766b8ac311ac8120d3a472950",
"sha256:d0e9464a0af6715852267bf29c9553e4555b61f5904a4fc538547a4d67617937",
"sha256:d8e9187bfcd5ffedbe87403195e1fc340189a68463903c39e2b63307c9fa0394",
"sha256:eaeaa0888b7f3ccb7bcd40b50497ca30923dba14f385bde4af78fac713d6d6f6",
"sha256:f46af8d162f3d470d8ffc997aaf7a269996d205f9d746124a179d3abe05ac602",
"sha256:f70a40410d774ae23fcb4afbbeca652905a04de7948eaf0b1789c8d1426b72d1",
"sha256:fe91be1c51c90e2afe6827601ca14353bbf3953f343c2129fa1e247d55fd95ba"
"sha256:1c4c42c60a8103ead4c1c060ac3cdd3ff01e18fddce6f1016e08939647a0e703",
"sha256:44797d031a41516fcf5cbfa652265bb994e53e51994c1bd649ffcd0c3a7eccbf",
"sha256:473117e310febe632ddf10e745a355714e771ffe534f06db40702775056614c4",
"sha256:4c99c3ecf223cf2952638da9cd82793d8f3c0c5fa8b6ae2b2d9ed1e1ff51ba85",
"sha256:550a8b3a19bb6589679a7c3c31f64312e7ff482a816c96e0cecec9ad3a7564dd",
"sha256:658fe7b674769a0770d4b26cb4d6f005e88a442fe82446f020be8e5f5efb2fae",
"sha256:6e33bb8b2613614a33dff70565f4c803f889ebd2f859466e42b46e1df76018dd",
"sha256:6e42d29e324cdda61daaec2336c42512e59c7c375340bd202efa1fe0f7b8f8ca",
"sha256:74bc9b6e0e79808bf8678d7678b2ae3736ea72d56eede3820bd3849823e7f305",
"sha256:76ec771e2342f1b558c36d49900dfe81d140361dd0d2df6cd71b3db1be155409",
"sha256:7d23370d2a6b7a71dc65d1266f9a34e4cde9e8e21511322415db4b26f46f6b8c",
"sha256:87df44954c31d86df96c8bd6e80dfcd773473e877ac6176a8e29898bfb3501cb",
"sha256:8c5979d0deb27e0f4479bee18ea0f83732a893e81b78e62e2dda3e7e518c92ee",
"sha256:95d8d31a7713510685b05fbb18d6ac287a56c8f6554d88c19e73f724a445448a",
"sha256:a22435632710a4fcf8acf86cbd0d69f68ac389a3892cb23fbad176d1cddaf228",
"sha256:a8763e72d5d9574d45ce5881962bc8e9046bf7b375b0abf031f3e6811732a897",
"sha256:c1eb485cea53f4f5284e5baf92902cd0088b24984f4209e25981cc359d64448d",
"sha256:c5d2cc54175bab47011b09688b418db71403aefad07cbcd62d44010543fc143f",
"sha256:cbc07246253b9e3d7d74c9ff948cd0fd7a71afcc2b77c7f0a59c26e9395cb152",
"sha256:d0b6c62206e04061e27009481cb0ec966f7d6172b5b936f3ead3d74f29fe3dcf",
"sha256:ddae0f39ca146972ff6bb4399f3b2943884a774b8771ea0a8f50e971f5ea5ba8",
"sha256:e1f4d16e296f5135624b34e8fb741eb0eadedca90862405b1f1fde2040b9bd11",
"sha256:e86c2c6852f62f8f2b24cb7a613ebe8e0c7dc1402c61d36a609174f63e0ff017",
"sha256:ebc95f8386314272bbc817026f8ce8f4f0d2ef7ae44f947c4664efac9adec929",
"sha256:f9dca1e257d4cc129517779226753dbefb4f2266c4eaad610fc15c6a7e14283e",
"sha256:faff86aa10c1aa4a10e1a301de160f3d8fc8703b88c7e98de46b531ff1276a9a"
],
"index": "pypi",
"version": "==1.2.0"
"version": "==1.3.0"
},
"mypy-extensions": {
"hashes": [
@ -968,11 +975,11 @@
},
"platformdirs": {
"hashes": [
"sha256:47692bc24c1958e8b0f13dd727307cff1db103fca36399f457da8e05f222fdc4",
"sha256:7954a68d0ba23558d753f73437c55f89027cf8f5108c19844d4b82e5af396335"
"sha256:412dae91f52a6f84830f39a8078cecd0e866cb72294a5c66808e74d5e88d251f",
"sha256:e2378146f1964972c03c085bb5662ae80b2b8c06226c54b2ff4aa9483e8a13a5"
],
"markers": "python_version >= '3.7'",
"version": "==3.5.0"
"version": "==3.5.1"
},
"pycodestyle": {
"hashes": [
@ -1062,11 +1069,11 @@
},
"s3transfer": {
"hashes": [
"sha256:06176b74f3a15f61f1b4f25a1fc29a4429040b7647133a463da8fa5bd28d5ecd",
"sha256:2ed07d3866f523cc561bf4a00fc5535827981b117dd7876f036b0c1aca42c947"
"sha256:3c0da2d074bf35d6870ef157158641178a4204a6e689e82546083e31e0311346",
"sha256:640bb492711f4c0c0905e1f62b6aaeb771881935ad27884852411f8e9cacbca9"
],
"markers": "python_version >= '3.7'",
"version": "==0.6.0"
"version": "==0.6.1"
},
"six": {
"hashes": [
@ -1118,11 +1125,11 @@
},
"types-awscrt": {
"hashes": [
"sha256:40854d9d7ce055620d5d41e5adc84df11b879aedbd2cf20de84e73f084aa5797",
"sha256:fe38c6fd71199a9f739b69a7c2f3a574585457c4f63730a62830628a7bffc5b0"
"sha256:9e447df3ad46767887d14fa9c856df94f80e8a0a7f0169577ab23b52ee37bcdf",
"sha256:e28fb3f20568ce9e96e33e01e0b87b891822f36b8f368adb582553b016d4aa08"
],
"markers": "python_version >= '3.7' and python_version < '4.0'",
"version": "==0.16.16"
"version": "==0.16.17"
},
"types-cachetools": {
"hashes": [
@ -1148,26 +1155,26 @@
},
"types-requests": {
"hashes": [
"sha256:0d580652ce903f643f8c3b494dd01d29367ea57cea0c7ad7f65cf3169092edb0",
"sha256:cc1aba862575019306b2ed134eb1ea994cab1c887a22e18d3383e6dd42e9789b"
"sha256:c6cf08e120ca9f0dc4fa4e32c3f953c3fba222bcc1db6b97695bce8da1ba9864",
"sha256:dec781054324a70ba64430ae9e62e7e9c8e4618c185a5cb3f87a6738251b5a31"
],
"index": "pypi",
"version": "==2.28.11.17"
"version": "==2.30.0.0"
},
"types-s3transfer": {
"hashes": [
"sha256:40e665643f0647832d51c4a26d8a8275cda9134b02bf22caf28198b79bcad382",
"sha256:d9c669b30fdd61347720434aacb8ecc4645d900712a70b10f495104f9039c07b"
"sha256:6d1ac1dedac750d570428362acdf60fdd4f277b0788855c3894d3226756b2bfb",
"sha256:75ac1d7143d58c1e6af467cfd4a96c67ee058a3adf7c249d9309999e1f5f41e4"
],
"markers": "python_version >= '3.7' and python_version < '4.0'",
"version": "==0.6.0.post7"
"version": "==0.6.1"
},
"types-urllib3": {
"hashes": [
"sha256:04235e792139cf3624b25d38faab593456738fbdb7439634046172e3b1339400",
"sha256:697102ddf4f781eed6f692353f40cee1098643526f5a8b99f49d2ede90fd3754"
"sha256:3300538c9dc11dad32eae4827ac313f5d986b8b21494801f1bf97a1ac6c03ae5",
"sha256:5dbd1d2bef14efee43f5318b5d36d805a489f6600252bb53626d4bfafd95e27c"
],
"version": "==1.26.25.11"
"version": "==1.26.25.13"
},
"typing-extensions": {
"hashes": [

View file

@ -37,7 +37,7 @@ def _domains():
Fetch a file from DOMAIN_FILE_URL, parse the CSV for the domain,
lowercase everything and return the list.
"""
Domain = apps.get_model("registrar.Domain")
DraftDomain = apps.get_model("registrar.DraftDomain")
# 5 second timeout
file_contents = requests.get(DOMAIN_FILE_URL, timeout=5).text
domains = set()
@ -46,7 +46,7 @@ def _domains():
# get the domain before the first comma
domain = line.split(",", 1)[0]
# sanity-check the string we got from the file here
if Domain.string_could_be_domain(domain):
if DraftDomain.string_could_be_domain(domain):
# lowercase everything when we put it in domains
domains.add(domain.lower())
return domains
@ -75,12 +75,12 @@ def available(request, domain=""):
Response is a JSON dictionary with the key "available" and value true or
false.
"""
Domain = apps.get_model("registrar.Domain")
DraftDomain = apps.get_model("registrar.DraftDomain")
# validate that the given domain could be a domain name and fail early if
# not.
if not (
Domain.string_could_be_domain(domain)
or Domain.string_could_be_domain(domain + ".gov")
DraftDomain.string_could_be_domain(domain)
or DraftDomain.string_could_be_domain(domain + ".gov")
):
return JsonResponse(
{"available": False, "message": DOMAIN_API_MESSAGES["invalid"]}

View file

@ -43,10 +43,15 @@ except NameError:
# Attn: these imports should NOT be at the top of the file
try:
from .client import CLIENT, commands
from .errors import RegistryError, ErrorCode
from epplib.models import common
except ImportError:
pass
__all__ = [
"CLIENT",
"commands",
"common",
"ErrorCode",
"RegistryError",
]

View file

@ -67,54 +67,47 @@ class EPPLibWrapper:
def _send(self, command):
"""Helper function used by `send`."""
try:
cmd_type = command.__class__.__name__
with self._connect as wire:
response = wire.send(command)
except (ValueError, ParsingError) as err:
logger.warning(
"%s failed to execute due to some syntax error."
% command.__class__.__name__,
exc_info=True,
)
raise RegistryError() from err
message = "%s failed to execute due to some syntax error."
logger.warning(message, cmd_type, exc_info=True)
raise RegistryError(message) from err
except TransportError as err:
logger.warning(
"%s failed to execute due to a connection error."
% command.__class__.__name__,
exc_info=True,
)
raise RegistryError() from err
message = "%s failed to execute due to a connection error."
logger.warning(message, cmd_type, exc_info=True)
raise RegistryError(message) from err
except LoginError as err:
logger.warning(
"%s failed to execute due to a registry login error."
% command.__class__.__name__,
exc_info=True,
)
raise RegistryError() from err
message = "%s failed to execute due to a registry login error."
logger.warning(message, cmd_type, exc_info=True)
raise RegistryError(message) from err
except Exception as err:
logger.warning(
"%s failed to execute due to an unknown error."
% command.__class__.__name__,
exc_info=True,
)
raise RegistryError() from err
message = "%s failed to execute due to an unknown error."
logger.warning(message, cmd_type, exc_info=True)
raise RegistryError(message) from err
else:
if response.code >= 2000:
raise RegistryError(response.msg)
raise RegistryError(response.msg, code=response.code)
else:
return response
def send(self, command):
def send(self, command, *, cleaned=False):
"""Login, send the command, then close the connection. Tries 3 times."""
# try to prevent use of this method without appropriate safeguards
if not cleaned:
raise ValueError("Please sanitize user input before sending it.")
counter = 0 # we'll try 3 times
while True:
try:
return self._send(command)
except RegistryError as err:
if counter == 3: # don't try again
raise err
else:
if err.should_retry() and counter < 3:
counter += 1
sleep((counter * 50) / 1000) # sleep 50 ms to 150 ms
else: # don't try again
raise err
try:

View file

@ -1,5 +1,77 @@
from enum import IntEnum
class ErrorCode(IntEnum):
"""
Overview of registry response codes from RFC 5730. See RFC 5730 for full text.
- 1000 - 1500 Success
- 2000 - 2308 Registrar did something silly
- 2400 - 2500 Registry did something silly
- 2501 - 2502 Something malicious or abusive may have occurred
"""
COMMAND_COMPLETED_SUCCESSFULLY = 1000
COMMAND_COMPLETED_SUCCESSFULLY_ACTION_PENDING = 1001
COMMAND_COMPLETED_SUCCESSFULLY_NO_MESSAGES = 1300
COMMAND_COMPLETED_SUCCESSFULLY_ACK_TO_DEQUEUE = 1301
COMMAND_COMPLETED_SUCCESSFULLY_ENDING_SESSION = 1500
UNKNOWN_COMMAND = 2000
COMMAND_SYNTAX_ERROR = 2001
COMMAND_USE_ERROR = 2002
REQUIRED_PARAMETER_MISSING = 2003
PARAMETER_VALUE_RANGE_ERROR = 2004
PARAMETER_VALUE_SYNTAX_ERROR = 2005
UNIMPLEMENTED_PROTOCOL_VERSION = 2100
UNIMPLEMENTED_COMMAND = 2101
UNIMPLEMENTED_OPTION = 2102
UNIMPLEMENTED_EXTENSION = 2103
BILLING_FAILURE = 2104
OBJECT_IS_NOT_ELIGIBLE_FOR_RENEWAL = 2105
OBJECT_IS_NOT_ELIGIBLE_FOR_TRANSFER = 2106
AUTHENTICATION_ERROR = 2200
AUTHORIZATION_ERROR = 2201
INVALID_AUTHORIZATION_INFORMATION = 2202
OBJECT_PENDING_TRANSFER = 2300
OBJECT_NOT_PENDING_TRANSFER = 2301
OBJECT_EXISTS = 2302
OBJECT_DOES_NOT_EXIST = 2303
OBJECT_STATUS_PROHIBITS_OPERATION = 2304
OBJECT_ASSOCIATION_PROHIBITS_OPERATION = 2305
PARAMETER_VALUE_POLICY_ERROR = 2306
UNIMPLEMENTED_OBJECT_SERVICE = 2307
DATA_MANAGEMENT_POLICY_VIOLATION = 2308
COMMAND_FAILED = 2400
COMMAND_FAILED_SERVER_CLOSING_CONNECTION = 2500
AUTHENTICATION_ERROR_SERVER_CLOSING_CONNECTION = 2501
SESSION_LIMIT_EXCEEDED_SERVER_CLOSING_CONNECTION = 2502
class RegistryError(Exception):
pass
"""
Overview of registry response codes from RFC 5730. See RFC 5730 for full text.
- 1000 - 1500 Success
- 2000 - 2308 Registrar did something silly
- 2400 - 2500 Registry did something silly
- 2501 - 2502 Something malicious or abusive may have occurred
"""
def __init__(self, *args, code=None, **kwargs):
super().__init__(*args, **kwargs)
self.code = code
def should_retry(self):
return self.code == ErrorCode.COMMAND_FAILED
def is_server_error(self):
return self.code is not None and (self.code >= 2400 and self.code <= 2500)
def is_client_error(self):
return self.code is not None and (self.code >= 2000 and self.code <= 2308)
class LoginError(RegistryError):

View file

@ -109,6 +109,8 @@ INSTALLED_APPS = [
"registrar",
# Our internal API application
"api",
# Only for generating documentation, uncomment to run manage.py generate_puml
# "puml_generator",
]
# Middleware are routines for processing web requests.

View file

@ -59,12 +59,12 @@ urlpatterns = [
),
path(
"application/<int:pk>/withdraw",
views.ApplicationWithdraw.as_view(),
views.ApplicationWithdrawConfirmation.as_view(),
name="application-withdraw-confirmation",
),
path(
"application/<int:pk>/withdrawconfirmed",
views.ApplicationWithdraw.updatestatus,
views.ApplicationWithdrawn.as_view(),
name="application-withdrawn",
),
path("health/", views.health),

View file

@ -5,7 +5,7 @@ from faker import Faker
from registrar.models import (
User,
DomainApplication,
Domain,
DraftDomain,
Contact,
Website,
)
@ -216,11 +216,13 @@ class DomainApplicationFixture:
if not da.requested_domain:
if "requested_domain" in app and app["requested_domain"] is not None:
da.requested_domain, _ = Domain.objects.get_or_create(
da.requested_domain, _ = DraftDomain.objects.get_or_create(
name=app["requested_domain"]
)
else:
da.requested_domain = Domain.objects.create(name=cls.fake_dot_gov())
da.requested_domain = DraftDomain.objects.create(
name=cls.fake_dot_gov()
)
@classmethod
def _set_many_to_many_relations(cls, da: DomainApplication, app: dict):

View file

@ -11,7 +11,7 @@ from django.utils.safestring import mark_safe
from api.views import DOMAIN_API_MESSAGES
from registrar.models import Contact, DomainApplication, Domain
from registrar.models import Contact, DomainApplication, DraftDomain, Domain
from registrar.utility import errors
logger = logging.getLogger(__name__)
@ -453,7 +453,7 @@ class AlternativeDomainForm(RegistrarForm):
"""Validation code for domain names."""
try:
requested = self.cleaned_data.get("alternative_domain", None)
validated = Domain.validate(requested, blank_ok=True)
validated = DraftDomain.validate(requested, blank_ok=True)
except errors.ExtraDotsError:
raise forms.ValidationError(
DOMAIN_API_MESSAGES["extra_dots"], code="extra_dots"
@ -498,7 +498,7 @@ class BaseAlternativeDomainFormSet(RegistrarFormSet):
@classmethod
def on_fetch(cls, query):
return [{"alternative_domain": domain.sld} for domain in query]
return [{"alternative_domain": Domain.sld(domain.name)} for domain in query]
@classmethod
def from_database(cls, obj):
@ -524,7 +524,7 @@ class DotGovDomainForm(RegistrarForm):
requested_domain.name = f"{domain}.gov"
requested_domain.save()
else:
requested_domain = Domain.objects.create(name=f"{domain}.gov")
requested_domain = DraftDomain.objects.create(name=f"{domain}.gov")
obj.requested_domain = requested_domain
obj.save()
@ -535,14 +535,14 @@ class DotGovDomainForm(RegistrarForm):
values = {}
requested_domain = getattr(obj, "requested_domain", None)
if requested_domain is not None:
values["requested_domain"] = requested_domain.sld
values["requested_domain"] = Domain.sld(requested_domain.name)
return values
def clean_requested_domain(self):
"""Validation code for domain names."""
try:
requested = self.cleaned_data.get("requested_domain", None)
validated = Domain.validate(requested)
validated = DraftDomain.validate(requested)
except errors.BlankValueError:
raise forms.ValidationError(
DOMAIN_API_MESSAGES["required"], code="required"

View file

@ -54,16 +54,6 @@ class Command(BaseCommand):
domains = []
for row in reader:
name = row["Name"].lower() # we typically use lowercase domains
# Ensure that there is a `Domain` object for each domain name in
# this file and that it is active. There is a uniqueness
# constraint for active Domain objects, so we are going to account
# for that here with this check so that our later bulk_create
# should succeed
if Domain.objects.filter(name=name, is_active=True).exists():
# don't do anything, this domain is here and active
continue
else:
domains.append(Domain(name=name, is_active=True))
domains.append(Domain(name=name))
logger.info("Creating %d new domains", len(domains))
Domain.objects.bulk_create(domains)

View file

@ -0,0 +1,122 @@
# Generated by Django 4.2.1 on 2023-05-25 15:03
from django.db import migrations, models
import django.db.models.deletion
import registrar.models.public_contact
class Migration(migrations.Migration):
dependencies = [
("registrar", "0020_remove_domaininformation_security_email"),
]
operations = [
migrations.AddField(
model_name="publiccontact",
name="domain",
field=models.ForeignKey(
default=1,
on_delete=django.db.models.deletion.PROTECT,
related_name="contacts",
to="registrar.domain",
),
preserve_default=False,
),
migrations.AddField(
model_name="publiccontact",
name="registry_id",
field=models.CharField(
default=registrar.models.public_contact.get_id,
help_text="Auto generated ID to track this contact in the registry",
max_length=16,
),
),
migrations.AlterField(
model_name="publiccontact",
name="cc",
field=models.TextField(help_text="Contact's country code"),
),
migrations.AlterField(
model_name="publiccontact",
name="city",
field=models.TextField(help_text="Contact's city"),
),
migrations.AlterField(
model_name="publiccontact",
name="contact_type",
field=models.CharField(
choices=[
("registrant", "Registrant"),
("administrative", "Administrative"),
("technical", "Technical"),
("security", "Security"),
],
help_text="For which type of WHOIS contact",
max_length=14,
),
),
migrations.AlterField(
model_name="publiccontact",
name="email",
field=models.TextField(help_text="Contact's email address"),
),
migrations.AlterField(
model_name="publiccontact",
name="fax",
field=models.TextField(
help_text="Contact's fax number (null ok). Must be in ITU.E164.2005 format.",
null=True,
),
),
migrations.AlterField(
model_name="publiccontact",
name="name",
field=models.TextField(help_text="Contact's full name"),
),
migrations.AlterField(
model_name="publiccontact",
name="org",
field=models.TextField(
help_text="Contact's organization (null ok)", null=True
),
),
migrations.AlterField(
model_name="publiccontact",
name="pc",
field=models.TextField(help_text="Contact's postal code"),
),
migrations.AlterField(
model_name="publiccontact",
name="pw",
field=models.TextField(
help_text="Contact's authorization code. 16 characters minimum."
),
),
migrations.AlterField(
model_name="publiccontact",
name="sp",
field=models.TextField(help_text="Contact's state or province"),
),
migrations.AlterField(
model_name="publiccontact",
name="street1",
field=models.TextField(help_text="Contact's street"),
),
migrations.AlterField(
model_name="publiccontact",
name="street2",
field=models.TextField(help_text="Contact's street (null ok)", null=True),
),
migrations.AlterField(
model_name="publiccontact",
name="street3",
field=models.TextField(help_text="Contact's street (null ok)", null=True),
),
migrations.AlterField(
model_name="publiccontact",
name="voice",
field=models.TextField(
help_text="Contact's phone number. Must be in ITU.E164.2005 format"
),
),
]

View file

@ -0,0 +1,66 @@
# Generated by Django 4.2.1 on 2023-05-26 13:14
from django.db import migrations, models
import django.db.models.deletion
import registrar.models.utility.domain_helper
class Migration(migrations.Migration):
dependencies = [
("registrar", "0021_publiccontact_domain_publiccontact_registry_id_and_more"),
]
operations = [
migrations.CreateModel(
name="DraftDomain",
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)),
(
"name",
models.CharField(
default=None,
help_text="Fully qualified domain name",
max_length=253,
),
),
],
options={
"abstract": False,
},
bases=(models.Model, registrar.models.utility.domain_helper.DomainHelper), # type: ignore
),
migrations.AddField(
model_name="domainapplication",
name="approved_domain",
field=models.OneToOneField(
blank=True,
help_text="The approved domain",
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="domain_application",
to="registrar.domain",
),
),
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_application",
to="registrar.draftdomain",
),
),
]

View file

@ -4,6 +4,7 @@ from .contact import Contact
from .domain_application import DomainApplication
from .domain_information import DomainInformation
from .domain import Domain
from .draft_domain import DraftDomain
from .host_ip import HostIP
from .host import Host
from .domain_invitation import DomainInvitation
@ -18,6 +19,7 @@ __all__ = [
"DomainApplication",
"DomainInformation",
"Domain",
"DraftDomain",
"DomainInvitation",
"HostIP",
"Host",
@ -31,6 +33,7 @@ __all__ = [
auditlog.register(Contact)
auditlog.register(DomainApplication)
auditlog.register(Domain)
auditlog.register(DraftDomain)
auditlog.register(DomainInvitation)
auditlog.register(HostIP)
auditlog.register(Host)

View file

@ -400,10 +400,19 @@ class DomainApplication(TimeStampedModel):
related_name="current+",
)
requested_domain = models.OneToOneField(
approved_domain = models.OneToOneField(
"Domain",
null=True,
blank=True,
help_text="The approved domain",
related_name="domain_application",
on_delete=models.PROTECT,
)
requested_domain = models.OneToOneField(
"DraftDomain",
null=True,
blank=True,
help_text="The requested domain",
related_name="domain_application",
on_delete=models.PROTECT,
@ -499,8 +508,8 @@ class DomainApplication(TimeStampedModel):
if self.requested_domain is None:
raise ValueError("Requested domain is missing.")
Domain = apps.get_model("registrar.Domain")
if not Domain.string_could_be_domain(self.requested_domain.name):
DraftDomain = apps.get_model("registrar.DraftDomain")
if not DraftDomain.string_could_be_domain(self.requested_domain.name):
raise ValueError("Requested domain is not a valid domain name.")
# When an application is submitted, we need to send a confirmation email
@ -516,13 +525,16 @@ class DomainApplication(TimeStampedModel):
application into an admin on that domain.
"""
# create the domain if it doesn't exist
# create the domain
Domain = apps.get_model("registrar.Domain")
created_domain, _ = Domain.objects.get_or_create(name=self.requested_domain)
if Domain.objects.filter(name=self.requested_domain.name).exists():
raise ValueError("Cannot approve. Requested domain is already in use.")
created_domain = Domain.objects.create(name=self.requested_domain.name)
self.approved_domain = created_domain
# copy the information from domainapplication into domaininformation
DomainInformation = apps.get_model("registrar.DomainInformation")
DomainInformation.create_from_da(self)
DomainInformation.create_from_da(self, domain=created_domain)
# create the permission for the user
UserDomainRole = apps.get_model("registrar.UserDomainRole")

View file

@ -211,24 +211,24 @@ class DomainInformation(TimeStampedModel):
return ""
@classmethod
def create_from_da(cls, domain_application):
def create_from_da(cls, domain_application, domain=None):
"""Takes in a DomainApplication dict and converts it into DomainInformation"""
da_dict = domain_application.to_dict()
# remove the id so one can be assinged on creation
da_id = da_dict.pop("id")
da_id = da_dict.pop("id", None)
# check if we have a record that corresponds with the domain
# application, if so short circuit the create
domain_info = cls.objects.filter(domain_application__id=da_id).first()
if domain_info:
return domain_info
# the following information below is not needed in the domain information:
da_dict.pop("status")
da_dict.pop("current_websites")
da_dict.pop("investigator")
da_dict.pop("alternative_domains")
# use the requested_domain to create information for this domain
da_dict["domain"] = da_dict.pop("requested_domain")
other_contacts = da_dict.pop("other_contacts")
da_dict.pop("status", None)
da_dict.pop("current_websites", None)
da_dict.pop("investigator", None)
da_dict.pop("alternative_domains", None)
da_dict.pop("requested_domain", None)
da_dict.pop("approved_domain", None)
other_contacts = da_dict.pop("other_contacts", [])
domain_info = cls(**da_dict)
domain_info.domain_application = domain_application
# Save so the object now have PK
@ -237,6 +237,8 @@ class DomainInformation(TimeStampedModel):
# Process the remaining "many to many" stuff
domain_info.other_contacts.add(*other_contacts)
if domain:
domain_info.domain = domain
domain_info.save()
return domain_info

View file

@ -0,0 +1,22 @@
import logging
from django.db import models
from .utility.domain_helper import DomainHelper
from .utility.time_stamped_model import TimeStampedModel
logger = logging.getLogger(__name__)
class DraftDomain(TimeStampedModel, DomainHelper):
"""Store domain names which registrants have requested."""
def __str__(self) -> str:
return self.name
name = models.CharField(
max_length=253,
blank=False,
default=None, # prevent saving without a value
help_text="Fully qualified domain name",
)

View file

@ -1,8 +1,21 @@
from datetime import datetime
from random import choices
from string import ascii_uppercase, ascii_lowercase, digits
from django.db import models
from .utility.time_stamped_model import TimeStampedModel
def get_id():
"""Generate a 16 character registry ID with a low probability of collision."""
day = datetime.today().strftime("%A")[:2]
rand = "".join(
choices(ascii_uppercase + ascii_lowercase + digits, k=14) # nosec B311
)
return f"{day}{rand}"
class PublicContact(TimeStampedModel):
"""Contact information intended to be published in WHOIS."""
@ -14,37 +27,126 @@ class PublicContact(TimeStampedModel):
TECHNICAL = "technical", "Technical"
SECURITY = "security", "Security"
contact_type = models.CharField(max_length=14, choices=ContactTypeChoices.choices)
def save(self, *args, **kwargs):
"""Save to the registry and also locally in the registrar database."""
if hasattr(self, "domain"):
match self.contact_type:
case PublicContact.ContactTypeChoices.REGISTRANT:
self.domain.registrant = self
case PublicContact.ContactTypeChoices.ADMINISTRATIVE:
self.domain.administrative_contact = self
case PublicContact.ContactTypeChoices.TECHNICAL:
self.domain.technical_contact = self
case PublicContact.ContactTypeChoices.SECURITY:
self.domain.security_contact = self
super().save(*args, **kwargs)
# contact's full name
name = models.TextField(null=False)
# contact's organization (null ok)
org = models.TextField(null=True)
# contact's street
street1 = models.TextField(null=False)
# contact's street (null ok)
street2 = models.TextField(null=True)
# contact's street (null ok)
street3 = models.TextField(null=True)
# contact's city
city = models.TextField(null=False)
# contact's state or province
sp = models.TextField(null=False)
# contact's postal code
pc = models.TextField(null=False)
# contact's country code
cc = models.TextField(null=False)
# contact's email address
email = models.TextField(null=False)
# contact's phone number
# Must be in ITU.E164.2005 format
voice = models.TextField(null=False)
# contact's fax number (null ok)
# Must be in ITU.E164.2005 format
fax = models.TextField(null=True)
# contact's authorization code
# 16 characters minium
pw = models.TextField(null=False)
contact_type = models.CharField(
max_length=14,
choices=ContactTypeChoices.choices,
help_text="For which type of WHOIS contact",
)
registry_id = models.CharField(
max_length=16,
default=get_id,
null=False,
help_text="Auto generated ID to track this contact in the registry",
)
domain = models.ForeignKey(
"registrar.Domain",
on_delete=models.PROTECT,
related_name="contacts",
)
name = models.TextField(null=False, help_text="Contact's full name")
org = models.TextField(null=True, help_text="Contact's organization (null ok)")
street1 = models.TextField(null=False, help_text="Contact's street")
street2 = models.TextField(null=True, help_text="Contact's street (null ok)")
street3 = models.TextField(null=True, help_text="Contact's street (null ok)")
city = models.TextField(null=False, help_text="Contact's city")
sp = models.TextField(null=False, help_text="Contact's state or province")
pc = models.TextField(null=False, help_text="Contact's postal code")
cc = models.TextField(null=False, help_text="Contact's country code")
email = models.TextField(null=False, help_text="Contact's email address")
voice = models.TextField(
null=False, help_text="Contact's phone number. Must be in ITU.E164.2005 format"
)
fax = models.TextField(
null=True,
help_text="Contact's fax number (null ok). Must be in ITU.E164.2005 format.",
)
pw = models.TextField(
null=False, help_text="Contact's authorization code. 16 characters minimum."
)
@classmethod
def get_default_registrant(cls):
return cls(
contact_type=PublicContact.ContactTypeChoices.REGISTRANT,
registry_id=get_id(),
name="CSD/CB Attn: Cameron Dixon",
org="Cybersecurity and Infrastructure Security Agency",
street1="CISA NGR STOP 0645",
street2="1110 N. Glebe Rd.",
city="Arlington",
sp="VA",
pc="20598-0645",
cc="US",
email="dotgov@cisa.dhs.gov",
voice="+1.8882820870",
pw="thisisnotapassword",
)
@classmethod
def get_default_administrative(cls):
return cls(
contact_type=PublicContact.ContactTypeChoices.ADMINISTRATIVE,
registry_id=get_id(),
name="Program Manager",
org="Cybersecurity and Infrastructure Security Agency",
street1="4200 Wilson Blvd.",
city="Arlington",
sp="VA",
pc="22201",
cc="US",
email="dotgov@cisa.dhs.gov",
voice="+1.8882820870",
pw="thisisnotapassword",
)
@classmethod
def get_default_technical(cls):
return cls(
contact_type=PublicContact.ContactTypeChoices.TECHNICAL,
registry_id=get_id(),
name="Registry Customer Service",
org="Cybersecurity and Infrastructure Security Agency",
street1="4200 Wilson Blvd.",
city="Arlington",
sp="VA",
pc="22201",
cc="US",
email="dotgov@cisa.dhs.gov",
voice="+1.8882820870",
pw="thisisnotapassword",
)
@classmethod
def get_default_security(cls):
return cls(
contact_type=PublicContact.ContactTypeChoices.SECURITY,
registry_id=get_id(),
name="Registry Customer Service",
org="Cybersecurity and Infrastructure Security Agency",
street1="4200 Wilson Blvd.",
city="Arlington",
sp="VA",
pc="22201",
cc="US",
email="dotgov@cisa.dhs.gov",
voice="+1.8882820870",
pw="thisisnotapassword",
)
def __str__(self):
return f"{self.name} <{self.email}>"

View file

@ -0,0 +1,64 @@
import re
from api.views import in_domains
from registrar.utility import errors
class DomainHelper:
"""Utility functions and constants for domain names."""
# a domain name is alphanumeric or hyphen, up to 63 characters, doesn't
# begin or end with a hyphen, followed by a TLD of 2-6 alphabetic characters
DOMAIN_REGEX = re.compile(r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)\.[A-Za-z]{2,6}$")
# a domain name is alphanumeric or hyphen, has at least 2 dots, doesn't
# begin or end with a hyphen, followed by a TLD of 2-6 alphabetic characters
HOST_REGEX = re.compile(r"^((?!-)[A-Za-z0-9-]{1,63}(?<!-)\.){2,}([A-Za-z]){2,6}$")
# a domain can be no longer than 253 characters in total
MAX_LENGTH = 253
@classmethod
def string_could_be_domain(cls, domain: str | None) -> bool:
"""Return True if the string could be a domain name, otherwise False."""
if not isinstance(domain, str):
return False
return bool(cls.DOMAIN_REGEX.match(domain))
@classmethod
def validate(cls, domain: str | None, blank_ok=False) -> str:
"""Attempt to determine if a domain name could be requested."""
if domain is None:
raise errors.BlankValueError()
if not isinstance(domain, str):
raise ValueError("Domain name must be a string")
domain = domain.lower().strip()
if domain == "":
if blank_ok:
return domain
else:
raise errors.BlankValueError()
if domain.endswith(".gov"):
domain = domain[:-4]
if "." in domain:
raise errors.ExtraDotsError()
if not DomainHelper.string_could_be_domain(domain + ".gov"):
raise ValueError()
if in_domains(domain):
raise errors.DomainUnavailableError()
return domain
@classmethod
def sld(cls, domain: str):
"""
Get the second level domain. Example: `gsa.gov` -> `gsa`.
If no TLD is present, returns the original string.
"""
return domain.split(".")[0]
@classmethod
def tld(cls, domain: str):
"""Get the top level domain. Example: `gsa.gov` -> `gov`."""
parts = domain.rsplit(".")
return parts[-1] if len(parts) > 1 else ""

View file

@ -1,5 +1,3 @@
from django.apps import apps
from django.core.exceptions import ValidationError
from django.db import models
from .utility.time_stamped_model import TimeStampedModel
@ -18,35 +16,5 @@ class Website(TimeStampedModel):
help_text="",
)
@property
def sld(self):
"""Get or set the second level domain string."""
return self.website.split(".")[0]
@sld.setter
def sld(self, value: str):
Domain = apps.get_model("registrar.Domain")
parts = self.website.split(".")
tld = parts[1] if len(parts) > 1 else ""
if Domain.string_could_be_domain(f"{value}.{tld}"):
self.website = f"{value}.{tld}"
else:
raise ValidationError("%s is not a valid second level domain" % value)
@property
def tld(self):
"""Get or set the top level domain string."""
parts = self.website.split(".")
return parts[1] if len(parts) > 1 else ""
@tld.setter
def tld(self, value: str):
Domain = apps.get_model("registrar.Domain")
sld = self.website.split(".")[0]
if Domain.string_could_be_domain(f"{sld}.{value}"):
self.website = f"{sld}.{value}"
else:
raise ValidationError("%s is not a valid top level domain" % value)
def __str__(self) -> str:
return str(self.website)

View file

@ -31,7 +31,7 @@
<p> <b class="review__step__name">Last updated:</b> {{domainapplication.updated_at|date:"F j, Y"}}<br>
<b class="review__step__name">Request #:</b> {{domainapplication.id}}</p>
<p>{% include "includes/domain_application.html" %}</p>
<p><a href="{% url 'application-withdraw-confirmation' domainapplication.id %}" class="usa-button usa-button--outline withdraw_outline">
<p><a href="{% url 'application-withdraw-confirmation' pk=domainapplication.id %}" class="usa-button usa-button--outline withdraw_outline">
Withdraw request</a>
</p>
</div>

View file

@ -17,7 +17,7 @@
{% for item in value %}
<li>
<p class="text-semibold margin-top-1 margin-bottom-0">
Conatct {{forloop.counter}}
Contact {{forloop.counter}}
</p>
{% include "includes/contact.html" with contact=item %}</li>
{% empty %}

View file

@ -5,7 +5,7 @@ from unittest.mock import MagicMock
from django.contrib.auth import get_user_model
from django.test import TestCase
from registrar.models import Contact, Domain, Website, DomainApplication
from registrar.models import Contact, DraftDomain, Website, DomainApplication
import boto3_mocking # type: ignore
@ -28,7 +28,7 @@ class TestEmails(TestCase):
email="testy@town.com",
phone="(555) 555 5555",
)
domain, _ = Domain.objects.get_or_create(name="city.gov")
domain, _ = DraftDomain.objects.get_or_create(name="city.gov")
alt, _ = Website.objects.get_or_create(website="city1.gov")
current, _ = Website.objects.get_or_create(website="city.com")
you, _ = Contact.objects.get_or_create(

View file

@ -8,6 +8,7 @@ from registrar.models import (
User,
Website,
Domain,
DraftDomain,
DomainInvitation,
UserDomainRole,
)
@ -40,7 +41,7 @@ class TestDomainApplication(TestCase):
contact = Contact.objects.create()
com_website, _ = Website.objects.get_or_create(website="igorville.com")
gov_website, _ = Website.objects.get_or_create(website="igorville.gov")
domain, _ = Domain.objects.get_or_create(name="igorville.gov")
domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov")
application = DomainApplication.objects.create(
creator=user,
investigator=user,
@ -100,7 +101,7 @@ class TestDomainApplication(TestCase):
def test_status_fsm_submit_succeed(self):
user, _ = User.objects.get_or_create()
site = Domain.objects.create(name="igorville.gov")
site = DraftDomain.objects.create(name="igorville.gov")
application = DomainApplication.objects.create(
creator=user, requested_domain=site
)
@ -113,7 +114,7 @@ class TestDomainApplication(TestCase):
"""Create an application and submit it and see if email was sent."""
user, _ = User.objects.get_or_create()
contact = Contact.objects.create(email="test@test.gov")
domain, _ = Domain.objects.get_or_create(name="igorville.gov")
domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov")
application = DomainApplication.objects.create(
creator=user,
requested_domain=domain,
@ -135,62 +136,22 @@ class TestDomainApplication(TestCase):
)
class TestDomain(TestCase):
def test_empty_create_fails(self):
"""Can't create a completely empty domain."""
with self.assertRaisesRegex(IntegrityError, "name"):
Domain.objects.create()
def test_minimal_create(self):
"""Can create with just a name."""
domain = Domain.objects.create(name="igorville.gov")
self.assertEqual(domain.is_active, False)
@skip("cannot activate a domain without mock registry")
def test_get_status(self):
"""Returns proper status based on `is_active`."""
domain = Domain.objects.create(name="igorville.gov")
domain.save()
self.assertEqual(None, domain.status)
domain.activate()
domain.save()
self.assertIn("ok", domain.status)
def test_fsm_activate_fail_unique(self):
"""Can't activate domain if name is not unique."""
d1, _ = Domain.objects.get_or_create(name="igorville.gov")
d2, _ = Domain.objects.get_or_create(name="igorville.gov")
d1.activate()
d1.save()
with self.assertRaises(ValueError):
d2.activate()
def test_fsm_activate_fail_unapproved(self):
"""Can't activate domain if application isn't approved."""
d1, _ = Domain.objects.get_or_create(name="igorville.gov")
user, _ = User.objects.get_or_create()
application = DomainApplication.objects.create(creator=user)
d1.domain_application = application
d1.save()
with self.assertRaises(ValueError):
d1.activate()
class TestPermissions(TestCase):
"""Test the User-Domain-Role connection."""
def test_approval_creates_role(self):
domain, _ = Domain.objects.get_or_create(name="igorville.gov")
draft_domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov")
user, _ = User.objects.get_or_create()
application = DomainApplication.objects.create(
creator=user, requested_domain=domain
creator=user, requested_domain=draft_domain
)
# skip using the submit method
application.status = DomainApplication.SUBMITTED
application.approve()
# should be a role for this user
domain = Domain.objects.get(name="igorville.gov")
self.assertTrue(UserDomainRole.objects.get(user=user, domain=domain))
@ -199,16 +160,17 @@ class TestDomainInfo(TestCase):
"""Test creation of Domain Information when approved."""
def test_approval_creates_info(self):
domain, _ = Domain.objects.get_or_create(name="igorville.gov")
draft_domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov")
user, _ = User.objects.get_or_create()
application = DomainApplication.objects.create(
creator=user, requested_domain=domain
creator=user, requested_domain=draft_domain
)
# skip using the submit method
application.status = DomainApplication.SUBMITTED
application.approve()
# should be an information present for this domain
domain = Domain.objects.get(name="igorville.gov")
self.assertTrue(DomainInformation.objects.get(domain=domain))

View file

@ -0,0 +1,54 @@
from django.test import TestCase
from django.db.utils import IntegrityError
from registrar.models import (
DomainApplication,
User,
Domain,
)
from unittest import skip
class TestDomain(TestCase):
def test_empty_create_fails(self):
"""Can't create a completely empty domain."""
with self.assertRaisesRegex(IntegrityError, "name"):
Domain.objects.create()
def test_minimal_create(self):
"""Can create with just a name."""
Domain.objects.create(name="igorville.gov")
# this assertion will not work -- for now, the fact that the
# above command didn't error out is proof enough
# self.assertEquals(domain.state, Domain.State.DRAFTED)
@skip("cannot activate a domain without mock registry")
def test_get_status(self):
"""Returns proper status based on `state`."""
domain = Domain.objects.create(name="igorville.gov")
domain.save()
self.assertEqual(None, domain.status)
domain.activate()
domain.save()
self.assertIn("ok", domain.status)
@skip("cannot activate a domain without mock registry")
def test_fsm_activate_fail_unique(self):
"""Can't activate domain if name is not unique."""
d1, _ = Domain.objects.get_or_create(name="igorville.gov")
d2, _ = Domain.objects.get_or_create(name="igorville.gov")
d1.activate()
d1.save()
with self.assertRaises(ValueError):
d2.activate()
@skip("cannot activate a domain without mock registry")
def test_fsm_activate_fail_unapproved(self):
"""Can't activate domain if application isn't approved."""
d1, _ = Domain.objects.get_or_create(name="igorville.gov")
user, _ = User.objects.get_or_create()
application = DomainApplication.objects.create(creator=user)
d1.domain_application = application
d1.save()
with self.assertRaises(ValueError):
d1.activate()

View file

@ -14,6 +14,7 @@ from registrar.models import (
DomainApplication,
Domain,
DomainInformation,
DraftDomain,
DomainInvitation,
Contact,
Website,
@ -76,7 +77,7 @@ class LoggedInTests(TestWithUser):
def test_home_lists_domain_applications(self):
response = self.client.get("/")
self.assertNotContains(response, "igorville.gov")
site = Domain.objects.create(name="igorville.gov")
site = DraftDomain.objects.create(name="igorville.gov")
application = DomainApplication.objects.create(
creator=self.user, requested_domain=site
)
@ -1040,6 +1041,8 @@ class TestWithDomainPermissions(TestWithUser):
def tearDown(self):
try:
self.domain_information.delete()
if hasattr(self.domain, "contacts"):
self.domain.contacts.all().delete()
self.domain.delete()
self.role.delete()
except ValueError: # pass if already deleted
@ -1125,7 +1128,7 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest):
self.assertContains(response, "Add another user")
def test_domain_user_add_form(self):
"""Adding a user works."""
"""Adding an existing user works."""
other_user, _ = get_user_model().objects.get_or_create(
email="mayor@igorville.gov"
)
@ -1208,6 +1211,22 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest):
with self.assertRaises(DomainInvitation.DoesNotExist):
DomainInvitation.objects.get(id=invitation.id)
def test_domain_invitation_cancel_no_permissions(self):
"""Posting to the delete view as a different user should fail."""
EMAIL = "mayor@igorville.gov"
invitation, _ = DomainInvitation.objects.get_or_create(
domain=self.domain, email=EMAIL
)
other_user = User()
other_user.save()
self.client.force_login(other_user)
with less_console_noise(): # permission denied makes console errors
result = self.client.post(
reverse("invitation-delete", kwargs={"pk": invitation.id})
)
self.assertEqual(result.status_code, 403)
@boto3_mocking.patching
def test_domain_invitation_flow(self):
"""Send an invitation to a new user, log in and load the dashboard."""
@ -1336,6 +1355,7 @@ class TestApplicationStatus(TestWithUser, WebTest):
def setUp(self):
super().setUp()
self.app.set_user(self.user.username)
self.client.force_login(self.user)
def _completed_application(
self,
@ -1353,7 +1373,7 @@ class TestApplicationStatus(TestWithUser, WebTest):
email="testy@town.com",
phone="(555) 555 5555",
)
domain, _ = Domain.objects.get_or_create(name="citystatus.gov")
domain, _ = DraftDomain.objects.get_or_create(name="citystatus.gov")
alt, _ = Website.objects.get_or_create(website="city1.gov")
current, _ = Website.objects.get_or_create(website="city.com")
you, _ = Contact.objects.get_or_create(
@ -1449,3 +1469,24 @@ class TestApplicationStatus(TestWithUser, WebTest):
)
home_page = self.app.get("/")
self.assertContains(home_page, "Withdrawn")
def test_application_status_no_permissions(self):
"""Can't access applications without being the creator."""
application = self._completed_application()
other_user = User()
other_user.save()
application.creator = other_user
application.save()
# PermissionDeniedErrors make lots of noise in test output
with less_console_noise():
for url_name in [
"application-status",
"application-withdraw-confirmation",
"application-withdrawn",
]:
with self.subTest(url_name=url_name):
page = self.client.get(
reverse(url_name, kwargs={"pk": application.pk})
)
self.assertEqual(page.status_code, 403)

View file

@ -6,7 +6,6 @@ from django.shortcuts import redirect, render
from django.urls import resolve, reverse
from django.utils.translation import gettext_lazy as _
from django.views.generic import TemplateView
from django.views import generic
from django.contrib import messages
from registrar.forms import application_wizard as forms
@ -14,7 +13,7 @@ from registrar.models import DomainApplication
from registrar.utility import StrEnum
from registrar.views.utility import StepsHelper
from .utility import DomainPermission
from .utility import DomainApplicationPermissionView
logger = logging.getLogger(__name__)
@ -478,29 +477,31 @@ class Finished(ApplicationWizard):
return render(self.request, self.template_name, context)
class ApplicationStatus(generic.DetailView):
model = DomainApplication
class ApplicationStatus(DomainApplicationPermissionView):
template_name = "application_status.html"
def get_context_data(self, **kwargs):
"""Get context details to process information from application"""
context = super(ApplicationStatus, self).get_context_data(**kwargs)
return context
class ApplicationWithdrawConfirmation(DomainApplicationPermissionView):
"""This page will ask user to confirm if they want to withdraw
class ApplicationWithdraw(LoginRequiredMixin, generic.DetailView, DomainPermission):
model = DomainApplication
template_name = "application_withdraw_confirmation.html"
""" The page above will display asking user to confirm if they want to withdraw;
Note it uses "DomainPermission" from Domain to ensure that the person who
applied only have access to withdraw the request
The DomainApplicationPermissionView restricts access so that only the
`creator` of the application may withdraw it.
"""
def updatestatus(request, pk):
"""If user click on withdraw confirm button, it will be updated to withdraw
and send back to homepage"""
application = DomainApplication.objects.get(id=pk)
template_name = "application_withdraw_confirmation.html"
class ApplicationWithdrawn(DomainApplicationPermissionView):
# this view renders no template
template_name = ""
def get(self, *args, **kwargs):
"""View class that does the actual withdrawing.
If user click on withdraw confirm button, this view updates the status
to withdraw and send back to homepage.
"""
application = DomainApplication.objects.get(id=self.kwargs["pk"])
application.status = "withdrawn"
application.save()
return HttpResponseRedirect(reverse("home"))

View file

@ -1,4 +1,9 @@
"""View for a single Domain."""
"""Views for a single Domain.
Authorization is handled by the `DomainPermissionView`. To ensure that only
authorized users can see information on a domain, every view here should
inherit from `DomainPermissionView` (or DomainInvitationPermissionDeleteView).
"""
import logging
@ -7,8 +12,7 @@ from django.contrib.messages.views import SuccessMessageMixin
from django.db import IntegrityError
from django.shortcuts import redirect
from django.urls import reverse
from django.views.generic import DetailView
from django.views.generic.edit import DeleteView, FormMixin
from django.views.generic.edit import FormMixin
from registrar.models import (
Domain,
@ -24,28 +28,24 @@ from ..forms import (
ContactForm,
)
from ..utility.email import send_templated_email, EmailSendingError
from .utility import DomainPermission
from .utility import DomainPermissionView, DomainInvitationPermissionDeleteView
logger = logging.getLogger(__name__)
class DomainView(DomainPermission, DetailView):
class DomainView(DomainPermissionView):
"""Domain detail overview page."""
model = Domain
template_name = "domain_detail.html"
context_object_name = "domain"
class DomainNameserversView(DomainPermission, FormMixin, DetailView):
class DomainNameserversView(DomainPermissionView, FormMixin):
"""Domain nameserver editing view."""
model = Domain
template_name = "domain_nameservers.html"
context_object_name = "domain"
form_class = NameserverFormset
def get_initial(self):
@ -106,13 +106,11 @@ class DomainNameserversView(DomainPermission, FormMixin, DetailView):
return super().form_valid(formset)
class DomainYourContactInformationView(DomainPermission, FormMixin, DetailView):
class DomainYourContactInformationView(DomainPermissionView, FormMixin):
"""Domain your contact information editing view."""
model = Domain
template_name = "domain_your_contact_information.html"
context_object_name = "domain"
form_class = ContactForm
def get_form_kwargs(self, *args, **kwargs):
@ -148,13 +146,11 @@ class DomainYourContactInformationView(DomainPermission, FormMixin, DetailView):
return super().form_valid(form)
class DomainSecurityEmailView(DomainPermission, FormMixin, DetailView):
class DomainSecurityEmailView(DomainPermissionView, FormMixin):
"""Domain security email editing view."""
model = Domain
template_name = "domain_security_email.html"
context_object_name = "domain"
form_class = DomainSecurityEmailForm
def get_initial(self):
@ -193,16 +189,14 @@ class DomainSecurityEmailView(DomainPermission, FormMixin, DetailView):
return redirect(self.get_success_url())
class DomainUsersView(DomainPermission, DetailView):
class DomainUsersView(DomainPermissionView):
"""User management page in the domain details."""
model = Domain
template_name = "domain_users.html"
context_object_name = "domain"
class DomainAddUserView(DomainPermission, FormMixin, DetailView):
class DomainAddUserView(DomainPermissionView, FormMixin):
"""Inside of a domain's user management, a form for adding users.
@ -211,7 +205,6 @@ class DomainAddUserView(DomainPermission, FormMixin, DetailView):
"""
template_name = "domain_add_user.html"
model = Domain
form_class = DomainAddUserForm
def get_success_url(self):
@ -291,8 +284,9 @@ class DomainAddUserView(DomainPermission, FormMixin, DetailView):
return redirect(self.get_success_url())
class DomainInvitationDeleteView(SuccessMessageMixin, DeleteView):
model = DomainInvitation
class DomainInvitationDeleteView(
DomainInvitationPermissionDeleteView, SuccessMessageMixin
):
object: DomainInvitation # workaround for type mismatch in DeleteView
def get_success_url(self):

View file

@ -1,3 +1,8 @@
from .steps_helper import StepsHelper
from .always_404 import always_404
from .mixins import DomainPermission
from .permission_views import (
DomainPermissionView,
DomainApplicationPermissionView,
DomainInvitationPermissionDeleteView,
)

View file

@ -2,7 +2,7 @@
from django.contrib.auth.mixins import PermissionRequiredMixin
from registrar.models import UserDomainRole
from registrar.models import UserDomainRole, DomainApplication, DomainInvitation
class PermissionsLoginMixin(PermissionRequiredMixin):
@ -35,3 +35,48 @@ class DomainPermission(PermissionsLoginMixin):
# if we need to check more about the nature of role, do it here.
return True
class DomainApplicationPermission(PermissionsLoginMixin):
"""Does the logged-in user have access to this domain application?"""
def has_permission(self):
"""Check if this user has access to this domain application.
The user is in self.request.user and the domain needs to be looked
up from the domain's primary key in self.kwargs["pk"]
"""
if not self.request.user.is_authenticated:
return False
# user needs to be the creator of the application
# this query is empty if there isn't a domain application with this
# id and this user as creator
if not DomainApplication.objects.filter(
creator=self.request.user, id=self.kwargs["pk"]
).exists():
return False
return True
class DomainInvitationPermission(PermissionsLoginMixin):
"""Does the logged-in user have access to this domain invitation?
A user has access to a domain invitation if they have a role on the
associated domain.
"""
def has_permission(self):
"""Check if this user has a role on the domain of this invitation."""
if not self.request.user.is_authenticated:
return False
if not DomainInvitation.objects.filter(
id=self.kwargs["pk"], domain__permissions__user=self.request.user
).exists():
return False
return True

View file

@ -0,0 +1,69 @@
"""View classes that enforce authorization."""
import abc # abstract base class
from django.views.generic import DetailView, DeleteView
from registrar.models import Domain, DomainApplication, DomainInvitation
from .mixins import (
DomainPermission,
DomainApplicationPermission,
DomainInvitationPermission,
)
class DomainPermissionView(DomainPermission, DetailView, abc.ABC):
"""Abstract base view for domains that enforces permissions.
This abstract view cannot be instantiated. Actual views must specify
`template_name`.
"""
# DetailView property for what model this is viewing
model = Domain
# variable name in template context for the model object
context_object_name = "domain"
# Abstract property enforces NotImplementedError on an attribute.
@property
@abc.abstractmethod
def template_name(self):
raise NotImplementedError
class DomainApplicationPermissionView(DomainApplicationPermission, DetailView, abc.ABC):
"""Abstract base view for domain applications that enforces permissions
This abstract view cannot be instantiated. Actual views must specify
`template_name`.
"""
# DetailView property for what model this is viewing
model = DomainApplication
# variable name in template context for the model object
context_object_name = "domainapplication"
# Abstract property enforces NotImplementedError on an attribute.
@property
@abc.abstractmethod
def template_name(self):
raise NotImplementedError
class DomainInvitationPermissionDeleteView(
DomainInvitationPermission, DeleteView, abc.ABC
):
"""Abstract view for deleting a domain invitation.
This one is fairly specialized, but this is the only thing that we do
right now with domain invitations. We still have the full
`DomainInvitationPermission` class, but here we just pair it with a
DeleteView.
"""
model = DomainInvitation
object: DomainInvitation # workaround for type mismatch in DeleteView