Merge branch 'main' into sspj/domain-interface

This commit is contained in:
Seamus Johnston 2023-06-01 10:13:24 -05:00
commit 5b78b0ec67
No known key found for this signature in database
GPG key ID: 2F21225985069105
31 changed files with 1167 additions and 215 deletions

View file

@ -4,6 +4,12 @@ title: "[Bug]: "
labels: ["bug"] labels: ["bug"]
body: 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 - type: textarea
id: current-behavior id: current-behavior
attributes: attributes:
@ -24,25 +30,30 @@ body:
id: steps-to-reproduce id: steps-to-reproduce
attributes: attributes:
label: Steps to Reproduce label: Steps to Reproduce
description: "How can the issue be reliably reproduced? feel free to include screenshots or other supporting artifacts" description: |
placeholder: | How can the issue be reliably reproduced? Feel free to include screenshots or other supporting artifacts
Example: Example:
1. In the test environment, fill out the application for a new domain 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 2. Click the button to trigger a save/submit on the final page and complete the application
3. See the error 3. See the error
value: |
1.
validations: validations:
required: true required: true
- type: textarea - type: textarea
id: environment id: environment
attributes: attributes:
label: Environment label: Environment (optional)
description: "Where is this issue occurring? If related to development environment, list the specific relevant tool versions" description: |
placeholder: | Where is this issue occurring? If related to development environment, list the relevant tool versions.
Example: Example:
- Environment: Sandbox
- Browser: Chrome x.y.z - Browser: Chrome x.y.z
- Python: x.y.z - Python: x.y.z
- type: textarea - type: textarea
id: additional-context id: additional-context
attributes: attributes:
label: Additional Context label: Additional Context (optional)
description: "Please include additional references, screenshots, documentation, etc. that are relevant" description: "Please include additional references, screenshots, documentation, etc. that are relevant"

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

@ -34,8 +34,8 @@ jobs:
- name: Delete existing data for ${{ github.event.inputs.environment }} - name: Delete existing data for ${{ github.event.inputs.environment }}
uses: 18f/cg-deploy-action@main uses: 18f/cg-deploy-action@main
with: with:
cf_username: ${{ secrets[CF_USERNAME] }} cf_username: ${{ secrets[env.CF_USERNAME] }}
cf_password: ${{ secrets[CF_PASSWORD] }} cf_password: ${{ secrets[env.CF_PASSWORD] }}
cf_org: cisa-getgov-prototyping cf_org: cisa-getgov-prototyping
cf_space: ${{ github.event.inputs.environment }} cf_space: ${{ github.event.inputs.environment }}
full_command: "cf run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py flush --no-input' --name flush" full_command: "cf run-task getgov-${{ github.event.inputs.environment }} --command 'python manage.py flush --no-input' --name flush"

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

@ -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 = "*" types-cachetools = "*"
boto3-mocking = "*" boto3-mocking = "*"
boto3-stubs = "*" boto3-stubs = "*"
django-model2puml = "*"

171
src/Pipfile.lock generated
View file

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

View file

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

View file

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

View file

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

View file

@ -59,12 +59,12 @@ urlpatterns = [
), ),
path( path(
"application/<int:pk>/withdraw", "application/<int:pk>/withdraw",
views.ApplicationWithdraw.as_view(), views.ApplicationWithdrawConfirmation.as_view(),
name="application-withdraw-confirmation", name="application-withdraw-confirmation",
), ),
path( path(
"application/<int:pk>/withdrawconfirmed", "application/<int:pk>/withdrawconfirmed",
views.ApplicationWithdraw.updatestatus, views.ApplicationWithdrawn.as_view(),
name="application-withdrawn", name="application-withdrawn",
), ),
path("health/", views.health), path("health/", views.health),
@ -83,6 +83,11 @@ urlpatterns = [
views.DomainNameserversView.as_view(), views.DomainNameserversView.as_view(),
name="domain-nameservers", name="domain-nameservers",
), ),
path(
"domain/<int:pk>/your-contact-information",
views.DomainYourContactInformationView.as_view(),
name="domain-your-contact-information",
),
path( path(
"domain/<int:pk>/security-email", "domain/<int:pk>/security-email",
views.DomainSecurityEmailView.as_view(), views.DomainSecurityEmailView.as_view(),

View file

@ -1,2 +1,7 @@
from .application_wizard import * from .application_wizard import *
from .domain import DomainAddUserForm, NameserverFormset, DomainSecurityEmailForm from .domain import (
DomainAddUserForm,
NameserverFormset,
DomainSecurityEmailForm,
ContactForm,
)

View file

@ -3,6 +3,10 @@
from django import forms from django import forms
from django.forms import formset_factory from django.forms import formset_factory
from phonenumber_field.widgets import RegionalPhoneNumberWidget
from ..models import Contact
class DomainAddUserForm(forms.Form): class DomainAddUserForm(forms.Form):
@ -29,3 +33,34 @@ class DomainSecurityEmailForm(forms.Form):
"""Form for adding or editing a security email to a domain.""" """Form for adding or editing a security email to a domain."""
security_email = forms.EmailField(label="Security email") security_email = forms.EmailField(label="Security email")
class ContactForm(forms.ModelForm):
"""Form for updating contacts."""
class Meta:
model = Contact
fields = ["first_name", "middle_name", "last_name", "title", "email", "phone"]
widgets = {
"first_name": forms.TextInput,
"middle_name": forms.TextInput,
"last_name": forms.TextInput,
"title": forms.TextInput,
"email": forms.EmailInput,
"phone": RegionalPhoneNumberWidget,
}
# the database fields have blank=True so ModelForm doesn't create
# required fields by default. Use this list in __init__ to mark each
# of these fields as required
required = ["first_name", "last_name", "title", "email", "phone"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# take off maxlength attribute for the phone number field
# which interferes with our input_with_errors template tag
self.fields["phone"].widget.attrs.pop("maxlength", None)
for field_name in self.required:
self.fields[field_name].required = True

View file

@ -0,0 +1,44 @@
# Generated by Django 4.2.1 on 2023-05-31 23:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0022_draftdomain_domainapplication_approved_domain_and_more"),
]
operations = [
migrations.AlterField(
model_name="contact",
name="first_name",
field=models.TextField(
blank=True,
db_index=True,
help_text="First name",
null=True,
verbose_name="first name / given name",
),
),
migrations.AlterField(
model_name="contact",
name="last_name",
field=models.TextField(
blank=True,
db_index=True,
help_text="Last name",
null=True,
verbose_name="last name / family name",
),
),
migrations.AlterField(
model_name="contact",
name="title",
field=models.TextField(
blank=True,
help_text="Title",
null=True,
verbose_name="title or role in your organization",
),
),
]

View file

@ -20,6 +20,7 @@ class Contact(TimeStampedModel):
null=True, null=True,
blank=True, blank=True,
help_text="First name", help_text="First name",
verbose_name="first name / given name",
db_index=True, db_index=True,
) )
middle_name = models.TextField( middle_name = models.TextField(
@ -31,12 +32,14 @@ class Contact(TimeStampedModel):
null=True, null=True,
blank=True, blank=True,
help_text="Last name", help_text="Last name",
verbose_name="last name / family name",
db_index=True, db_index=True,
) )
title = models.TextField( title = models.TextField(
null=True, null=True,
blank=True, blank=True,
help_text="Title", help_text="Title",
verbose_name="title or role in your organization",
) )
email = models.TextField( email = models.TextField(
null=True, null=True,

View file

@ -31,7 +31,7 @@
<p> <b class="review__step__name">Last updated:</b> {{domainapplication.updated_at|date:"F j, Y"}}<br> <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> <b class="review__step__name">Request #:</b> {{domainapplication.id}}</p>
<p>{% include "includes/domain_application.html" %}</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> Withdraw request</a>
</p> </p>
</div> </div>

View file

@ -40,7 +40,7 @@
</li> </li>
<li class="usa-sidenav__item"> <li class="usa-sidenav__item">
{% url 'todo' as url %} {% url 'domain-your-contact-information' pk=domain.id as url %}
<a href="{{ url }}" <a href="{{ url }}"
{% if request.path == url %}class="usa-current"{% endif %} {% if request.path == url %}class="usa-current"{% endif %}
> >

View file

@ -0,0 +1,35 @@
{% extends "domain_base.html" %}
{% load static field_helpers %}
{% block title %}Domain contact information | {{ domain.name }} | {% endblock %}
{% block domain_content %}
<h1>Domain contact information</h1>
<p>If youd like us to use a different name, email, or phone number you can make those changes below. Changing your contact information here wont affect your Login.gov account information.</p>
{% include "includes/required_fields.html" %}
<form class="usa-form usa-form--large" method="post" novalidate id="form-container">
{% csrf_token %}
{% input_with_errors form.first_name %}
{% input_with_errors form.middle_name %}
{% input_with_errors form.last_name %}
{% input_with_errors form.title %}
{% input_with_errors form.email %}
{% input_with_errors form.phone %}
<button
type="submit"
class="usa-button"
>Save</button>
</form>
{% endblock %} {# domain_content #}

View file

@ -13,6 +13,7 @@ import boto3_mocking # type: ignore
from registrar.models import ( from registrar.models import (
DomainApplication, DomainApplication,
Domain, Domain,
DomainInformation,
DraftDomain, DraftDomain,
DomainInvitation, DomainInvitation,
Contact, Contact,
@ -1030,12 +1031,16 @@ class TestWithDomainPermissions(TestWithUser):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.domain, _ = Domain.objects.get_or_create(name="igorville.gov") self.domain, _ = Domain.objects.get_or_create(name="igorville.gov")
self.domain_information, _ = DomainInformation.objects.get_or_create(
creator=self.user, domain=self.domain
)
self.role, _ = UserDomainRole.objects.get_or_create( self.role, _ = UserDomainRole.objects.get_or_create(
user=self.user, domain=self.domain, role=UserDomainRole.Roles.ADMIN user=self.user, domain=self.domain, role=UserDomainRole.Roles.ADMIN
) )
def tearDown(self): def tearDown(self):
try: try:
self.domain_information.delete()
if hasattr(self.domain, "contacts"): if hasattr(self.domain, "contacts"):
self.domain.contacts.all().delete() self.domain.contacts.all().delete()
self.domain.delete() self.domain.delete()
@ -1048,26 +1053,17 @@ class TestWithDomainPermissions(TestWithUser):
class TestDomainPermissions(TestWithDomainPermissions): class TestDomainPermissions(TestWithDomainPermissions):
def test_not_logged_in(self): def test_not_logged_in(self):
"""Not logged in gets a redirect to Login.""" """Not logged in gets a redirect to Login."""
response = self.client.get(reverse("domain", kwargs={"pk": self.domain.id})) for view_name in [
self.assertEqual(response.status_code, 302) "domain",
"domain-users",
"domain-users-add",
"domain-nameservers",
"domain-your-contact-information",
"domain-security-email",
]:
with self.subTest(view_name=view_name):
response = self.client.get( response = self.client.get(
reverse("domain-users", kwargs={"pk": self.domain.id}) reverse(view_name, kwargs={"pk": self.domain.id})
)
self.assertEqual(response.status_code, 302)
response = self.client.get(
reverse("domain-users-add", kwargs={"pk": self.domain.id})
)
self.assertEqual(response.status_code, 302)
response = self.client.get(
reverse("domain-nameservers", kwargs={"pk": self.domain.id})
)
self.assertEqual(response.status_code, 302)
response = self.client.get(
reverse("domain-security-email", kwargs={"pk": self.domain.id})
) )
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
@ -1076,25 +1072,18 @@ class TestDomainPermissions(TestWithDomainPermissions):
self.client.force_login(self.user) self.client.force_login(self.user)
self.role.delete() # user no longer has a role on this domain self.role.delete() # user no longer has a role on this domain
with less_console_noise(): for view_name in [
response = self.client.get(reverse("domain", kwargs={"pk": self.domain.id})) "domain",
self.assertEqual(response.status_code, 403) "domain-users",
"domain-users-add",
"domain-nameservers",
"domain-your-contact-information",
"domain-security-email",
]:
with self.subTest(view_name=view_name):
with less_console_noise(): with less_console_noise():
response = self.client.get( response = self.client.get(
reverse("domain-users", kwargs={"pk": self.domain.id}) reverse(view_name, kwargs={"pk": self.domain.id})
)
self.assertEqual(response.status_code, 403)
with less_console_noise():
response = self.client.get(
reverse("domain-users-add", kwargs={"pk": self.domain.id})
)
self.assertEqual(response.status_code, 403)
with less_console_noise():
response = self.client.get(
reverse("domain-nameservers", kwargs={"pk": self.domain.id})
) )
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
@ -1139,7 +1128,7 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest):
self.assertContains(response, "Add another user") self.assertContains(response, "Add another user")
def test_domain_user_add_form(self): 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( other_user, _ = get_user_model().objects.get_or_create(
email="mayor@igorville.gov" email="mayor@igorville.gov"
) )
@ -1222,6 +1211,22 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest):
with self.assertRaises(DomainInvitation.DoesNotExist): with self.assertRaises(DomainInvitation.DoesNotExist):
DomainInvitation.objects.get(id=invitation.id) 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 @boto3_mocking.patching
def test_domain_invitation_flow(self): def test_domain_invitation_flow(self):
"""Send an invitation to a new user, log in and load the dashboard.""" """Send an invitation to a new user, log in and load the dashboard."""
@ -1296,6 +1301,23 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest):
# the field. # the field.
self.assertContains(result, "This field is required", count=2, status_code=200) self.assertContains(result, "This field is required", count=2, status_code=200)
def test_domain_your_contact_information(self):
"""Can load domain's your contact information page."""
page = self.client.get(
reverse("domain-your-contact-information", kwargs={"pk": self.domain.id})
)
self.assertContains(page, "Domain contact information")
def test_domain_your_contact_information_content(self):
"""Your contact information appears on the page."""
self.domain_information.submitter = Contact(first_name="Testy")
self.domain_information.submitter.save()
self.domain_information.save()
page = self.app.get(
reverse("domain-your-contact-information", kwargs={"pk": self.domain.id})
)
self.assertContains(page, "Testy")
def test_domain_security_email(self): def test_domain_security_email(self):
"""Can load domain's security email page.""" """Can load domain's security email page."""
page = self.client.get( page = self.client.get(
@ -1333,6 +1355,7 @@ class TestApplicationStatus(TestWithUser, WebTest):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
self.client.force_login(self.user)
def _completed_application( def _completed_application(
self, self,
@ -1446,3 +1469,24 @@ class TestApplicationStatus(TestWithUser, WebTest):
) )
home_page = self.app.get("/") home_page = self.app.get("/")
self.assertContains(home_page, "Withdrawn") 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

@ -2,6 +2,7 @@ from .application import *
from .domain import ( from .domain import (
DomainView, DomainView,
DomainNameserversView, DomainNameserversView,
DomainYourContactInformationView,
DomainSecurityEmailView, DomainSecurityEmailView,
DomainUsersView, DomainUsersView,
DomainAddUserView, DomainAddUserView,

View file

@ -6,7 +6,6 @@ from django.shortcuts import redirect, render
from django.urls import resolve, reverse from django.urls import resolve, reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import TemplateView from django.views.generic import TemplateView
from django.views import generic
from django.contrib import messages from django.contrib import messages
from registrar.forms import application_wizard as forms from registrar.forms import application_wizard as forms
@ -14,7 +13,7 @@ from registrar.models import DomainApplication
from registrar.utility import StrEnum from registrar.utility import StrEnum
from registrar.views.utility import StepsHelper from registrar.views.utility import StepsHelper
from .utility import DomainPermission from .utility import DomainApplicationPermissionView
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -478,29 +477,31 @@ class Finished(ApplicationWizard):
return render(self.request, self.template_name, context) return render(self.request, self.template_name, context)
class ApplicationStatus(generic.DetailView): class ApplicationStatus(DomainApplicationPermissionView):
model = DomainApplication
template_name = "application_status.html" 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): The DomainApplicationPermissionView restricts access so that only the
model = DomainApplication `creator` of the application may withdraw it.
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
""" """
def updatestatus(request, pk): template_name = "application_withdraw_confirmation.html"
"""If user click on withdraw confirm button, it will be updated to withdraw
and send back to homepage"""
application = DomainApplication.objects.get(id=pk) 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.status = "withdrawn"
application.save() application.save()
return HttpResponseRedirect(reverse("home")) 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 import logging
@ -7,35 +12,39 @@ from django.contrib.messages.views import SuccessMessageMixin
from django.db import IntegrityError from django.db import IntegrityError
from django.shortcuts import redirect from django.shortcuts import redirect
from django.urls import reverse from django.urls import reverse
from django.views.generic import DetailView from django.views.generic.edit import FormMixin
from django.views.generic.edit import DeleteView, FormMixin
from registrar.models import Domain, DomainInvitation, User, UserDomainRole from registrar.models import (
DomainInvitation,
User,
UserDomainRole,
)
from ..forms import DomainAddUserForm, NameserverFormset, DomainSecurityEmailForm from ..forms import (
DomainAddUserForm,
NameserverFormset,
DomainSecurityEmailForm,
ContactForm,
)
from ..utility.email import send_templated_email, EmailSendingError from ..utility.email import send_templated_email, EmailSendingError
from .utility import DomainPermission from .utility import DomainPermissionView, DomainInvitationPermissionDeleteView
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class DomainView(DomainPermission, DetailView): class DomainView(DomainPermissionView):
"""Domain detail overview page.""" """Domain detail overview page."""
model = Domain
template_name = "domain_detail.html" template_name = "domain_detail.html"
context_object_name = "domain"
class DomainNameserversView(DomainPermission, FormMixin, DetailView): class DomainNameserversView(DomainPermissionView, FormMixin):
"""Domain nameserver editing view.""" """Domain nameserver editing view."""
model = Domain
template_name = "domain_nameservers.html" template_name = "domain_nameservers.html"
context_object_name = "domain"
form_class = NameserverFormset form_class = NameserverFormset
def get_initial(self): def get_initial(self):
@ -44,7 +53,7 @@ class DomainNameserversView(DomainPermission, FormMixin, DetailView):
return [{"server": name} for name, *ip in domain.nameservers] return [{"server": name} for name, *ip in domain.nameservers]
def get_success_url(self): def get_success_url(self):
"""Redirect to the overview page for the domain.""" """Redirect to the nameservers page for the domain."""
return reverse("domain-nameservers", kwargs={"pk": self.object.pk}) return reverse("domain-nameservers", kwargs={"pk": self.object.pk})
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@ -97,13 +106,51 @@ class DomainNameserversView(DomainPermission, FormMixin, DetailView):
return super().form_valid(formset) return super().form_valid(formset)
class DomainSecurityEmailView(DomainPermission, FormMixin, DetailView): class DomainYourContactInformationView(DomainPermissionView, FormMixin):
"""Domain your contact information editing view."""
template_name = "domain_your_contact_information.html"
form_class = ContactForm
def get_form_kwargs(self, *args, **kwargs):
"""Add domain_info.submitter instance to make a bound form."""
form_kwargs = super().get_form_kwargs(*args, **kwargs)
form_kwargs["instance"] = self.get_object().domain_info.submitter
return form_kwargs
def get_success_url(self):
"""Redirect to the your contact information for the domain."""
return reverse("domain-your-contact-information", kwargs={"pk": self.object.pk})
def post(self, request, *args, **kwargs):
"""Form submission posts to this view."""
self.object = self.get_object()
form = self.get_form()
if form.is_valid():
# there is a valid email address in the form
return self.form_valid(form)
else:
return self.form_invalid(form)
def form_valid(self, form):
"""The form is valid, call setter in model."""
# Post to DB using values from the form
form.save()
messages.success(
self.request, "Your contact information for this domain has been updated."
)
# superclass has the redirect
return super().form_valid(form)
class DomainSecurityEmailView(DomainPermissionView, FormMixin):
"""Domain security email editing view.""" """Domain security email editing view."""
model = Domain
template_name = "domain_security_email.html" template_name = "domain_security_email.html"
context_object_name = "domain"
form_class = DomainSecurityEmailForm form_class = DomainSecurityEmailForm
def get_initial(self): def get_initial(self):
@ -114,11 +161,11 @@ class DomainSecurityEmailView(DomainPermission, FormMixin, DetailView):
return initial return initial
def get_success_url(self): def get_success_url(self):
"""Redirect to the overview page for the domain.""" """Redirect to the security email page for the domain."""
return reverse("domain-security-email", kwargs={"pk": self.object.pk}) return reverse("domain-security-email", kwargs={"pk": self.object.pk})
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
"""Formset submission posts to this view.""" """Form submission posts to this view."""
self.object = self.get_object() self.object = self.get_object()
form = self.get_form() form = self.get_form()
if form.is_valid(): if form.is_valid():
@ -144,16 +191,14 @@ class DomainSecurityEmailView(DomainPermission, FormMixin, DetailView):
return redirect(self.get_success_url()) return redirect(self.get_success_url())
class DomainUsersView(DomainPermission, DetailView): class DomainUsersView(DomainPermissionView):
"""User management page in the domain details.""" """User management page in the domain details."""
model = Domain
template_name = "domain_users.html" 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. """Inside of a domain's user management, a form for adding users.
@ -162,7 +207,6 @@ class DomainAddUserView(DomainPermission, FormMixin, DetailView):
""" """
template_name = "domain_add_user.html" template_name = "domain_add_user.html"
model = Domain
form_class = DomainAddUserForm form_class = DomainAddUserForm
def get_success_url(self): def get_success_url(self):
@ -242,8 +286,9 @@ class DomainAddUserView(DomainPermission, FormMixin, DetailView):
return redirect(self.get_success_url()) return redirect(self.get_success_url())
class DomainInvitationDeleteView(SuccessMessageMixin, DeleteView): class DomainInvitationDeleteView(
model = DomainInvitation DomainInvitationPermissionDeleteView, SuccessMessageMixin
):
object: DomainInvitation # workaround for type mismatch in DeleteView object: DomainInvitation # workaround for type mismatch in DeleteView
def get_success_url(self): def get_success_url(self):

View file

@ -1,3 +1,8 @@
from .steps_helper import StepsHelper from .steps_helper import StepsHelper
from .always_404 import always_404 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 django.contrib.auth.mixins import PermissionRequiredMixin
from registrar.models import UserDomainRole from registrar.models import UserDomainRole, DomainApplication, DomainInvitation
class PermissionsLoginMixin(PermissionRequiredMixin): class PermissionsLoginMixin(PermissionRequiredMixin):
@ -35,3 +35,48 @@ class DomainPermission(PermissionsLoginMixin):
# if we need to check more about the nature of role, do it here. # if we need to check more about the nature of role, do it here.
return True 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

View file

@ -52,6 +52,7 @@
10038 OUTOFSCOPE http://app:8080/users 10038 OUTOFSCOPE http://app:8080/users
10038 OUTOFSCOPE http://app:8080/users/add 10038 OUTOFSCOPE http://app:8080/users/add
10038 OUTOFSCOPE http://app:8080/nameservers 10038 OUTOFSCOPE http://app:8080/nameservers
10038 OUTOFSCOPE http://app:8080/your-contact-information
10038 OUTOFSCOPE http://app:8080/security-email 10038 OUTOFSCOPE http://app:8080/security-email
10038 OUTOFSCOPE http://app:8080/delete 10038 OUTOFSCOPE http://app:8080/delete
10038 OUTOFSCOPE http://app:8080/withdraw 10038 OUTOFSCOPE http://app:8080/withdraw