diff --git a/.github/ISSUE_TEMPLATE/issue-default.yml b/.github/ISSUE_TEMPLATE/issue-default.yml
index 35c816e35..27ec10415 100644
--- a/.github/ISSUE_TEMPLATE/issue-default.yml
+++ b/.github/ISSUE_TEMPLATE/issue-default.yml
@@ -1,34 +1,35 @@
name: Issue
-description: Capture uncategorized work or content
+description: Describe an idea, feature, content, or non-bug finding
body:
- type: markdown
- id: help
+ id: title-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.
+ > Titles should be short, descriptive, and compelling.
- type: textarea
- id: issue
+ id: issue-description
attributes:
- label: Issue Description
+ label: Issue description and context
description: |
- Describe the issue you are adding or content you are suggesting.
- Share any next steps that should be taken our outcomes that would be beneficial.
+ Describe the issue so that someone who wasn't present for its discovery can understand the problem and why it matters. Use full sentences, plain language, and good [formatting](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax). Share desired outcomes or potential next steps. Images or links to other content/context (like documents or Slack discussions) are welcome.
validations:
required: true
- type: textarea
- id: additional-context
+ id: acceptance-criteria
attributes:
- label: Additional Context (optional)
- description: "Include additional references (screenshots, design links, documentation, etc.) that are relevant"
+ label: Acceptance criteria
+ description: "If known, share 1-3 statements that would need to be true for this issue to be considered resolved. Use a [task list](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/about-task-lists#creating-task-lists) if appropriate."
+ placeholder: "- [ ] The button does the thing."
- type: textarea
- id: issue-links
+ id: links-to-other-issues
attributes:
- label: Issue Links
+ label: Links to other issues
description: |
- What other issues does this story relate to and how?
-
- Example:
- - 🚧 Blocked by: #123
- - 🔄 Relates to: #234
\ No newline at end of file
+ Add the issue #number of other issues this relates to and how (e.g., 🚧 Blocks, ⛔️ Is blocked by, 🔄 Relates to).
+ placeholder: 🔄 Relates to...
+ - type: markdown
+ id: note
+ attributes:
+ value: |
+ > We may edit this issue's text to document our understanding and clarify the product work.
diff --git a/docs/architecture/decisions/0023-use-geventconnpool.md b/docs/architecture/decisions/0023-use-geventconnpool.md
new file mode 100644
index 000000000..c24318b4f
--- /dev/null
+++ b/docs/architecture/decisions/0023-use-geventconnpool.md
@@ -0,0 +1,89 @@
+# 22. Use geventconnpool library for Connection Pooling
+
+Date: 2023-13-10
+
+## Status
+
+In Review
+
+## Context
+
+When sending and receiving data from the registry, we use the [EPPLib](https://github.com/cisagov/epplib) library to facilitate that process. To manage these connections within our application, we utilize a module named `epplibwrapper` which serves as a bridge between getgov and the EPPLib library. As part of this process, `epplibwrapper` will instantiate a client that handles sending/receiving data.
+
+At present, each time we need to send a command to the registry, the client establishes a new connection to handle this task. This becomes inefficient when dealing with multiple calls in parallel or in series, as we have to initiate a handshake for each of them. To mitigate this issue, a widely adopted solution is to use a [connection pool](https://en.wikipedia.org/wiki/Connection_pool). In general, a connection pool stores a cache of active connections so that rather than restarting the handshake process when it is unnecessary, we can utilize an existing connection to avoid this problem.
+
+In practice, the lack of a connection pool has resulted in performance issues when dealing with connections to and from the registry. Given the unique nature of our development stack, our options for prebuilt libraries are limited. Out of our available options, a library called [`geventconnpool`](https://github.com/rasky/geventconnpool) was identified that most closely matched our needs.
+
+## Considered Options
+
+**Option 1:** Use the existing connection pool library `geventconnpool`.
+
+➕ Pros
+
+- Saves development time and effort.
+- A tiny library that is easy to audit and understand.
+- Built to be flexible, so every built-in function can be overridden with minimal effort.
+- This library has been used for [EPP before](https://github.com/rasky/geventconnpool/issues/9).
+- Uses [`gevent`](http://www.gevent.org/) for coroutines, which is reliable and well maintained.
+- [`gevent`](http://www.gevent.org/) is used in our WSGI web server.
+- This library is the closest match to our needs that we have found.
+
+
+
+➖ Cons
+
+- Not a well maintained library, could require a fork if a dependency breaks.
+- Heavily reliant on `gevent`.
+
+
+
+**Option 2:** Write our own connection pool logic.
+
+➕ Pros
+
+- Full control over functionality, can be tailored to our specific needs.
+- Highly specific to our stack, could be fine tuned for performance.
+
+
+
+➖ Cons
+
+- Requires significant development time and effort, needs thorough testing.
+- Would require managing with and developing around concurrency.
+- Introduces the potential for many unseen bugs.
+
+
+
+**Option 3:** Modify an existing library which we will then tailor to our needs.
+
+➕ Pros
+
+- Savings in development time and effort, can be tailored to our specific needs.
+- Good middleground between the first two options.
+
+
+
+➖ Cons
+
+- Could introduce complexity, potential issues with maintaining the modified library.
+- May not be necessary if the given library is flexible enough.
+
+
+
+## Decision
+
+We have decided to go with option 1, which is to use the `geventconnpool` library. It closely matches our needs and offers several advantages. Of note, it significantly saves on development time and it is inherently flexible. This allows us to easily change functionality with minimal effort. In addition, the gevent library (which this uses) offers performance benefits due to it being a) written in [cython](https://cython.org/), b) very well maintained and purpose built for tasks such as these, and c) used in our WGSI server.
+
+In summary, this decision was driven by the library's flexibility, simplicity, and compatibility with our tech stack. We acknowledge the risk associated with its maintenance status, but believe that the benefit outweighs the risk.
+
+## Consequences
+
+While its small size makes it easy to work around, `geventconnpool` is not actively maintained. Its last update was in 2021, and as such there is a risk that its dependencies (gevent) will outpace this library and cause it to break. If such an event occurs, it would require that we fork the library and fix those issues. See option 3 pros/cons.
+
+## Mitigation Plan
+To manage this risk, we'll:
+
+1. Monitor the gevent library for updates.
+2. Design the connection pool logic abstractly such that we can easily swap the underlying logic out without needing (or minimizing the need) to rewrite code in `epplibwrapper`.
+3. Document a process for forking and maintaining the library if it becomes necessary, including testing procedures.
+4. Establish a contingency plan for reverting to a previous system state or switching to a different library if significant issues arise with `gevent` or `geventconnpool`.
\ No newline at end of file
diff --git a/docs/developer/README.md b/docs/developer/README.md
index de97b6107..c23671aac 100644
--- a/docs/developer/README.md
+++ b/docs/developer/README.md
@@ -80,7 +80,7 @@ The endpoint /admin can be used to view and manage site content, including but n
1. Login via login.gov
2. Go to the home page and make sure you can see the part where you can submit an application
3. Go to /admin and it will tell you that UUID is not authorized, copy that UUID for use in 4
-4. in src/registrar/fixtures.py add to the `ADMINS` list in that file by adding your UUID as your username along with your first and last name. See below:
+4. in src/registrar/fixtures_users.py add to the `ADMINS` list in that file by adding your UUID as your username along with your first and last name. See below:
```
ADMINS = [
@@ -102,7 +102,7 @@ Analysts are a variant of the admin role with limited permissions. The process f
1. Login via login.gov (if you already exist as an admin, you will need to create a separate login.gov account for this: i.e. first.last+1@email.com)
2. Go to the home page and make sure you can see the part where you can submit an application
3. Go to /admin and it will tell you that UUID is not authorized, copy that UUID for use in 4 (this will be a different UUID than the one obtained from creating an admin)
-4. in src/registrar/fixtures.py add to the `STAFF` list in that file by adding your UUID as your username along with your first and last name. See below:
+4. in src/registrar/fixtures_users.py add to the `STAFF` list in that file by adding your UUID as your username along with your first and last name. See below:
```
STAFF = [
@@ -145,7 +145,7 @@ You can change the logging verbosity, if needed. Do a web search for "django log
## Mock data
-There is a `post_migrate` signal in [signals.py](../../src/registrar/signals.py) that will load the fixtures from [fixtures.py](../../src/registrar/fixtures.py), giving you some test data to play with while developing.
+There is a `post_migrate` signal in [signals.py](../../src/registrar/signals.py) that will load the fixtures from [fixtures_user.py](../../src/registrar/fixtures_users.py) and [fixtures_applications.py](../../src/registrar/fixtures_applications.py), giving you some test data to play with while developing.
See the [database-access README](./database-access.md) for information on how to pull data to update these fixtures.
diff --git a/docs/developer/user-permissions.md b/docs/developer/user-permissions.md
index af5aa1259..31b69d3b3 100644
--- a/docs/developer/user-permissions.md
+++ b/docs/developer/user-permissions.md
@@ -48,3 +48,7 @@ future, as we add additional roles that our product vision calls for
(read-only? editing only some information?), we need to add conditional
behavior in the permission mixin, or additional mixins that more clearly
express what is allowed for those new roles.
+
+# Admin User Permissions
+
+Refer to [Django Admin Roles](../django-admin/roles.md)
diff --git a/docs/django-admin/roles.md b/docs/django-admin/roles.md
index ab4867184..458029e07 100644
--- a/docs/django-admin/roles.md
+++ b/docs/django-admin/roles.md
@@ -1,21 +1,20 @@
# Django admin user roles
-Roles other than superuser should be defined in authentication and authorization groups in django admin
+For our MVP, we create and maintain 2 admin roles:
+Full access and CISA analyst. Both have the role `staff`.
+Permissions on these roles are set through groups:
+`full_access_group` and `cisa_analysts_group`. These
+groups and the methods to create them are defined in
+our `user_group` model and run in a migration.
-## Superuser
+For more details, refer to the [user group model](../../src/registrar/models/user_group.py).
-Full access
+## Editing group permissions through code
-## CISA analyst
+We can edit and deploy new group permissions by:
-### Basic permission level
-
-Staff
-
-### Additional group permissions
-
-auditlog | log entry | can view log entry
-registrar | contact | can view contact
-registrar | domain application | can change domain application
-registrar | domain | can view domain
-registrar | user | can view user
\ No newline at end of file
+1. Editing `user_group` then:
+2. Duplicating migration `0036_create_groups_01`
+and running migrations (append the name with a version number
+to help django detect the migration eg 0037_create_groups_02)
+3. Making sure to update the dependency on the new migration with the previous migration
\ No newline at end of file
diff --git a/docs/operations/README.md b/docs/operations/README.md
index e4ab64135..4de866cf5 100644
--- a/docs/operations/README.md
+++ b/docs/operations/README.md
@@ -89,7 +89,8 @@ command in the running Cloud.gov container. For example, to run our Django
admin command that loads test fixture data:
```
-cf run-task getgov-{environment} --command "./manage.py load" --name fixtures
+cf run-task getgov-{environment} --command "./manage.py load" --name fixtures--users
+cf run-task getgov-{environment} --command "./manage.py load" --name fixtures--applications
```
However, this task runs asynchronously in the background without any command
diff --git a/docs/operations/data_migration.md b/docs/operations/data_migration.md
index c677554de..192db0db8 100644
--- a/docs/operations/data_migration.md
+++ b/docs/operations/data_migration.md
@@ -1,11 +1,12 @@
# Registrar Data Migration
-There is an existing registrar/registry at Verisign. They will provide us with an
-export of the data from that system. The goal of our data migration is to take
-the provided data and use it to create as much as possible a _matching_ state
+The original system has an existing registrar/registry that we will import.
+The company of that system will provide us with an export of the data.
+The goal of our data migration is to take the provided data and use
+it to create as much as possible a _matching_ state
in our registrar.
-There is no way to make our registrar _identical_ to the Verisign system
+There is no way to make our registrar _identical_ to the original system
because we have a different data model and workflow model. Instead, we should
focus our migration efforts on creating a state in our new registrar that will
primarily allow users of the system to perform the tasks that they want to do.
@@ -18,7 +19,7 @@ Login.gov account can make an account on the new registrar, and the first time
that person logs in through Login.gov, we make a corresponding account in our
user table. Because we cannot know the Universal Unique ID (UUID) for a
person's Login.gov account, we cannot pre-create user accounts for individuals
-in our new registrar based on the data from Verisign.
+in our new registrar based on the original data.
## Domains
@@ -27,7 +28,7 @@ information is the registry, but the registrar needs a copy of that
information to make connections between registry users and the domains that
they manage. The registrar stores very few fields about a domain except for
its name, so it could be straightforward to import the exported list of domains
-from Verisign's `escrow_domains.daily.dotgov.GOV.txt`. It doesn't appear that
+from `escrow_domains.daily.dotgov.GOV.txt`. It doesn't appear that
that table stores a flag for active or inactive.
An example Django management command that can load the delimited text file
@@ -43,7 +44,7 @@ docker compose run -T app ./manage.py load_domains_data < /tmp/escrow_domains.da
## User access to domains
-The Verisign data contains a `escrow_domain_contacts.daily.dotgov.txt` file
+The data export contains a `escrow_domain_contacts.daily.dotgov.txt` file
that links each domain to three different types of contacts: `billing`,
`tech`, and `admin`. The ID of the contact in this linking table corresponds
to the ID of a contact in the `escrow_contacts.daily.dotgov.txt` file. In the
@@ -59,9 +60,9 @@ invitation's domain.
For the purposes of migration, we can prime the invitation system by creating
an invitation in the system for each email address listed in the
`domain_contacts` file. This means that if a person is currently a user in the
-Verisign system, and they use the same email address with Login.gov, then they
+original system, and they use the same email address with Login.gov, then they
will end up with access to the same domains in the new registrar that they
-were associated with in the Verisign system.
+were associated with in the original system.
A management command that does this needs to process two data files, one for
the contact information and one for the domain/contact association, so we
@@ -76,3 +77,56 @@ An example script using this technique is in
```shell
docker compose run app ./manage.py load_domain_invitations /app/escrow_domain_contacts.daily.dotgov.GOV.txt /app/escrow_contacts.daily.dotgov.GOV.txt
```
+
+## Transition Domains
+We are provided with information about Transition Domains in 3 files:
+FILE 1: **escrow_domain_contacts.daily.gov.GOV.txt** -> has the map of domain names to contact ID. Domains in this file will usually have 3 contacts each
+FILE 2: **escrow_contacts.daily.gov.GOV.txt** -> has the mapping of contact id to contact email address (which is what we care about for sending domain invitations)
+FILE 3: **escrow_domain_statuses.daily.gov.GOV.txt** -> has the map of domains and their statuses
+
+Transferring this data from these files into our domain tables happens in two steps;
+
+***IMPORTANT: only run the following locally, to avoid publicizing PII in our public repo.***
+
+### STEP 1: Load Transition Domain data into TransitionDomain table
+
+**SETUP**
+In order to use the management command, we need to add the files to a folder under `src/`.
+This will allow Docker to mount the files to a container (under `/app`) for our use.
+
+ - Create a folder called `tmp` underneath `src/`
+ - Add the above files to this folder
+ - Open a terminal and navigate to `src/`
+
+Then run the following command (This will parse the three files in your `tmp` folder and load the information into the TransitionDomain table);
+```shell
+docker compose run -T app ./manage.py load_transition_domain /app/tmp/escrow_domain_contacts.daily.gov.GOV.txt /app/tmp/escrow_contacts.daily.gov.GOV.txt /app/tmp/escrow_domain_statuses.daily.gov.GOV.txt
+```
+
+**OPTIONAL COMMAND LINE ARGUMENTS**:
+`--debug`
+This will print out additional, detailed logs.
+
+`--limitParse 100`
+Directs the script to load only the first 100 entries into the table. You can adjust this number as needed for testing purposes.
+
+`--resetTable`
+This will delete all the data loaded into transtion_domain. It is helpful if you want to see the entries reload from scratch or for clearing test data.
+
+
+### STEP 2: Transfer Transition Domain data into main Domain tables
+
+Now that we've loaded all the data into TransitionDomain, we need to update the main Domain and DomainInvitation tables with this information.
+
+In the same terminal as used in STEP 1, run the command below;
+(This will parse the data in TransitionDomain and either create a corresponding Domain object, OR, if a corresponding Domain already exists, it will update that Domain with the incoming status. It will also create DomainInvitation objects for each user associated with the domain):
+```shell
+docker compose run -T app ./manage.py transfer_transition_domains_to_domains
+```
+
+**OPTIONAL COMMAND LINE ARGUMENTS**:
+`--debug`
+This will print out additional, detailed logs.
+
+`--limitParse 100`
+Directs the script to load only the first 100 entries into the table. You can adjust this number as needed for testing purposes.
\ No newline at end of file
diff --git a/src/epplibwrapper/__init__.py b/src/epplibwrapper/__init__.py
index dd6664a3a..d0138d73c 100644
--- a/src/epplibwrapper/__init__.py
+++ b/src/epplibwrapper/__init__.py
@@ -45,7 +45,7 @@ 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 .errors import RegistryError, ErrorCode, CANNOT_CONTACT_REGISTRY, GENERIC_ERROR
from epplib.models import common, info
from epplib.responses import extensions
from epplib import responses
@@ -61,4 +61,6 @@ __all__ = [
"info",
"ErrorCode",
"RegistryError",
+ "CANNOT_CONTACT_REGISTRY",
+ "GENERIC_ERROR",
]
diff --git a/src/epplibwrapper/errors.py b/src/epplibwrapper/errors.py
index d34ed5e91..dba5f328c 100644
--- a/src/epplibwrapper/errors.py
+++ b/src/epplibwrapper/errors.py
@@ -1,5 +1,8 @@
from enum import IntEnum
+CANNOT_CONTACT_REGISTRY = "Update failed. Cannot contact the registry."
+GENERIC_ERROR = "Value entered was wrong."
+
class ErrorCode(IntEnum):
"""
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 275f67bb3..8d0ed8c2e 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -3,6 +3,7 @@ from django import forms
from django_fsm import get_available_FIELD_transitions
from django.contrib import admin, messages
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
+from django.contrib.auth.models import Group
from django.contrib.contenttypes.models import ContentType
from django.http.response import HttpResponseRedirect
from django.urls import reverse
@@ -137,8 +138,9 @@ class MyUserAdmin(BaseUserAdmin):
"email",
"first_name",
"last_name",
- "is_staff",
- "is_superuser",
+ # Group is a custom property defined within this file,
+ # rather than in a model like the other properties
+ "group",
"status",
)
@@ -163,6 +165,9 @@ class MyUserAdmin(BaseUserAdmin):
("Important dates", {"fields": ("last_login", "date_joined")}),
)
+ # Hide Username (uuid), Groups and Permissions
+ # Q: Now that we're using Groups and Permissions,
+ # do we expose those to analysts to view?
analyst_fieldsets = (
(
None,
@@ -174,14 +179,23 @@ class MyUserAdmin(BaseUserAdmin):
{
"fields": (
"is_active",
- "is_staff",
- "is_superuser",
+ "groups",
)
},
),
("Important dates", {"fields": ("last_login", "date_joined")}),
)
+ analyst_list_display = [
+ "email",
+ "first_name",
+ "last_name",
+ "group",
+ "status",
+ ]
+
+ # NOT all fields are readonly for admin, otherwise we would have
+ # set this at the permissions level. The exception is 'status'
analyst_readonly_fields = [
"password",
"Personal Info",
@@ -190,43 +204,56 @@ class MyUserAdmin(BaseUserAdmin):
"email",
"Permissions",
"is_active",
- "is_staff",
- "is_superuser",
+ "groups",
"Important dates",
"last_login",
"date_joined",
]
- def get_list_display(self, request):
- if not request.user.is_superuser:
- # Customize the list display for staff users
- return (
- "email",
- "first_name",
- "last_name",
- "is_staff",
- "is_superuser",
- "status",
- )
+ list_filter = (
+ "is_active",
+ "groups",
+ )
- # Use the default list display for non-staff users
- return super().get_list_display(request)
+ # Let's define First group
+ # (which should in theory be the ONLY group)
+ def group(self, obj):
+ if obj.groups.filter(name="full_access_group").exists():
+ return "Full access"
+ elif obj.groups.filter(name="cisa_analysts_group").exists():
+ return "Analyst"
+ return ""
+
+ def get_list_display(self, request):
+ # The full_access_permission perm will load onto the full_access_group
+ # which is equivalent to superuser. The other group we use to manage
+ # perms is cisa_analysts_group. cisa_analysts_group will never contain
+ # full_access_permission
+ if request.user.has_perm("registrar.full_access_permission"):
+ # Use the default list display for all access users
+ return super().get_list_display(request)
+
+ # Customize the list display for analysts
+ return self.analyst_list_display
def get_fieldsets(self, request, obj=None):
- if not request.user.is_superuser:
- # If the user doesn't have permission to change the model,
- # show a read-only fieldset
+ if request.user.has_perm("registrar.full_access_permission"):
+ # Show all fields for all access users
+ return super().get_fieldsets(request, obj)
+ elif request.user.has_perm("registrar.analyst_access_permission"):
+ # show analyst_fieldsets for analysts
return self.analyst_fieldsets
-
- # If the user has permission to change the model, show all fields
- return super().get_fieldsets(request, obj)
+ else:
+ # any admin user should belong to either full_access_group
+ # or cisa_analyst_group
+ return []
def get_readonly_fields(self, request, obj=None):
- if request.user.is_superuser:
- return () # No read-only fields for superusers
- elif request.user.is_staff:
- return self.analyst_readonly_fields # Read-only fields for staff
- return () # No read-only fields for other users
+ if request.user.has_perm("registrar.full_access_permission"):
+ return () # No read-only fields for all access users
+ # Return restrictive Read-only fields for analysts and
+ # users who might not belong to groups
+ return self.analyst_readonly_fields
class HostIPInline(admin.StackedInline):
@@ -315,6 +342,12 @@ class DomainInvitationAdmin(ListHeaderAdmin):
]
search_help_text = "Search by email or domain."
+ # Mark the FSM field 'status' as readonly
+ # to allow admin users to create Domain Invitations
+ # without triggering the FSM Transition Not Allowed
+ # error.
+ readonly_fields = ["status"]
+
class DomainInformationAdmin(ListHeaderAdmin):
"""Customize domain information admin class."""
@@ -405,11 +438,12 @@ class DomainInformationAdmin(ListHeaderAdmin):
readonly_fields = list(self.readonly_fields)
- if request.user.is_superuser:
- return readonly_fields
- else:
- readonly_fields.extend([field for field in self.analyst_readonly_fields])
+ if request.user.has_perm("registrar.full_access_permission"):
return readonly_fields
+ # Return restrictive Read-only fields for analysts and
+ # users who might not belong to groups
+ readonly_fields.extend([field for field in self.analyst_readonly_fields])
+ return readonly_fields # Read-only fields for analysts
class DomainApplicationAdminForm(forms.ModelForm):
@@ -623,11 +657,12 @@ class DomainApplicationAdmin(ListHeaderAdmin):
["current_websites", "other_contacts", "alternative_domains"]
)
- if request.user.is_superuser:
- return readonly_fields
- else:
- readonly_fields.extend([field for field in self.analyst_readonly_fields])
+ if request.user.has_perm("registrar.full_access_permission"):
return readonly_fields
+ # Return restrictive Read-only fields for analysts and
+ # users who might not belong to groups
+ readonly_fields.extend([field for field in self.analyst_readonly_fields])
+ return readonly_fields
def display_restricted_warning(self, request, obj):
if obj and obj.creator.status == models.User.RESTRICTED:
@@ -784,7 +819,8 @@ class DomainAdmin(ListHeaderAdmin):
else:
self.message_user(
request,
- ("Domain statuses are %s" ". Thanks!") % statuses,
+ f"The registry statuses are {statuses}. "
+ "These statuses are from the provider of the .gov registry.",
)
return HttpResponseRedirect(".")
@@ -870,7 +906,9 @@ class DomainAdmin(ListHeaderAdmin):
# Fixes a bug wherein users which are only is_staff
# can access 'change' when GET,
# but cannot access this page when it is a request of type POST.
- if request.user.is_staff:
+ if request.user.has_perm(
+ "registrar.full_access_permission"
+ ) or request.user.has_perm("registrar.analyst_access_permission"):
return True
return super().has_change_permission(request, obj)
@@ -885,6 +923,10 @@ class DraftDomainAdmin(ListHeaderAdmin):
admin.site.unregister(LogEntry) # Unregister the default registration
admin.site.register(LogEntry, CustomLogEntryAdmin)
admin.site.register(models.User, MyUserAdmin)
+# Unregister the built-in Group model
+admin.site.unregister(Group)
+# Register UserGroup
+admin.site.register(models.UserGroup)
admin.site.register(models.UserDomainRole, UserDomainRoleAdmin)
admin.site.register(models.Contact, ContactAdmin)
admin.site.register(models.DomainInvitation, DomainInvitationAdmin)
diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js
index 57dc6d2e3..c21060382 100644
--- a/src/registrar/assets/js/get-gov.js
+++ b/src/registrar/assets/js/get-gov.js
@@ -236,28 +236,150 @@ function handleValidationClick(e) {
* Only does something on a single page, but it should be fast enough to run
* it everywhere.
*/
-(function prepareForms() {
- let serverForm = document.querySelectorAll(".server-form")
- let container = document.querySelector("#form-container")
- let addButton = document.querySelector("#add-form")
- let totalForms = document.querySelector("#id_form-TOTAL_FORMS")
+(function prepareNameserverForms() {
+ let serverForm = document.querySelectorAll(".server-form");
+ let container = document.querySelector("#form-container");
+ let addButton = document.querySelector("#add-nameserver-form");
+ let totalForms = document.querySelector("#id_form-TOTAL_FORMS");
- let formNum = serverForm.length-1
- addButton.addEventListener('click', addForm)
+ let formNum = serverForm.length-1;
+ if (addButton)
+ addButton.addEventListener('click', addForm);
function addForm(e){
- let newForm = serverForm[2].cloneNode(true)
- let formNumberRegex = RegExp(`form-(\\d){1}-`,'g')
- let formLabelRegex = RegExp(`Name server (\\d){1}`, 'g')
- let formExampleRegex = RegExp(`ns(\\d){1}`, 'g')
+ let newForm = serverForm[2].cloneNode(true);
+ let formNumberRegex = RegExp(`form-(\\d){1}-`,'g');
+ let formLabelRegex = RegExp(`Name server (\\d){1}`, 'g');
+ let formExampleRegex = RegExp(`ns(\\d){1}`, 'g');
- formNum++
- newForm.innerHTML = newForm.innerHTML.replace(formNumberRegex, `form-${formNum}-`)
- newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `Name server ${formNum+1}`)
- newForm.innerHTML = newForm.innerHTML.replace(formExampleRegex, `ns${formNum+1}`)
- container.insertBefore(newForm, addButton)
- newForm.querySelector("input").value = ""
+ formNum++;
+ newForm.innerHTML = newForm.innerHTML.replace(formNumberRegex, `form-${formNum}-`);
+ newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `Name server ${formNum+1}`);
+ newForm.innerHTML = newForm.innerHTML.replace(formExampleRegex, `ns${formNum+1}`);
+ container.insertBefore(newForm, addButton);
+ newForm.querySelector("input").value = "";
- totalForms.setAttribute('value', `${formNum+1}`)
+ totalForms.setAttribute('value', `${formNum+1}`);
}
})();
+
+function prepareDeleteButtons() {
+ let deleteButtons = document.querySelectorAll(".delete-record");
+ let totalForms = document.querySelector("#id_form-TOTAL_FORMS");
+
+ // Loop through each delete button and attach the click event listener
+ deleteButtons.forEach((deleteButton) => {
+ deleteButton.addEventListener('click', removeForm);
+ });
+
+ function removeForm(e){
+ let formToRemove = e.target.closest(".ds-record");
+ formToRemove.remove();
+ let forms = document.querySelectorAll(".ds-record");
+ totalForms.setAttribute('value', `${forms.length}`);
+
+ let formNumberRegex = RegExp(`form-(\\d){1}-`, 'g');
+ let formLabelRegex = RegExp(`DS Data record (\\d){1}`, 'g');
+
+ forms.forEach((form, index) => {
+ // Iterate over child nodes of the current element
+ Array.from(form.querySelectorAll('label, input, select')).forEach((node) => {
+ // Iterate through the attributes of the current node
+ Array.from(node.attributes).forEach((attr) => {
+ // Check if the attribute value matches the regex
+ if (formNumberRegex.test(attr.value)) {
+ // Replace the attribute value with the updated value
+ attr.value = attr.value.replace(formNumberRegex, `form-${index}-`);
+ }
+ });
+ });
+
+ Array.from(form.querySelectorAll('h2, legend')).forEach((node) => {
+ node.textContent = node.textContent.replace(formLabelRegex, `DS Data record ${index + 1}`);
+ });
+
+ });
+ }
+}
+
+/**
+ * An IIFE that attaches a click handler for our dynamic DNSSEC forms
+ *
+ */
+(function prepareDNSSECForms() {
+ let serverForm = document.querySelectorAll(".ds-record");
+ let container = document.querySelector("#form-container");
+ let addButton = document.querySelector("#add-ds-form");
+ let totalForms = document.querySelector("#id_form-TOTAL_FORMS");
+
+ // Attach click event listener on the delete buttons of the existing forms
+ prepareDeleteButtons();
+
+ // Attack click event listener on the add button
+ if (addButton)
+ addButton.addEventListener('click', addForm);
+
+ /*
+ * Add a formset to the end of the form.
+ * For each element in the added formset, name the elements with the prefix,
+ * form-{#}-{element_name} where # is the index of the formset and element_name
+ * is the element's name.
+ * Additionally, update the form element's metadata, including totalForms' value.
+ */
+ function addForm(e){
+ let forms = document.querySelectorAll(".ds-record");
+ let formNum = forms.length;
+ let newForm = serverForm[0].cloneNode(true);
+ let formNumberRegex = RegExp(`form-(\\d){1}-`,'g');
+ let formLabelRegex = RegExp(`DS Data record (\\d){1}`, 'g');
+
+ formNum++;
+ newForm.innerHTML = newForm.innerHTML.replace(formNumberRegex, `form-${formNum-1}-`);
+ newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `DS Data record ${formNum}`);
+ container.insertBefore(newForm, addButton);
+
+ let inputs = newForm.querySelectorAll("input");
+ // Reset the values of each input to blank
+ inputs.forEach((input) => {
+ input.classList.remove("usa-input--error");
+ if (input.type === "text" || input.type === "number" || input.type === "password") {
+ input.value = ""; // Set the value to an empty string
+
+ } else if (input.type === "checkbox" || input.type === "radio") {
+ input.checked = false; // Uncheck checkboxes and radios
+ }
+ });
+
+ // Reset any existing validation classes
+ let selects = newForm.querySelectorAll("select");
+ selects.forEach((select) => {
+ select.classList.remove("usa-input--error");
+ select.selectedIndex = 0; // Set the value to an empty string
+ });
+
+ let labels = newForm.querySelectorAll("label");
+ labels.forEach((label) => {
+ label.classList.remove("usa-label--error");
+ });
+
+ let usaFormGroups = newForm.querySelectorAll(".usa-form-group");
+ usaFormGroups.forEach((usaFormGroup) => {
+ usaFormGroup.classList.remove("usa-form-group--error");
+ });
+
+ // Remove any existing error messages
+ let usaErrorMessages = newForm.querySelectorAll(".usa-error-message");
+ usaErrorMessages.forEach((usaErrorMessage) => {
+ let parentDiv = usaErrorMessage.closest('div');
+ if (parentDiv) {
+ parentDiv.remove(); // Remove the parent div if it exists
+ }
+ });
+
+ totalForms.setAttribute('value', `${formNum}`);
+
+ // Attach click event listener on the delete buttons of the new form
+ prepareDeleteButtons();
+ }
+
+})();
diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss
index a2e32bd21..35d089cbd 100644
--- a/src/registrar/assets/sass/_theme/_admin.scss
+++ b/src/registrar/assets/sass/_theme/_admin.scss
@@ -179,4 +179,4 @@ h1, h2, h3 {
text-align: left;
background: var(--primary);
color: var(--header-link-color);
-}
\ No newline at end of file
+}
diff --git a/src/registrar/assets/sass/_theme/_alerts.scss b/src/registrar/assets/sass/_theme/_alerts.scss
new file mode 100644
index 000000000..dd51734ed
--- /dev/null
+++ b/src/registrar/assets/sass/_theme/_alerts.scss
@@ -0,0 +1,17 @@
+// Fixes some font size disparities with the Figma
+// for usa-alert alert elements
+.usa-alert {
+ .usa-alert__heading.larger-font-sizing {
+ font-size: units(3);
+ }
+}
+
+// The icon was off center for some reason
+// Fixes that issue
+@media (min-width: 64em){
+ .usa-alert--warning{
+ .usa-alert__body::before {
+ left: 1rem !important;
+ }
+ }
+}
diff --git a/src/registrar/assets/sass/_theme/_base.scss b/src/registrar/assets/sass/_theme/_base.scss
new file mode 100644
index 000000000..668a6ace6
--- /dev/null
+++ b/src/registrar/assets/sass/_theme/_base.scss
@@ -0,0 +1,127 @@
+@use "uswds-core" as *;
+
+/* Styles for making visible to screen reader / AT users only. */
+.sr-only {
+ @include sr-only;
+}
+
+* {
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+body {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+}
+
+#wrapper {
+ flex-grow: 1;
+ padding-top: units(3);
+ padding-bottom: units(6) * 2 ; //Workaround because USWDS units jump from 10 to 15
+}
+
+#wrapper.dashboard {
+ background-color: color('primary-lightest');
+ padding-top: units(5);
+}
+
+.usa-logo {
+ @include at-media(desktop) {
+ margin-top: units(2);
+ }
+}
+
+.usa-logo__text {
+ @include typeset('sans', 'xl', 2);
+ color: color('primary-darker');
+}
+
+.usa-nav__primary {
+ margin-top: units(1);
+}
+
+.section--outlined {
+ background-color: color('white');
+ border: 1px solid color('base-lighter');
+ border-radius: 4px;
+ padding: 0 units(2) units(3);
+ margin-top: units(3);
+
+ h2 {
+ color: color('primary-dark');
+ margin-top: units(2);
+ margin-bottom: units(2);
+ }
+
+ p {
+ margin-bottom: 0;
+ }
+
+ @include at-media(mobile-lg) {
+ margin-top: units(5);
+
+ h2 {
+ margin-bottom: 0;
+ }
+ }
+}
+
+.break-word {
+ word-break: break-word;
+}
+
+.dotgov-status-box {
+ background-color: color('primary-lightest');
+ border-color: color('accent-cool-lighter');
+}
+
+.dotgov-status-box--action-need {
+ background-color: color('warning-lighter');
+ border-color: color('warning');
+}
+
+footer {
+ border-top: 1px solid color('primary-darker');
+}
+
+.usa-footer__secondary-section {
+ background-color: color('primary-lightest');
+}
+
+.usa-footer__secondary-section a {
+ color: color('primary');
+}
+
+.usa-identifier__logo {
+ height: units(7);
+}
+
+abbr[title] {
+ // workaround for underlining abbr element
+ border-bottom: none;
+ text-decoration: none;
+}
+
+@include at-media(tablet) {
+ .float-right-tablet {
+ float: right;
+ }
+ .float-left-tablet {
+ float: left;
+ }
+}
+
+@include at-media(desktop) {
+ .float-right-desktop {
+ float: right;
+ }
+ .float-left-desktop {
+ float: left;
+ }
+}
+
+.flex-end {
+ align-items: flex-end;
+}
diff --git a/src/registrar/assets/sass/_theme/_buttons.scss b/src/registrar/assets/sass/_theme/_buttons.scss
new file mode 100644
index 000000000..718bd5792
--- /dev/null
+++ b/src/registrar/assets/sass/_theme/_buttons.scss
@@ -0,0 +1,125 @@
+@use "uswds-core" as *;
+
+/* Make "placeholder" links visually obvious */
+a[href$="todo"]::after {
+ background-color: yellow;
+ color: color(blue-80v);
+ content: " [link TBD]";
+ font-style: italic;
+}
+
+a.breadcrumb__back {
+ display:flex;
+ align-items: center;
+ margin-bottom: units(2.5);
+ &:visited {
+ color: color('primary');
+ }
+
+ @include at-media('tablet') {
+ //align to top of sidebar
+ margin-top: units(-0.5);
+ }
+}
+
+a.usa-button {
+ text-decoration: none;
+ color: color('white');
+}
+
+a.usa-button:visited,
+a.usa-button:hover,
+a.usa-button:focus,
+a.usa-button:active {
+ color: color('white');
+}
+
+a.usa-button--outline,
+a.usa-button--outline:visited {
+ box-shadow: inset 0 0 0 2px color('primary');
+ color: color('primary');
+}
+
+a.usa-button--outline:hover,
+a.usa-button--outline:focus {
+ box-shadow: inset 0 0 0 2px color('primary-dark');
+ color: color('primary-dark');
+}
+
+a.usa-button--outline:active {
+ box-shadow: inset 0 0 0 2px color('primary-darker');
+ color: color('primary-darker');
+}
+
+a.withdraw {
+ background-color: color('error');
+}
+
+a.withdraw_outline,
+a.withdraw_outline:visited {
+ box-shadow: inset 0 0 0 2px color('error');
+ color: color('error');
+}
+
+a.withdraw_outline:hover,
+a.withdraw_outline:focus {
+ box-shadow: inset 0 0 0 2px color('error-dark');
+ color: color('error-dark');
+}
+
+a.withdraw_outline:active {
+ box-shadow: inset 0 0 0 2px color('error-darker');
+ color: color('error-darker');
+}
+
+a.withdraw:hover,
+a.withdraw:focus {
+ background-color: color('error-dark');
+}
+
+a.withdraw:active {
+ background-color: color('error-darker');
+}
+
+.usa-button--unstyled .usa-icon {
+ vertical-align: bottom;
+}
+
+a.usa-button--unstyled:visited {
+ color: color('primary');
+}
+
+.dotgov-button--green {
+ background-color: color('success-dark');
+
+ &:hover {
+ background-color: color('success-darker');
+ }
+
+ &:active {
+ background-color: color('green-80v');
+ }
+}
+
+// Cancel button used on the
+// DNSSEC main page
+// We want to center this button on mobile
+// and add some extra left margin on tablet+
+.usa-button--cancel {
+ text-align: center;
+ @include at-media('tablet') {
+ margin-left: units(2);
+ }
+}
+
+
+// WARNING: crazy hack ahead:
+// Cancel button(s) on the DNSSEC form pages
+// We want to position the cancel button on the
+// dnssec forms next to the submit button
+// This button's markup is in its own form
+.btn-cancel {
+ position: relative;
+ top: -39.2px;
+ left: 88px;
+}
diff --git a/src/registrar/assets/sass/_theme/_fieldsets.scss b/src/registrar/assets/sass/_theme/_fieldsets.scss
new file mode 100644
index 000000000..c60080cb9
--- /dev/null
+++ b/src/registrar/assets/sass/_theme/_fieldsets.scss
@@ -0,0 +1,10 @@
+@use "uswds-core" as *;
+
+fieldset {
+ border: solid 1px color('base-lighter');
+ padding: units(3);
+}
+
+fieldset:not(:first-child) {
+ margin-top: units(2);
+}
diff --git a/src/registrar/assets/sass/_theme/_forms.scss b/src/registrar/assets/sass/_theme/_forms.scss
new file mode 100644
index 000000000..ed118bb94
--- /dev/null
+++ b/src/registrar/assets/sass/_theme/_forms.scss
@@ -0,0 +1,25 @@
+@use "uswds-core" as *;
+
+.usa-form .usa-button {
+ margin-top: units(3);
+}
+
+.usa-form--extra-large {
+ max-width: none;
+}
+
+.usa-form--text-width {
+ max-width: measure(5);
+}
+
+.usa-textarea {
+ @include at-media('tablet') {
+ height: units('mobile');
+ }
+}
+
+.usa-form-group--unstyled-error {
+ margin-left: 0;
+ padding-left: 0;
+ border-left: none;
+}
diff --git a/src/registrar/assets/sass/_theme/_register-form.scss b/src/registrar/assets/sass/_theme/_register-form.scss
new file mode 100644
index 000000000..d0405a3c3
--- /dev/null
+++ b/src/registrar/assets/sass/_theme/_register-form.scss
@@ -0,0 +1,80 @@
+@use "uswds-core" as *;
+@use "typography" as *;
+
+.register-form-step > h1 {
+ //align to top of sidebar on first page of the form
+ margin-top: units(-1);
+}
+
+ //Tighter spacing when H2 is immediatly after H1
+.register-form-step .usa-fieldset:first-of-type h2:first-of-type,
+.register-form-step h1 + h2 {
+ margin-top: units(1);
+}
+
+.register-form-step h3 {
+ color: color('primary-dark');
+ letter-spacing: $letter-space--xs;
+ margin-top: units(3);
+ margin-bottom: 0;
+
+ + p {
+ margin-top: units(0.5);
+ }
+}
+
+.register-form-step h4 {
+ margin-bottom: 0;
+
+ + p {
+ margin-top: units(0.5);
+ }
+}
+
+.register-form-step a {
+ color: color('primary');
+
+ &:visited {
+ color: color('violet-70v'); //USWDS default
+ }
+}
+.register-form-step .usa-form-group:first-of-type,
+.register-form-step .usa-label:first-of-type {
+ margin-top: units(1);
+}
+
+.ao_example p {
+ margin-top: units(1);
+}
+
+.domain_example {
+ p {
+ margin-bottom: 0;
+ }
+
+ .usa-list {
+ margin-top: units(0.5);
+ }
+}
+
+.review__step {
+ margin-top: units(3);
+}
+
+ .summary-item hr,
+.review__step hr {
+ border: none; //reset
+ border-top: 1px solid color('primary-dark');
+ margin-top: 0;
+ margin-bottom: units(0.5);
+}
+
+.review__step__title a:visited {
+ color: color('primary');
+}
+
+.review__step__name {
+ color: color('primary-dark');
+ font-weight: font-weight('semibold');
+ margin-bottom: units(0.5);
+}
diff --git a/src/registrar/assets/sass/_theme/_sidenav.scss b/src/registrar/assets/sass/_theme/_sidenav.scss
new file mode 100644
index 000000000..caafa7dd4
--- /dev/null
+++ b/src/registrar/assets/sass/_theme/_sidenav.scss
@@ -0,0 +1,30 @@
+@use "uswds-core" as *;
+
+.usa-sidenav {
+ .usa-sidenav__item {
+ span {
+ a.link_usa-checked {
+ padding: 0;
+ }
+ }
+ }
+}
+
+.sidenav__step--locked {
+ color: color('base-darker');
+ span {
+ display: flex;
+ align-items: flex-start;
+ padding: units(1);
+
+ .usa-icon {
+ flex-shrink: 0;
+ //align lock body to x-height
+ margin: units('2px') units(1) 0 0;
+ }
+ }
+}
+
+.stepnav {
+ margin-top: units(2);
+}
diff --git a/src/registrar/assets/sass/_theme/_tables.scss b/src/registrar/assets/sass/_theme/_tables.scss
new file mode 100644
index 000000000..6dcc6f3bc
--- /dev/null
+++ b/src/registrar/assets/sass/_theme/_tables.scss
@@ -0,0 +1,93 @@
+@use "uswds-core" as *;
+
+.dotgov-table--stacked {
+ td, th {
+ padding: units(1) units(2) units(2px) 0;
+ border: none;
+ }
+
+ tr:first-child th:first-child {
+ border-top: none;
+ }
+
+ tr {
+ border-bottom: none;
+ border-top: 2px solid color('base-light');
+ margin-top: units(2);
+
+ &:first-child {
+ margin-top: 0;
+ }
+ }
+
+ td[data-label]:before,
+ th[data-label]:before {
+ color: color('primary-darker');
+ padding-bottom: units(2px);
+ }
+}
+
+.dotgov-table {
+ width: 100%;
+
+ a {
+ display: flex;
+ align-items: flex-start;
+ color: color('primary');
+
+ &:visited {
+ color: color('primary');
+ }
+
+ .usa-icon {
+ // align icon with x height
+ margin-top: units(0.5);
+ margin-right: units(0.5);
+ }
+ }
+
+ th[data-sortable]:not([aria-sort]) .usa-table__header__button {
+ right: auto;
+ }
+
+ tbody th {
+ word-break: break-word;
+ }
+
+ @include at-media(mobile-lg) {
+
+ margin-top: units(1);
+
+ tr {
+ border: none;
+ }
+
+ td, th {
+ border-bottom: 1px solid color('base-light');
+ }
+
+ thead th {
+ color: color('primary-darker');
+ border-bottom: 2px solid color('base-light');
+ }
+
+ tbody tr:last-of-type {
+ td, th {
+ border-bottom: 0;
+ }
+ }
+
+ td, th,
+ .usa-tabel th{
+ padding: units(2) units(2) units(2) 0;
+ }
+
+ th:first-of-type {
+ padding-left: 0;
+ }
+
+ thead tr:first-child th:first-child {
+ border-top: none;
+ }
+ }
+}
diff --git a/src/registrar/assets/sass/_theme/_typography.scss b/src/registrar/assets/sass/_theme/_typography.scss
new file mode 100644
index 000000000..4fc2bb819
--- /dev/null
+++ b/src/registrar/assets/sass/_theme/_typography.scss
@@ -0,0 +1,24 @@
+@use "uswds-core" as *;
+
+// Finer grained letterspacing adjustments
+$letter-space--xs: .0125em;
+
+p,
+address,
+.usa-list li {
+ @include typeset('sans', 'sm', 5);
+ max-width: measure(5);
+}
+
+h1 {
+ @include typeset('sans', '2xl', 2);
+ margin: 0 0 units(2);
+ color: color('primary-darker');
+}
+
+h2 {
+ font-weight: font-weight('semibold');
+ line-height: line-height('heading', 3);
+ margin: units(4) 0 units(1);
+ color: color('primary-darker');
+}
diff --git a/src/registrar/assets/sass/_theme/_uswds-theme-custom-styles.scss b/src/registrar/assets/sass/_theme/_uswds-theme-custom-styles.scss
deleted file mode 100644
index e69b36bb8..000000000
--- a/src/registrar/assets/sass/_theme/_uswds-theme-custom-styles.scss
+++ /dev/null
@@ -1,457 +0,0 @@
-/*
-* * * * * ==============================
-* * * * * ==============================
-* * * * * ==============================
-* * * * * ==============================
-========================================
-========================================
-========================================
-----------------------------------------
-USWDS THEME CUSTOM STYLES
-----------------------------------------
-!! Copy this file to your project's
- sass root. Don't edit the version
- in node_modules.
-----------------------------------------
-Custom project SASS goes here.
-
-i.e.
-@include u-padding-right('05');
-----------------------------------------
-*/
-
-// Finer grained letterspacing adjustments
-$letter-space--xs: .0125em;
-
-@use "uswds-core" as *;
-
-/* Styles for making visible to screen reader / AT users only. */
-.sr-only {
- @include sr-only;
- }
-
- * {
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
-}
-
-body {
- display: flex;
- flex-direction: column;
- min-height: 100vh;
-}
-
-#wrapper {
- flex-grow: 1;
-}
-
-.usa-logo {
- @include at-media(desktop) {
- margin-top: units(2);
- }
-}
-
-.usa-logo__text {
- @include typeset('sans', 'xl', 2);
- color: color('primary-darker');
-}
-
-.usa-nav__primary {
- margin-top: units(1);
-}
-
-p,
-address,
-.usa-list li {
- @include typeset('sans', 'sm', 5);
- max-width: measure(5);
-}
-
-h1 {
- @include typeset('sans', '2xl', 2);
- margin: 0 0 units(2);
- color: color('primary-darker');
-}
-
-h2 {
- font-weight: font-weight('semibold');
- line-height: line-height('heading', 3);
- margin: units(4) 0 units(1);
- color: color('primary-darker');
-}
-
-.register-form-step > h1 {
- //align to top of sidebar on first page of the form
- margin-top: units(-1);
-}
-
- //Tighter spacing when H2 is immediatly after H1
-.register-form-step .usa-fieldset:first-of-type h2:first-of-type,
-.register-form-step h1 + h2 {
- margin-top: units(1);
-}
-
-.register-form-step h3 {
- color: color('primary-dark');
- letter-spacing: $letter-space--xs;
- margin-top: units(3);
- margin-bottom: 0;
-
- + p {
- margin-top: units(0.5);
- }
-}
-
-.register-form-step h4 {
- margin-bottom: 0;
-
- + p {
- margin-top: units(0.5);
- }
-}
-
-
-.register-form-step a {
- color: color('primary');
-
- &:visited {
- color: color('violet-70v'); //USWDS default
- }
-}
-.register-form-step .usa-form-group:first-of-type,
-.register-form-step .usa-label:first-of-type {
- margin-top: units(1);
-}
-
-/* Make "placeholder" links visually obvious */
-a[href$="todo"]::after {
- background-color: yellow;
- color: color(blue-80v);
- content: " [link TBD]";
- font-style: italic;
-}
-
-a.breadcrumb__back {
- display:flex;
- align-items: center;
- margin-bottom: units(2.5);
- &:visited {
- color: color('primary');
- }
-
- @include at-media('tablet') {
- //align to top of sidebar
- margin-top: units(-0.5);
- }
-}
-
-a.withdraw {
- background-color: color('error');
-}
-
-a.withdraw_outline,
-a.withdraw_outline:visited {
- box-shadow: inset 0 0 0 2px color('error');
- color: color('error');
-}
-
-a.withdraw_outline:hover,
-a.withdraw_outline:focus {
- box-shadow: inset 0 0 0 2px color('error-dark');
- color: color('error-dark');
-}
-
-a.withdraw_outline:active {
- box-shadow: inset 0 0 0 2px color('error-darker');
- color: color('error-darker');
-}
-a.withdraw:hover,
-a.withdraw:focus {
- background-color: color('error-dark');
-}
-
-a.withdraw:active {
- background-color: color('error-darker');
-}
-
-.usa-sidenav {
- .usa-sidenav__item {
- span {
- a.link_usa-checked {
- padding: 0;
- }
- }
- }
-}
-
-.sidenav__step--locked {
- color: color('base-darker');
- span {
- display: flex;
- align-items: flex-start;
- padding: units(1);
-
- .usa-icon {
- flex-shrink: 0;
- //align lock body to x-height
- margin: units('2px') units(1) 0 0;
- }
- }
-}
-
-
-.stepnav {
- margin-top: units(2);
-}
-
-.ao_example p {
- margin-top: units(1);
-}
-
-.domain_example {
- p {
- margin-bottom: 0;
- }
-
- .usa-list {
- margin-top: units(0.5);
- }
-}
-
-.review__step {
- margin-top: units(3);
-}
-
-.summary-item hr,
-.review__step hr {
- border: none; //reset
- border-top: 1px solid color('primary-dark');
- margin-top: 0;
- margin-bottom: units(0.5);
-}
-
-.review__step__title a:visited {
- color: color('primary');
-}
-
-.review__step__name {
- color: color('primary-dark');
- font-weight: font-weight('semibold');
- margin-bottom: units(0.5);
-}
-
-.usa-form .usa-button {
- margin-top: units(3);
-}
-
-.usa-button--unstyled .usa-icon {
- vertical-align: bottom;
-}
-
-a.usa-button--unstyled:visited {
- color: color('primary');
-}
-
-.dotgov-button--green {
- background-color: color('success-dark');
-
- &:hover {
- background-color: color('success-darker');
- }
-
- &:active {
- background-color: color('green-80v');
- }
-}
-
-/** ---- DASHBOARD ---- */
-
-#wrapper.dashboard {
- background-color: color('primary-lightest');
- padding-top: units(5);
-}
-
-.section--outlined {
- background-color: color('white');
- border: 1px solid color('base-lighter');
- border-radius: 4px;
- padding: 0 units(2) units(3);
- margin-top: units(3);
-
- h2 {
- color: color('primary-dark');
- margin-top: units(2);
- margin-bottom: units(2);
- }
-
- p {
- margin-bottom: 0;
- }
-
- @include at-media(mobile-lg) {
- margin-top: units(5);
-
- h2 {
- margin-bottom: 0;
- }
- }
-}
-
-.dotgov-table--stacked {
- td, th {
- padding: units(1) units(2) units(2px) 0;
- border: none;
- }
-
- tr:first-child th:first-child {
- border-top: none;
- }
-
- tr {
- border-bottom: none;
- border-top: 2px solid color('base-light');
- margin-top: units(2);
-
- &:first-child {
- margin-top: 0;
- }
- }
-
- td[data-label]:before,
- th[data-label]:before {
- color: color('primary-darker');
- padding-bottom: units(2px);
- }
-}
-
-.dotgov-table {
- width: 100%;
-
- a {
- display: flex;
- align-items: flex-start;
- color: color('primary');
-
- &:visited {
- color: color('primary');
- }
-
- .usa-icon {
- // align icon with x height
- margin-top: units(0.5);
- margin-right: units(0.5);
- }
- }
-
- th[data-sortable]:not([aria-sort]) .usa-table__header__button {
- right: auto;
- }
-
- tbody th {
- word-break: break-word;
- }
-
-
- @include at-media(mobile-lg) {
-
- margin-top: units(1);
-
- tr {
- border: none;
- }
-
- td, th {
- border-bottom: 1px solid color('base-light');
- }
-
- thead th {
- color: color('primary-darker');
- border-bottom: 2px solid color('base-light');
- }
-
- tbody tr:last-of-type {
- td, th {
- border-bottom: 0;
- }
- }
-
- td, th,
- .usa-tabel th{
- padding: units(2) units(2) units(2) 0;
- }
-
- th:first-of-type {
- padding-left: 0;
- }
-
- thead tr:first-child th:first-child {
- border-top: none;
- }
- }
-}
-
-.break-word {
- word-break: break-word;
-}
-
-.dotgov-status-box {
- background-color: color('primary-lightest');
- border-color: color('accent-cool-lighter');
-}
-
-.dotgov-status-box--action-need {
- background-color: color('warning-lighter');
- border-color: color('warning');
-}
-
-#wrapper {
- padding-top: units(3);
- padding-bottom: units(6) * 2 ; //Workaround because USWDS units jump from 10 to 15
-}
-
-
-footer {
- border-top: 1px solid color('primary-darker');
-}
-
-.usa-footer__secondary-section {
- background-color: color('primary-lightest');
-}
-
-.usa-footer__secondary-section a {
- color: color('primary');
-}
-
-.usa-identifier__logo {
- height: units(7);
-}
-
-abbr[title] {
- // workaround for underlining abbr element
- border-bottom: none;
- text-decoration: none;
-}
-
-.usa-textarea {
- @include at-media('tablet') {
- height: units('mobile');
- }
-}
-
-// Fixes some font size disparities with the Figma
-// for usa-alert alert elements
-.usa-alert {
- .usa-alert__heading.larger-font-sizing {
- font-size: units(3);
- }
-}
-
-// The icon was off center for some reason
-// Fixes that issue
-@media (min-width: 64em){
- .usa-alert--warning{
- .usa-alert__body::before {
- left: 1rem !important;
- }
- }
-}
diff --git a/src/registrar/assets/sass/_theme/styles.scss b/src/registrar/assets/sass/_theme/styles.scss
index 27d844760..8a2e1d2d3 100644
--- a/src/registrar/assets/sass/_theme/styles.scss
+++ b/src/registrar/assets/sass/_theme/styles.scss
@@ -8,7 +8,15 @@
/*--------------------------------------------------
--- Custom Styles ---------------------------------*/
-@forward "uswds-theme-custom-styles";
+@forward "base";
+@forward "typography";
+@forward "buttons";
+@forward "forms";
+@forward "fieldsets";
+@forward "alerts";
+@forward "tables";
+@forward "sidenav";
+@forward "register-form";
/*--------------------------------------------------
--- Admin ---------------------------------*/
diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py
index ceb215a4d..7b96af5ee 100644
--- a/src/registrar/config/settings.py
+++ b/src/registrar/config/settings.py
@@ -652,6 +652,9 @@ SESSION_COOKIE_SAMESITE = "Lax"
# instruct browser to only send cookie via HTTPS
SESSION_COOKIE_SECURE = True
+# session engine to cache session information
+SESSION_ENGINE = "django.contrib.sessions.backends.cache"
+
# ~ Set by django.middleware.clickjacking.XFrameOptionsMiddleware
# prevent clickjacking by instructing the browser not to load
# our site within an iframe
diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py
index 9c3624c2c..bd2215620 100644
--- a/src/registrar/config/urls.py
+++ b/src/registrar/config/urls.py
@@ -81,9 +81,29 @@ urlpatterns = [
path("domain/", views.DomainView.as_view(), name="domain"),
path("domain//users", views.DomainUsersView.as_view(), name="domain-users"),
path(
- "domain//nameservers",
+ "domain//dns",
+ views.DomainDNSView.as_view(),
+ name="domain-dns",
+ ),
+ path(
+ "domain//dns/nameservers",
views.DomainNameserversView.as_view(),
- name="domain-nameservers",
+ name="domain-dns-nameservers",
+ ),
+ path(
+ "domain//dns/dnssec",
+ views.DomainDNSSECView.as_view(),
+ name="domain-dns-dnssec",
+ ),
+ path(
+ "domain//dns/dnssec/dsdata",
+ views.DomainDsDataView.as_view(),
+ name="domain-dns-dnssec-dsdata",
+ ),
+ path(
+ "domain//dns/dnssec/keydata",
+ views.DomainKeyDataView.as_view(),
+ name="domain-dns-dnssec-keydata",
),
path(
"domain//your-contact-information",
diff --git a/src/registrar/fixtures.py b/src/registrar/fixtures_applications.py
similarity index 51%
rename from src/registrar/fixtures.py
rename to src/registrar/fixtures_applications.py
index e1db054b1..18be79814 100644
--- a/src/registrar/fixtures.py
+++ b/src/registrar/fixtures_applications.py
@@ -10,255 +10,10 @@ from registrar.models import (
Website,
)
-from django.contrib.auth.models import Permission
-from django.contrib.contenttypes.models import ContentType
-
fake = Faker()
logger = logging.getLogger(__name__)
-class UserFixture:
- """
- Load users into the database.
-
- Make sure this class' `load` method is called from `handle`
- in management/commands/load.py, then use `./manage.py load`
- to run this code.
- """
-
- ADMINS = [
- {
- "username": "5f283494-31bd-49b5-b024-a7e7cae00848",
- "first_name": "Rachid",
- "last_name": "Mrad",
- },
- {
- "username": "eb2214cd-fc0c-48c0-9dbd-bc4cd6820c74",
- "first_name": "Alysia",
- "last_name": "Broddrick",
- },
- {
- "username": "8f8e7293-17f7-4716-889b-1990241cbd39",
- "first_name": "Katherine",
- "last_name": "Osos",
- },
- {
- "username": "70488e0a-e937-4894-a28c-16f5949effd4",
- "first_name": "Gaby",
- "last_name": "DiSarli",
- },
- {
- "username": "83c2b6dd-20a2-4cac-bb40-e22a72d2955c",
- "first_name": "Cameron",
- "last_name": "Dixon",
- },
- {
- "username": "0353607a-cbba-47d2-98d7-e83dcd5b90ea",
- "first_name": "Ryan",
- "last_name": "Brooks",
- },
- {
- "username": "30001ee7-0467-4df2-8db2-786e79606060",
- "first_name": "Zander",
- "last_name": "Adkinson",
- },
- {
- "username": "2bf518c2-485a-4c42-ab1a-f5a8b0a08484",
- "first_name": "Paul",
- "last_name": "Kuykendall",
- },
- {
- "username": "2a88a97b-be96-4aad-b99e-0b605b492c78",
- "first_name": "Rebecca",
- "last_name": "Hsieh",
- },
- {
- "username": "fa69c8e8-da83-4798-a4f2-263c9ce93f52",
- "first_name": "David",
- "last_name": "Kennedy",
- },
- {
- "username": "f14433d8-f0e9-41bf-9c72-b99b110e665d",
- "first_name": "Nicolle",
- "last_name": "LeClair",
- },
- {
- "username": "24840450-bf47-4d89-8aa9-c612fe68f9da",
- "first_name": "Erin",
- "last_name": "Song",
- },
- {
- "username": "e0ea8b94-6e53-4430-814a-849a7ca45f21",
- "first_name": "Kristina",
- "last_name": "Yin",
- },
- ]
-
- STAFF = [
- {
- "username": "319c490d-453b-43d9-bc4d-7d6cd8ff6844",
- "first_name": "Rachid-Analyst",
- "last_name": "Mrad-Analyst",
- "email": "rachid.mrad@gmail.com",
- },
- {
- "username": "b6a15987-5c88-4e26-8de2-ca71a0bdb2cd",
- "first_name": "Alysia-Analyst",
- "last_name": "Alysia-Analyst",
- },
- {
- "username": "91a9b97c-bd0a-458d-9823-babfde7ebf44",
- "first_name": "Katherine-Analyst",
- "last_name": "Osos-Analyst",
- "email": "kosos@truss.works",
- },
- {
- "username": "2cc0cde8-8313-4a50-99d8-5882e71443e8",
- "first_name": "Zander-Analyst",
- "last_name": "Adkinson-Analyst",
- },
- {
- "username": "57ab5847-7789-49fe-a2f9-21d38076d699",
- "first_name": "Paul-Analyst",
- "last_name": "Kuykendall-Analyst",
- },
- {
- "username": "e474e7a9-71ca-449d-833c-8a6e094dd117",
- "first_name": "Rebecca-Analyst",
- "last_name": "Hsieh-Analyst",
- },
- {
- "username": "5dc6c9a6-61d9-42b4-ba54-4beff28bac3c",
- "first_name": "David-Analyst",
- "last_name": "Kennedy-Analyst",
- },
- {
- "username": "0eb6f326-a3d4-410f-a521-aa4c1fad4e47",
- "first_name": "Gaby-Analyst",
- "last_name": "DiSarli-Analyst",
- "email": "gaby@truss.works",
- },
- {
- "username": "cfe7c2fc-e24a-480e-8b78-28645a1459b3",
- "first_name": "Nicolle-Analyst",
- "last_name": "LeClair-Analyst",
- "email": "nicolle.leclair@ecstech.com",
- },
- {
- "username": "378d0bc4-d5a7-461b-bd84-3ae6f6864af9",
- "first_name": "Erin-Analyst",
- "last_name": "Song-Analyst",
- "email": "erin.song+1@gsa.gov",
- },
- {
- "username": "9a98e4c9-9409-479d-964e-4aec7799107f",
- "first_name": "Kristina-Analyst",
- "last_name": "Yin-Analyst",
- "email": "kristina.yin+1@gsa.gov",
- },
- ]
-
- STAFF_PERMISSIONS = [
- {
- "app_label": "auditlog",
- "model": "logentry",
- "permissions": ["view_logentry"],
- },
- {"app_label": "registrar", "model": "contact", "permissions": ["view_contact"]},
- {
- "app_label": "registrar",
- "model": "domaininformation",
- "permissions": ["change_domaininformation"],
- },
- {
- "app_label": "registrar",
- "model": "domainapplication",
- "permissions": ["change_domainapplication"],
- },
- {"app_label": "registrar", "model": "domain", "permissions": ["view_domain"]},
- {
- "app_label": "registrar",
- "model": "draftdomain",
- "permissions": ["change_draftdomain"],
- },
- {"app_label": "registrar", "model": "user", "permissions": ["change_user"]},
- ]
-
- @classmethod
- def load(cls):
- logger.info("Going to load %s superusers" % str(len(cls.ADMINS)))
- for admin in cls.ADMINS:
- try:
- user, _ = User.objects.get_or_create(
- username=admin["username"],
- )
- user.is_superuser = True
- user.first_name = admin["first_name"]
- user.last_name = admin["last_name"]
- if "email" in admin.keys():
- user.email = admin["email"]
- user.is_staff = True
- user.is_active = True
- user.save()
- logger.debug("User object created for %s" % admin["first_name"])
- except Exception as e:
- logger.warning(e)
- logger.info("All superusers loaded.")
-
- logger.info("Going to load %s CISA analysts (staff)" % str(len(cls.STAFF)))
- for staff in cls.STAFF:
- try:
- user, _ = User.objects.get_or_create(
- username=staff["username"],
- )
- user.is_superuser = False
- user.first_name = staff["first_name"]
- user.last_name = staff["last_name"]
- if "email" in admin.keys():
- user.email = admin["email"]
- user.is_staff = True
- user.is_active = True
-
- for permission in cls.STAFF_PERMISSIONS:
- app_label = permission["app_label"]
- model_name = permission["model"]
- permissions = permission["permissions"]
-
- # Retrieve the content type for the app and model
- content_type = ContentType.objects.get(
- app_label=app_label, model=model_name
- )
-
- # Retrieve the permissions based on their codenames
- permissions = Permission.objects.filter(
- content_type=content_type, codename__in=permissions
- )
-
- # Assign the permissions to the user
- user.user_permissions.add(*permissions)
-
- # Convert the permissions QuerySet to a list of codenames
- permission_list = list(
- permissions.values_list("codename", flat=True)
- )
-
- logger.debug(
- app_label
- + " | "
- + model_name
- + " | "
- + ", ".join(permission_list)
- + " added for user "
- + staff["first_name"]
- )
-
- user.save()
- logger.debug("User object created for %s" % staff["first_name"])
- except Exception as e:
- logger.warning(e)
- logger.info("All CISA analysts (staff) loaded.")
-
-
class DomainApplicationFixture:
"""
Load domain applications into the database.
diff --git a/src/registrar/fixtures_users.py b/src/registrar/fixtures_users.py
new file mode 100644
index 000000000..dfe51785b
--- /dev/null
+++ b/src/registrar/fixtures_users.py
@@ -0,0 +1,178 @@
+import logging
+from faker import Faker
+
+from registrar.models import (
+ User,
+ UserGroup,
+)
+
+fake = Faker()
+logger = logging.getLogger(__name__)
+
+
+class UserFixture:
+ """
+ Load users into the database.
+
+ Make sure this class' `load` method is called from `handle`
+ in management/commands/load.py, then use `./manage.py load`
+ to run this code.
+ """
+
+ ADMINS = [
+ {
+ "username": "5f283494-31bd-49b5-b024-a7e7cae00848",
+ "first_name": "Rachid",
+ "last_name": "Mrad",
+ },
+ {
+ "username": "eb2214cd-fc0c-48c0-9dbd-bc4cd6820c74",
+ "first_name": "Alysia",
+ "last_name": "Broddrick",
+ },
+ {
+ "username": "8f8e7293-17f7-4716-889b-1990241cbd39",
+ "first_name": "Katherine",
+ "last_name": "Osos",
+ },
+ {
+ "username": "70488e0a-e937-4894-a28c-16f5949effd4",
+ "first_name": "Gaby",
+ "last_name": "DiSarli",
+ "email": "gaby@truss.works",
+ },
+ {
+ "username": "83c2b6dd-20a2-4cac-bb40-e22a72d2955c",
+ "first_name": "Cameron",
+ "last_name": "Dixon",
+ },
+ {
+ "username": "0353607a-cbba-47d2-98d7-e83dcd5b90ea",
+ "first_name": "Ryan",
+ "last_name": "Brooks",
+ },
+ {
+ "username": "30001ee7-0467-4df2-8db2-786e79606060",
+ "first_name": "Zander",
+ "last_name": "Adkinson",
+ },
+ {
+ "username": "2bf518c2-485a-4c42-ab1a-f5a8b0a08484",
+ "first_name": "Paul",
+ "last_name": "Kuykendall",
+ },
+ {
+ "username": "2a88a97b-be96-4aad-b99e-0b605b492c78",
+ "first_name": "Rebecca",
+ "last_name": "Hsieh",
+ },
+ {
+ "username": "fa69c8e8-da83-4798-a4f2-263c9ce93f52",
+ "first_name": "David",
+ "last_name": "Kennedy",
+ },
+ {
+ "username": "f14433d8-f0e9-41bf-9c72-b99b110e665d",
+ "first_name": "Nicolle",
+ "last_name": "LeClair",
+ },
+ {
+ "username": "24840450-bf47-4d89-8aa9-c612fe68f9da",
+ "first_name": "Erin",
+ "last_name": "Song",
+ },
+ {
+ "username": "e0ea8b94-6e53-4430-814a-849a7ca45f21",
+ "first_name": "Kristina",
+ "last_name": "Yin",
+ },
+ ]
+
+ STAFF = [
+ {
+ "username": "319c490d-453b-43d9-bc4d-7d6cd8ff6844",
+ "first_name": "Rachid-Analyst",
+ "last_name": "Mrad-Analyst",
+ "email": "rachid.mrad@gmail.com",
+ },
+ {
+ "username": "b6a15987-5c88-4e26-8de2-ca71a0bdb2cd",
+ "first_name": "Alysia-Analyst",
+ "last_name": "Alysia-Analyst",
+ },
+ {
+ "username": "91a9b97c-bd0a-458d-9823-babfde7ebf44",
+ "first_name": "Katherine-Analyst",
+ "last_name": "Osos-Analyst",
+ "email": "kosos@truss.works",
+ },
+ {
+ "username": "2cc0cde8-8313-4a50-99d8-5882e71443e8",
+ "first_name": "Zander-Analyst",
+ "last_name": "Adkinson-Analyst",
+ },
+ {
+ "username": "57ab5847-7789-49fe-a2f9-21d38076d699",
+ "first_name": "Paul-Analyst",
+ "last_name": "Kuykendall-Analyst",
+ },
+ {
+ "username": "e474e7a9-71ca-449d-833c-8a6e094dd117",
+ "first_name": "Rebecca-Analyst",
+ "last_name": "Hsieh-Analyst",
+ },
+ {
+ "username": "5dc6c9a6-61d9-42b4-ba54-4beff28bac3c",
+ "first_name": "David-Analyst",
+ "last_name": "Kennedy-Analyst",
+ },
+ {
+ "username": "0eb6f326-a3d4-410f-a521-aa4c1fad4e47",
+ "first_name": "Gaby-Analyst",
+ "last_name": "DiSarli-Analyst",
+ "email": "gaby+1@truss.works",
+ },
+ {
+ "username": "cfe7c2fc-e24a-480e-8b78-28645a1459b3",
+ "first_name": "Nicolle-Analyst",
+ "last_name": "LeClair-Analyst",
+ "email": "nicolle.leclair@ecstech.com",
+ },
+ {
+ "username": "378d0bc4-d5a7-461b-bd84-3ae6f6864af9",
+ "first_name": "Erin-Analyst",
+ "last_name": "Song-Analyst",
+ "email": "erin.song+1@gsa.gov",
+ },
+ {
+ "username": "9a98e4c9-9409-479d-964e-4aec7799107f",
+ "first_name": "Kristina-Analyst",
+ "last_name": "Yin-Analyst",
+ "email": "kristina.yin+1@gsa.gov",
+ },
+ ]
+
+ def load_users(cls, users, group_name):
+ logger.info(f"Going to load {len(users)} users in group {group_name}")
+ for user_data in users:
+ try:
+ user, _ = User.objects.get_or_create(username=user_data["username"])
+ user.is_superuser = False
+ user.first_name = user_data["first_name"]
+ user.last_name = user_data["last_name"]
+ if "email" in user_data:
+ user.email = user_data["email"]
+ user.is_staff = True
+ user.is_active = True
+ group = UserGroup.objects.get(name=group_name)
+ user.groups.add(group)
+ user.save()
+ logger.debug(f"User object created for {user_data['first_name']}")
+ except Exception as e:
+ logger.warning(e)
+ logger.info(f"All users in group {group_name} loaded.")
+
+ @classmethod
+ def load(cls):
+ cls.load_users(cls, cls.ADMINS, "full_access_group")
+ cls.load_users(cls, cls.STAFF, "cisa_analysts_group")
diff --git a/src/registrar/forms/__init__.py b/src/registrar/forms/__init__.py
index 13f75563f..7d2baf646 100644
--- a/src/registrar/forms/__init__.py
+++ b/src/registrar/forms/__init__.py
@@ -5,4 +5,9 @@ from .domain import (
DomainSecurityEmailForm,
DomainOrgNameAddressForm,
ContactForm,
+ DomainDnssecForm,
+ DomainDsdataFormset,
+ DomainDsdataForm,
+ DomainKeydataFormset,
+ DomainKeydataForm,
)
diff --git a/src/registrar/forms/common.py b/src/registrar/forms/common.py
new file mode 100644
index 000000000..159113488
--- /dev/null
+++ b/src/registrar/forms/common.py
@@ -0,0 +1,38 @@
+# common.py
+#
+# ALGORITHM_CHOICES are options for alg attribute in DS Data and Key Data
+# reference:
+# https://www.iana.org/assignments/dns-sec-alg-numbers/dns-sec-alg-numbers.xhtml
+ALGORITHM_CHOICES = [
+ (1, "(1) ERSA/MD5 [RSAMD5]"),
+ (2, "(2) Diffie-Hellman [DH]"),
+ (3, "(3) DSA/SHA-1 [DSA]"),
+ (5, "(5) RSA/SHA-1 [RSASHA1]"),
+ (6, "(6) DSA-NSEC3-SHA1"),
+ (7, "(7) RSASHA1-NSEC3-SHA1"),
+ (8, "(8) RSA/SHA-256 [RSASHA256]"),
+ (10, "(10) RSA/SHA-512 [RSASHA512]"),
+ (12, "(12) GOST R 34.10-2001 [ECC-GOST]"),
+ (13, "(13) ECDSA Curve P-256 with SHA-256 [ECDSAP256SHA256]"),
+ (14, "(14) ECDSA Curve P-384 with SHA-384 [ECDSAP384SHA384]"),
+ (15, "(15) Ed25519"),
+ (16, "(16) Ed448"),
+]
+# DIGEST_TYPE_CHOICES are options for digestType attribute in DS Data
+# reference: https://datatracker.ietf.org/doc/html/rfc4034#appendix-A.2
+DIGEST_TYPE_CHOICES = [
+ (0, "(0) Reserved"),
+ (1, "(1) SHA-256"),
+]
+# PROTOCOL_CHOICES are options for protocol attribute in Key Data
+# reference: https://datatracker.ietf.org/doc/html/rfc4034#section-2.1.2
+PROTOCOL_CHOICES = [
+ (3, "(3) DNSSEC"),
+]
+# FLAG_CHOICES are options for flags attribute in Key Data
+# reference: https://datatracker.ietf.org/doc/html/rfc4034#section-2.1.1
+FLAG_CHOICES = [
+ (0, "(0)"),
+ (256, "(256) ZSK"),
+ (257, "(257) KSK"),
+]
diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py
index f14448bcf..8abc7e14a 100644
--- a/src/registrar/forms/domain.py
+++ b/src/registrar/forms/domain.py
@@ -1,26 +1,31 @@
"""Forms for domain management."""
from django import forms
-from django.core.validators import RegexValidator
+from django.core.validators import MinValueValidator, MaxValueValidator, RegexValidator
from django.forms import formset_factory
from phonenumber_field.widgets import RegionalPhoneNumberWidget
from ..models import Contact, DomainInformation
+from .common import (
+ ALGORITHM_CHOICES,
+ DIGEST_TYPE_CHOICES,
+ FLAG_CHOICES,
+ PROTOCOL_CHOICES,
+)
class DomainAddUserForm(forms.Form):
-
"""Form for adding a user to a domain."""
email = forms.EmailField(label="Email")
class DomainNameserverForm(forms.Form):
-
"""Form for changing nameservers."""
- server = forms.CharField(label="Name server")
+ server = forms.CharField(label="Name server", strip=True)
+ # when adding IPs to this form ensure they are stripped as well
NameserverFormset = formset_factory(
@@ -30,7 +35,6 @@ NameserverFormset = formset_factory(
class ContactForm(forms.ModelForm):
-
"""Form for updating contacts."""
class Meta:
@@ -61,14 +65,12 @@ class ContactForm(forms.ModelForm):
class DomainSecurityEmailForm(forms.Form):
-
"""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", required=False)
class DomainOrgNameAddressForm(forms.ModelForm):
-
"""Form for updating the organization name and mailing address."""
zipcode = forms.CharField(
@@ -139,3 +141,91 @@ class DomainOrgNameAddressForm(forms.ModelForm):
self.fields[field_name].required = True
self.fields["state_territory"].widget.attrs.pop("maxlength", None)
self.fields["zipcode"].widget.attrs.pop("maxlength", None)
+
+
+class DomainDnssecForm(forms.Form):
+ """Form for enabling and disabling dnssec"""
+
+
+class DomainDsdataForm(forms.Form):
+ """Form for adding or editing DNSSEC DS Data to a domain."""
+
+ key_tag = forms.IntegerField(
+ required=True,
+ label="Key tag",
+ validators=[
+ MinValueValidator(0, message="Value must be between 0 and 65535"),
+ MaxValueValidator(65535, message="Value must be between 0 and 65535"),
+ ],
+ error_messages={"required": ("Key tag is required.")},
+ )
+
+ algorithm = forms.TypedChoiceField(
+ required=True,
+ label="Algorithm",
+ coerce=int, # need to coerce into int so dsData objects can be compared
+ choices=[(None, "--Select--")] + ALGORITHM_CHOICES, # type: ignore
+ error_messages={"required": ("Algorithm is required.")},
+ )
+
+ digest_type = forms.TypedChoiceField(
+ required=True,
+ label="Digest type",
+ coerce=int, # need to coerce into int so dsData objects can be compared
+ choices=[(None, "--Select--")] + DIGEST_TYPE_CHOICES, # type: ignore
+ error_messages={"required": ("Digest Type is required.")},
+ )
+
+ digest = forms.CharField(
+ required=True,
+ label="Digest",
+ error_messages={"required": ("Digest is required.")},
+ )
+
+
+DomainDsdataFormset = formset_factory(
+ DomainDsdataForm,
+ extra=0,
+ can_delete=True,
+)
+
+
+class DomainKeydataForm(forms.Form):
+ """Form for adding or editing DNSSEC Key Data to a domain."""
+
+ flag = forms.TypedChoiceField(
+ required=True,
+ label="Flag",
+ coerce=int,
+ choices=FLAG_CHOICES,
+ error_messages={"required": ("Flag is required.")},
+ )
+
+ protocol = forms.TypedChoiceField(
+ required=True,
+ label="Protocol",
+ coerce=int,
+ choices=PROTOCOL_CHOICES,
+ error_messages={"required": ("Protocol is required.")},
+ )
+
+ algorithm = forms.TypedChoiceField(
+ required=True,
+ label="Algorithm",
+ coerce=int,
+ choices=[(None, "--Select--")] + ALGORITHM_CHOICES, # type: ignore
+ error_messages={"required": ("Algorithm is required.")},
+ )
+
+ pub_key = forms.CharField(
+ required=True,
+ label="Pub key",
+ error_messages={"required": ("Pub key is required.")},
+ )
+
+
+DomainKeydataFormset = formset_factory(
+ DomainKeydataForm,
+ extra=0,
+ can_delete=True,
+)
diff --git a/src/registrar/management/commands/load.py b/src/registrar/management/commands/load.py
index 589d37260..757d1a6e9 100644
--- a/src/registrar/management/commands/load.py
+++ b/src/registrar/management/commands/load.py
@@ -4,7 +4,8 @@ from django.core.management.base import BaseCommand
from auditlog.context import disable_auditlog # type: ignore
-from registrar.fixtures import UserFixture, DomainApplicationFixture, DomainFixture
+from registrar.fixtures_users import UserFixture
+from registrar.fixtures_applications import DomainApplicationFixture, DomainFixture
logger = logging.getLogger(__name__)
diff --git a/src/registrar/management/commands/load_transition_domain.py b/src/registrar/management/commands/load_transition_domain.py
new file mode 100644
index 000000000..206589c33
--- /dev/null
+++ b/src/registrar/management/commands/load_transition_domain.py
@@ -0,0 +1,524 @@
+import sys
+import csv
+import logging
+import argparse
+
+from collections import defaultdict
+
+from django.core.management import BaseCommand
+
+from registrar.models import TransitionDomain
+
+logger = logging.getLogger(__name__)
+
+
+class termColors:
+ """Colors for terminal outputs
+ (makes reading the logs WAY easier)"""
+
+ HEADER = "\033[95m"
+ OKBLUE = "\033[94m"
+ OKCYAN = "\033[96m"
+ OKGREEN = "\033[92m"
+ YELLOW = "\033[93m"
+ FAIL = "\033[91m"
+ ENDC = "\033[0m"
+ BOLD = "\033[1m"
+ UNDERLINE = "\033[4m"
+ BackgroundLightYellow = "\033[103m"
+
+
+def query_yes_no(question: str, default="yes") -> bool:
+ """Ask a yes/no question via raw_input() and return their answer.
+
+ "question" is a string that is presented to the user.
+ "default" is the presumed answer if the user just hits .
+ It must be "yes" (the default), "no" or None (meaning
+ an answer is required of the user).
+
+ The "answer" return value is True for "yes" or False for "no".
+ """
+ valid = {"yes": True, "y": True, "ye": True, "no": False, "n": False}
+ if default is None:
+ prompt = " [y/n] "
+ elif default == "yes":
+ prompt = " [Y/n] "
+ elif default == "no":
+ prompt = " [y/N] "
+ else:
+ raise ValueError("invalid default answer: '%s'" % default)
+
+ while True:
+ logger.info(question + prompt)
+ choice = input().lower()
+ if default is not None and choice == "":
+ return valid[default]
+ elif choice in valid:
+ return valid[choice]
+ else:
+ logger.info("Please respond with 'yes' or 'no' " "(or 'y' or 'n').\n")
+
+
+class Command(BaseCommand):
+ help = """Loads data for domains that are in transition
+ (populates transition_domain model objects)."""
+
+ def add_arguments(self, parser):
+ """Add our three filename arguments (in order: domain contacts,
+ contacts, and domain statuses)
+ OPTIONAL ARGUMENTS:
+ --sep
+ The default delimiter is set to "|", but may be changed using --sep
+ --debug
+ A boolean (default to true), which activates additional print statements
+ --limitParse
+ Used to set a limit for the number of data entries to insert. Set to 0
+ (or just don't use this argument) to parse every entry.
+ --resetTable
+ Use this to trigger a prompt for deleting all table entries. Useful
+ for testing purposes, but USE WITH CAUTION
+ """
+ parser.add_argument(
+ "domain_contacts_filename", help="Data file with domain contact information"
+ )
+ parser.add_argument(
+ "contacts_filename",
+ help="Data file with contact information",
+ )
+ parser.add_argument(
+ "domain_statuses_filename", help="Data file with domain status information"
+ )
+
+ parser.add_argument("--sep", default="|", help="Delimiter character")
+
+ parser.add_argument("--debug", action=argparse.BooleanOptionalAction)
+
+ parser.add_argument(
+ "--limitParse", default=0, help="Sets max number of entries to load"
+ )
+
+ parser.add_argument(
+ "--resetTable",
+ help="Deletes all data in the TransitionDomain table",
+ action=argparse.BooleanOptionalAction,
+ )
+
+ def print_debug_mode_statements(
+ self, debug_on: bool, debug_max_entries_to_parse: int
+ ):
+ """Prints additional terminal statements to indicate if --debug
+ or --limitParse are in use"""
+ if debug_on:
+ logger.info(
+ f"""{termColors.OKCYAN}
+ ----------DEBUG MODE ON----------
+ Detailed print statements activated.
+ {termColors.ENDC}
+ """
+ )
+ if debug_max_entries_to_parse > 0:
+ logger.info(
+ f"""{termColors.OKCYAN}
+ ----------LIMITER ON----------
+ Parsing of entries will be limited to
+ {debug_max_entries_to_parse} lines per file.")
+ Detailed print statements activated.
+ {termColors.ENDC}
+ """
+ )
+
+ def get_domain_user_dict(
+ self, domain_statuses_filename: str, sep: str
+ ) -> defaultdict[str, str]:
+ """Creates a mapping of domain name -> status"""
+ domain_status_dictionary = defaultdict(str)
+ logger.info("Reading domain statuses data file %s", domain_statuses_filename)
+ with open(domain_statuses_filename, "r") as domain_statuses_file: # noqa
+ for row in csv.reader(domain_statuses_file, delimiter=sep):
+ domainName = row[0].lower()
+ domainStatus = row[1].lower()
+ domain_status_dictionary[domainName] = domainStatus
+ logger.info("Loaded statuses for %d domains", len(domain_status_dictionary))
+ return domain_status_dictionary
+
+ def get_user_emails_dict(
+ self, contacts_filename: str, sep
+ ) -> defaultdict[str, str]:
+ """Creates mapping of userId -> emails"""
+ user_emails_dictionary = defaultdict(str)
+ logger.info("Reading domain-contacts data file %s", contacts_filename)
+ with open(contacts_filename, "r") as contacts_file:
+ for row in csv.reader(contacts_file, delimiter=sep):
+ user_id = row[0]
+ user_email = row[6]
+ user_emails_dictionary[user_id] = user_email
+ logger.info("Loaded emails for %d users", len(user_emails_dictionary))
+ return user_emails_dictionary
+
+ def get_mapped_status(self, status_to_map: str):
+ """
+ Given a verisign domain status, return a corresponding
+ status defined for our domains.
+
+ We map statuses as follows;
+ "serverHold” fields will map to hold, clientHold to hold
+ and any ok state should map to Ready.
+ """
+ status_maps = {
+ "hold": TransitionDomain.StatusChoices.ON_HOLD,
+ "serverhold": TransitionDomain.StatusChoices.ON_HOLD,
+ "clienthold": TransitionDomain.StatusChoices.ON_HOLD,
+ "created": TransitionDomain.StatusChoices.READY,
+ "ok": TransitionDomain.StatusChoices.READY,
+ }
+ mapped_status = status_maps.get(status_to_map)
+ return mapped_status
+
+ def print_summary_duplications(
+ self,
+ duplicate_domain_user_combos: list[TransitionDomain],
+ duplicate_domains: list[TransitionDomain],
+ users_without_email: list[str],
+ ):
+ """Called at the end of the script execution to print out a summary of
+ data anomalies in the imported Verisign data. Currently, we check for:
+ - duplicate domains
+ - duplicate domain - user pairs
+ - any users without e-mails (this would likely only happen if the contacts
+ file is missing a user found in the domain_contacts file)
+ """
+ total_duplicate_pairs = len(duplicate_domain_user_combos)
+ total_duplicate_domains = len(duplicate_domains)
+ total_users_without_email = len(users_without_email)
+ if total_users_without_email > 0:
+ users_without_email_as_string = "{}".format(
+ ", ".join(map(str, duplicate_domain_user_combos))
+ )
+ logger.warning(
+ f"{termColors.YELLOW} No e-mails found for users: {users_without_email_as_string}" # noqa
+ )
+ if total_duplicate_pairs > 0 or total_duplicate_domains > 0:
+ duplicate_pairs_as_string = "{}".format(
+ ", ".join(map(str, duplicate_domain_user_combos))
+ )
+ duplicate_domains_as_string = "{}".format(
+ ", ".join(map(str, duplicate_domains))
+ )
+ logger.warning(
+ f"""{termColors.YELLOW}
+
+ ----DUPLICATES FOUND-----
+
+ {total_duplicate_pairs} DOMAIN - USER pairs
+ were NOT unique in the supplied data files;
+
+ {duplicate_pairs_as_string}
+
+ {total_duplicate_domains} DOMAINS were NOT unique in
+ the supplied data files;
+
+ {duplicate_domains_as_string}
+ {termColors.ENDC}"""
+ )
+
+ def print_summary_status_findings(
+ self, domains_without_status: list[str], outlier_statuses: list[str]
+ ):
+ """Called at the end of the script execution to print out a summary of
+ status anomolies in the imported Verisign data. Currently, we check for:
+ - domains without a status
+ - any statuses not accounted for in our status mappings (see
+ get_mapped_status() function)
+ """
+ total_domains_without_status = len(domains_without_status)
+ total_outlier_statuses = len(outlier_statuses)
+ if total_domains_without_status > 0:
+ domains_without_status_as_string = "{}".format(
+ ", ".join(map(str, domains_without_status))
+ )
+ logger.warning(
+ f"""{termColors.YELLOW}
+
+ --------------------------------------------
+ Found {total_domains_without_status} domains
+ without a status (defaulted to READY)
+ ---------------------------------------------
+
+ {domains_without_status_as_string}
+ {termColors.ENDC}"""
+ )
+
+ if total_outlier_statuses > 0:
+ domains_without_status_as_string = "{}".format(
+ ", ".join(map(str, outlier_statuses))
+ ) # noqa
+ logger.warning(
+ f"""{termColors.YELLOW}
+
+ --------------------------------------------
+ Found {total_outlier_statuses} unaccounted
+ for statuses-
+ --------------------------------------------
+
+ No mappings found for the following statuses
+ (defaulted to Ready):
+
+ {domains_without_status_as_string}
+ {termColors.ENDC}"""
+ )
+
+ def print_debug(self, print_condition: bool, print_statement: str):
+ """This function reduces complexity of debug statements
+ in other functions.
+ It uses the logger to write the given print_statement to the
+ terminal if print_condition is TRUE"""
+ # DEBUG:
+ if print_condition:
+ logger.info(print_statement)
+
+ def prompt_table_reset(self):
+ """Brings up a prompt in the terminal asking
+ if the user wishes to delete data in the
+ TransitionDomain table. If the user confirms,
+ deletes all the data in the TransitionDomain table"""
+ confirm_reset = query_yes_no(
+ f"""
+ {termColors.FAIL}
+ WARNING: Resetting the table will permanently delete all
+ the data!
+ Are you sure you want to continue?{termColors.ENDC}"""
+ )
+ if confirm_reset:
+ logger.info(
+ f"""{termColors.YELLOW}
+ ----------Clearing Table Data----------
+ (please wait)
+ {termColors.ENDC}"""
+ )
+ TransitionDomain.objects.all().delete()
+
+ def handle( # noqa: C901
+ self,
+ domain_contacts_filename,
+ contacts_filename,
+ domain_statuses_filename,
+ **options,
+ ):
+ """Parse the data files and create TransitionDomains."""
+ sep = options.get("sep")
+
+ # If --resetTable was used, prompt user to confirm
+ # deletion of table data
+ if options.get("resetTable"):
+ self.prompt_table_reset()
+
+ # Get --debug argument
+ debug_on = options.get("debug")
+
+ # Get --LimitParse argument
+ debug_max_entries_to_parse = int(
+ options.get("limitParse")
+ ) # set to 0 to parse all entries
+
+ # print message to terminal about which args are in use
+ self.print_debug_mode_statements(debug_on, debug_max_entries_to_parse)
+
+ # STEP 1:
+ # Create mapping of domain name -> status
+ domain_status_dictionary = self.get_domain_user_dict(
+ domain_statuses_filename, sep
+ )
+
+ # STEP 2:
+ # Create mapping of userId -> email
+ user_emails_dictionary = self.get_user_emails_dict(contacts_filename, sep)
+
+ # STEP 3:
+ # Parse the domain_contacts file and create TransitionDomain objects,
+ # using the dictionaries from steps 1 & 2 to lookup needed information.
+
+ to_create = []
+
+ # keep track of statuses that don't match our available
+ # status values
+ outlier_statuses = []
+
+ # keep track of domains that have no known status
+ domains_without_status = []
+
+ # keep track of users that have no e-mails
+ users_without_email = []
+
+ # keep track of duplications..
+ duplicate_domains = []
+ duplicate_domain_user_combos = []
+
+ # keep track of domains we ADD or UPDATE
+ total_updated_domain_entries = 0
+ total_new_entries = 0
+
+ # if we are limiting our parse (for testing purposes, keep
+ # track of total rows parsed)
+ total_rows_parsed = 0
+
+ # Start parsing the main file and create TransitionDomain objects
+ logger.info("Reading domain-contacts data file %s", domain_contacts_filename)
+ with open(domain_contacts_filename, "r") as domain_contacts_file:
+ for row in csv.reader(domain_contacts_file, delimiter=sep):
+ total_rows_parsed += 1
+
+ # fields are just domain, userid, role
+ # lowercase the domain names
+ new_entry_domain_name = row[0].lower()
+ user_id = row[1]
+
+ new_entry_status = TransitionDomain.StatusChoices.READY
+ new_entry_email = ""
+ new_entry_emailSent = False # set to False by default
+
+ # PART 1: Get the status
+ if new_entry_domain_name not in domain_status_dictionary:
+ # This domain has no status...default to "Create"
+ # (For data analysis purposes, add domain name
+ # to list of all domains without status
+ # (avoid duplicate entries))
+ if new_entry_domain_name not in domains_without_status:
+ domains_without_status.append(new_entry_domain_name)
+ else:
+ # Map the status
+ original_status = domain_status_dictionary[new_entry_domain_name]
+ mapped_status = self.get_mapped_status(original_status)
+ if mapped_status is None:
+ # (For data analysis purposes, check for any statuses
+ # that don't have a mapping and add to list
+ # of "outlier statuses")
+ logger.info("Unknown status: " + original_status)
+ outlier_statuses.append(original_status)
+ else:
+ new_entry_status = mapped_status
+
+ # PART 2: Get the e-mail
+ if user_id not in user_emails_dictionary:
+ # this user has no e-mail...this should never happen
+ if user_id not in users_without_email:
+ users_without_email.append(user_id)
+ else:
+ new_entry_email = user_emails_dictionary[user_id]
+
+ # PART 3: Create the transition domain object
+ # Check for duplicate data in the file we are
+ # parsing so we do not add duplicates
+ # NOTE: Currently, we allow duplicate domains,
+ # but not duplicate domain-user pairs.
+ # However, track duplicate domains for now,
+ # since we are still deciding on whether
+ # to make this field unique or not. ~10/25/2023
+ existing_domain = next(
+ (x for x in to_create if x.domain_name == new_entry_domain_name),
+ None,
+ )
+ existing_domain_user_pair = next(
+ (
+ x
+ for x in to_create
+ if x.username == new_entry_email
+ and x.domain_name == new_entry_domain_name
+ ),
+ None,
+ )
+ if existing_domain is not None:
+ # DEBUG:
+ self.print_debug(
+ debug_on,
+ f"{termColors.YELLOW} DUPLICATE file entries found for domain: {new_entry_domain_name} {termColors.ENDC}", # noqa
+ )
+ if new_entry_domain_name not in duplicate_domains:
+ duplicate_domains.append(new_entry_domain_name)
+ if existing_domain_user_pair is not None:
+ # DEBUG:
+ self.print_debug(
+ debug_on,
+ f"""{termColors.YELLOW} DUPLICATE file entries found for domain - user {termColors.BackgroundLightYellow} PAIR {termColors.ENDC}{termColors.YELLOW}:
+ {new_entry_domain_name} - {new_entry_email} {termColors.ENDC}""", # noqa
+ )
+ if existing_domain_user_pair not in duplicate_domain_user_combos:
+ duplicate_domain_user_combos.append(existing_domain_user_pair)
+ else:
+ entry_exists = TransitionDomain.objects.filter(
+ username=new_entry_email, domain_name=new_entry_domain_name
+ ).exists()
+ if entry_exists:
+ try:
+ existing_entry = TransitionDomain.objects.get(
+ username=new_entry_email,
+ domain_name=new_entry_domain_name,
+ )
+
+ if existing_entry.status != new_entry_status:
+ # DEBUG:
+ self.print_debug(
+ debug_on,
+ f"{termColors.OKCYAN}"
+ f"Updating entry: {existing_entry}"
+ f"Status: {existing_entry.status} > {new_entry_status}" # noqa
+ f"Email Sent: {existing_entry.email_sent} > {new_entry_emailSent}" # noqa
+ f"{termColors.ENDC}",
+ )
+ existing_entry.status = new_entry_status
+ existing_entry.email_sent = new_entry_emailSent
+ existing_entry.save()
+ except TransitionDomain.MultipleObjectsReturned:
+ logger.info(
+ f"{termColors.FAIL}"
+ f"!!! ERROR: duplicate entries exist in the"
+ f"transtion_domain table for domain:"
+ f"{new_entry_domain_name}"
+ f"----------TERMINATING----------"
+ )
+ sys.exit()
+ else:
+ # no matching entry, make one
+ new_entry = TransitionDomain(
+ username=new_entry_email,
+ domain_name=new_entry_domain_name,
+ status=new_entry_status,
+ email_sent=new_entry_emailSent,
+ )
+ to_create.append(new_entry)
+ total_new_entries += 1
+
+ # DEBUG:
+ self.print_debug(
+ debug_on,
+ f"{termColors.OKCYAN} Adding entry {total_new_entries}: {new_entry} {termColors.ENDC}", # noqa
+ )
+
+ # Check Parse limit and exit loop if needed
+ if (
+ total_rows_parsed >= debug_max_entries_to_parse
+ and debug_max_entries_to_parse != 0
+ ):
+ logger.info(
+ f"{termColors.YELLOW}"
+ f"----PARSE LIMIT REACHED. HALTING PARSER.----"
+ f"{termColors.ENDC}"
+ )
+ break
+
+ TransitionDomain.objects.bulk_create(to_create)
+
+ logger.info(
+ f"""{termColors.OKGREEN}
+ ============= FINISHED ===============
+ Created {total_new_entries} transition domain entries,
+ updated {total_updated_domain_entries} transition domain entries
+ {termColors.ENDC}
+ """
+ )
+
+ # Print a summary of findings (duplicate entries,
+ # missing data..etc.)
+ self.print_summary_duplications(
+ duplicate_domain_user_combos, duplicate_domains, users_without_email
+ )
+ self.print_summary_status_findings(domains_without_status, outlier_statuses)
diff --git a/src/registrar/management/commands/transfer_transition_domains_to_domains.py b/src/registrar/management/commands/transfer_transition_domains_to_domains.py
new file mode 100644
index 000000000..b98e8e2a9
--- /dev/null
+++ b/src/registrar/management/commands/transfer_transition_domains_to_domains.py
@@ -0,0 +1,409 @@
+import logging
+import argparse
+import sys
+
+from django_fsm import TransitionNotAllowed # type: ignore
+
+from django.core.management import BaseCommand
+
+from registrar.models import TransitionDomain
+from registrar.models import Domain
+from registrar.models import DomainInvitation
+
+logger = logging.getLogger(__name__)
+
+
+class termColors:
+ """Colors for terminal outputs
+ (makes reading the logs WAY easier)"""
+
+ HEADER = "\033[95m"
+ OKBLUE = "\033[94m"
+ OKCYAN = "\033[96m"
+ OKGREEN = "\033[92m"
+ YELLOW = "\033[93m"
+ FAIL = "\033[91m"
+ ENDC = "\033[0m"
+ BOLD = "\033[1m"
+ UNDERLINE = "\033[4m"
+ BackgroundLightYellow = "\033[103m"
+
+
+class Command(BaseCommand):
+ help = """Load data from transition domain tables
+ into main domain tables. Also create domain invitation
+ entries for every domain we ADD (but not for domains
+ we UPDATE)"""
+
+ def add_arguments(self, parser):
+ parser.add_argument("--debug", action=argparse.BooleanOptionalAction)
+
+ parser.add_argument(
+ "--limitParse",
+ default=0,
+ help="Sets max number of entries to load, set to 0 to load all entries",
+ )
+
+ def print_debug_mode_statements(
+ self, debug_on: bool, debug_max_entries_to_parse: int
+ ):
+ """Prints additional terminal statements to indicate if --debug
+ or --limitParse are in use"""
+ self.print_debug(
+ debug_on,
+ f"""{termColors.OKCYAN}
+ ----------DEBUG MODE ON----------
+ Detailed print statements activated.
+ {termColors.ENDC}
+ """,
+ )
+ self.print_debug(
+ debug_max_entries_to_parse > 0,
+ f"""{termColors.OKCYAN}
+ ----------LIMITER ON----------
+ Parsing of entries will be limited to
+ {debug_max_entries_to_parse} lines per file.")
+ Detailed print statements activated.
+ {termColors.ENDC}
+ """,
+ )
+
+ def print_debug(self, print_condition: bool, print_statement: str):
+ """This function reduces complexity of debug statements
+ in other functions.
+ It uses the logger to write the given print_statement to the
+ terminal if print_condition is TRUE"""
+ # DEBUG:
+ if print_condition:
+ logger.info(print_statement)
+
+ def update_domain_status(
+ self, transition_domain: TransitionDomain, target_domain: Domain, debug_on: bool
+ ) -> bool:
+ """Given a transition domain that matches an existing domain,
+ updates the existing domain object with that status of
+ the transition domain.
+ Returns TRUE if an update was made. FALSE if the states
+ matched and no update was made"""
+
+ transition_domain_status = transition_domain.status
+ existing_status = target_domain.state
+ if transition_domain_status != existing_status:
+ if transition_domain_status == TransitionDomain.StatusChoices.ON_HOLD:
+ target_domain.place_client_hold(ignoreEPP=True)
+ else:
+ target_domain.revert_client_hold(ignoreEPP=True)
+ target_domain.save()
+
+ # DEBUG:
+ self.print_debug(
+ debug_on,
+ f"""{termColors.YELLOW}
+ >> Updated {target_domain.name} state from
+ '{existing_status}' to '{target_domain.state}'
+ (no domain invitation entry added)
+ {termColors.ENDC}""",
+ )
+ return True
+ return False
+
+ def print_summary_of_findings(
+ self,
+ domains_to_create,
+ updated_domain_entries,
+ domain_invitations_to_create,
+ skipped_domain_entries,
+ debug_on,
+ ):
+ """Prints to terminal a summary of findings from
+ transferring transition domains to domains"""
+
+ total_new_entries = len(domains_to_create)
+ total_updated_domain_entries = len(updated_domain_entries)
+ total_domain_invitation_entries = len(domain_invitations_to_create)
+
+ logger.info(
+ f"""{termColors.OKGREEN}
+ ============= FINISHED ===============
+ Created {total_new_entries} transition domain entries,
+ Updated {total_updated_domain_entries} transition domain entries
+
+ Created {total_domain_invitation_entries} domain invitation entries
+ (NOTE: no invitations are SENT in this script)
+ {termColors.ENDC}
+ """
+ )
+ if len(skipped_domain_entries) > 0:
+ logger.info(
+ f"""{termColors.FAIL}
+ ============= SKIPPED DOMAINS (ERRORS) ===============
+ {skipped_domain_entries}
+ {termColors.ENDC}
+ """
+ )
+
+ # determine domainInvitations we SKIPPED
+ skipped_domain_invitations = []
+ for domain in domains_to_create:
+ skipped_domain_invitations.append(domain)
+ for domain_invite in domain_invitations_to_create:
+ if domain_invite.domain in skipped_domain_invitations:
+ skipped_domain_invitations.remove(domain_invite.domain)
+ if len(skipped_domain_invitations) > 0:
+ logger.info(
+ f"""{termColors.FAIL}
+ ============= SKIPPED DOMAIN INVITATIONS (ERRORS) ===============
+ {skipped_domain_invitations}
+ {termColors.ENDC}
+ """
+ )
+
+ # DEBUG:
+ self.print_debug(
+ debug_on,
+ f"""{termColors.YELLOW}
+
+ Created Domains:
+ {domains_to_create}
+
+ Updated Domains:
+ {updated_domain_entries}
+
+ {termColors.ENDC}
+ """,
+ )
+
+ def try_add_domain_invitation(
+ self, domain_email: str, associated_domain: Domain
+ ) -> DomainInvitation | None:
+ """If no domain invitation exists for the given domain and
+ e-mail, create and return a new domain invitation object.
+ If one already exists, or if the email is invalid, return NONE"""
+
+ # this should never happen, but adding it just in case
+ if associated_domain is None:
+ logger.warning(
+ f"""
+ {termColors.FAIL}
+ !!! ERROR: Domain cannot be null for a
+ Domain Invitation object!
+
+ RECOMMENDATION:
+ Somehow, an empty domain object is
+ being passed to the subroutine in charge
+ of making domain invitations. Walk through
+ the code to see what is amiss.
+
+ ----------TERMINATING----------"""
+ )
+ sys.exit()
+
+ # check that the given e-mail is valid
+ if domain_email is not None and domain_email != "":
+ # check that a domain invitation doesn't already
+ # exist for this e-mail / Domain pair
+ domain_email_already_in_domain_invites = DomainInvitation.objects.filter(
+ email=domain_email.lower(), domain=associated_domain
+ ).exists()
+ if not domain_email_already_in_domain_invites:
+ # Create new domain invitation
+ new_domain_invitation = DomainInvitation(
+ email=domain_email.lower(), domain=associated_domain
+ )
+ return new_domain_invitation
+ return None
+
+ def handle(
+ self,
+ **options,
+ ):
+ """Parse entries in TransitionDomain table
+ and create (or update) corresponding entries in the
+ Domain and DomainInvitation tables."""
+
+ # grab command line arguments and store locally...
+ debug_on = options.get("debug")
+ debug_max_entries_to_parse = int(
+ options.get("limitParse")
+ ) # set to 0 to parse all entries
+
+ self.print_debug_mode_statements(debug_on, debug_max_entries_to_parse)
+
+ # domains to ADD
+ domains_to_create = []
+ domain_invitations_to_create = []
+ # domains we UPDATED
+ updated_domain_entries = []
+ # domains we SKIPPED
+ skipped_domain_entries = []
+ # if we are limiting our parse (for testing purposes, keep
+ # track of total rows parsed)
+ total_rows_parsed = 0
+
+ logger.info(
+ f"""{termColors.OKGREEN}
+ ==========================
+ Beginning Data Transfer
+ ==========================
+ {termColors.ENDC}"""
+ )
+
+ for transition_domain in TransitionDomain.objects.all():
+ transition_domain_name = transition_domain.domain_name
+ transition_domain_status = transition_domain.status
+ transition_domain_email = transition_domain.username
+
+ # DEBUG:
+ self.print_debug(
+ debug_on,
+ f"""{termColors.OKCYAN}
+ Processing Transition Domain: {transition_domain_name}, {transition_domain_status}, {transition_domain_email}
+ {termColors.ENDC}""", # noqa
+ )
+
+ new_domain_invitation = None
+ # Check for existing domain entry
+ domain_exists = Domain.objects.filter(name=transition_domain_name).exists()
+ if domain_exists:
+ try:
+ # get the existing domain
+ domain_to_update = Domain.objects.get(name=transition_domain_name)
+ # DEBUG:
+ self.print_debug(
+ debug_on,
+ f"""{termColors.YELLOW}
+ > Found existing entry in Domain table for: {transition_domain_name}, {domain_to_update.state}
+ {termColors.ENDC}""", # noqa
+ )
+
+ # for existing entry, update the status to
+ # the transition domain status
+ update_made = self.update_domain_status(
+ transition_domain, domain_to_update, debug_on
+ )
+ if update_made:
+ # keep track of updated domains for data analysis purposes
+ updated_domain_entries.append(transition_domain.domain_name)
+
+ # check if we need to add a domain invitation
+ # (eg. for a new user)
+ new_domain_invitation = self.try_add_domain_invitation(
+ transition_domain_email, domain_to_update
+ )
+
+ except Domain.MultipleObjectsReturned:
+ # This exception was thrown once before during testing.
+ # While the circumstances that led to corrupt data in
+ # the domain table was a freak accident, and the possibility of it
+ # happening again is safe-guarded by a key constraint,
+ # better to keep an eye out for it since it would require
+ # immediate attention.
+ logger.warning(
+ f"""
+ {termColors.FAIL}
+ !!! ERROR: duplicate entries already exist in the
+ Domain table for the following domain:
+ {transition_domain_name}
+
+ RECOMMENDATION:
+ This means the Domain table is corrupt. Please
+ check the Domain table data as there should be a key
+ constraint which prevents duplicate entries.
+
+ ----------TERMINATING----------"""
+ )
+ sys.exit()
+ except TransitionNotAllowed as err:
+ skipped_domain_entries.append(transition_domain_name)
+ logger.warning(
+ f"""{termColors.FAIL}
+ Unable to change state for {transition_domain_name}
+
+ RECOMMENDATION:
+ This indicates there might have been changes to the
+ Domain model which were not accounted for in this
+ migration script. Please check state change rules
+ in the Domain model and ensure we are following the
+ correct state transition pathways.
+
+ INTERNAL ERROR MESSAGE:
+ 'TRANSITION NOT ALLOWED' exception
+ {err}
+ ----------SKIPPING----------"""
+ )
+ else:
+ # no entry was found in the domain table
+ # for the given domain. Create a new entry.
+
+ # first see if we are already adding an entry for this domain.
+ # The unique key constraint does not allow duplicate domain entries
+ # even if there are different users.
+ existing_domain_in_to_create = next(
+ (x for x in domains_to_create if x.name == transition_domain_name),
+ None,
+ )
+ if existing_domain_in_to_create is not None:
+ self.print_debug(
+ debug_on,
+ f"""{termColors.YELLOW}
+ Duplicate Detected: {transition_domain_name}.
+ Cannot add duplicate entry for another username.
+ Violates Unique Key constraint.
+
+ Checking for unique user e-mail for Domain Invitations...
+ {termColors.ENDC}""",
+ )
+ new_domain_invitation = self.try_add_domain_invitation(
+ transition_domain_email, existing_domain_in_to_create
+ )
+ else:
+ # no matching entry, make one
+ new_domain = Domain(
+ name=transition_domain_name, state=transition_domain_status
+ )
+ domains_to_create.append(new_domain)
+ # DEBUG:
+ self.print_debug(
+ debug_on,
+ f"{termColors.OKCYAN} Adding domain: {new_domain} {termColors.ENDC}", # noqa
+ )
+ new_domain_invitation = self.try_add_domain_invitation(
+ transition_domain_email, new_domain
+ )
+
+ if new_domain_invitation is None:
+ logger.info(
+ f"{termColors.YELLOW} ! No new e-mail detected !" # noqa
+ f"(SKIPPED ADDING DOMAIN INVITATION){termColors.ENDC}"
+ )
+ else:
+ # DEBUG:
+ self.print_debug(
+ debug_on,
+ f"{termColors.OKCYAN} Adding domain invitation: {new_domain_invitation} {termColors.ENDC}", # noqa
+ )
+ domain_invitations_to_create.append(new_domain_invitation)
+
+ # Check parse limit and exit loop if parse limit has been reached
+ if (
+ debug_max_entries_to_parse > 0
+ and total_rows_parsed >= debug_max_entries_to_parse
+ ):
+ logger.info(
+ f"""{termColors.YELLOW}
+ ----PARSE LIMIT REACHED. HALTING PARSER.----
+ {termColors.ENDC}
+ """
+ )
+ break
+
+ Domain.objects.bulk_create(domains_to_create)
+ DomainInvitation.objects.bulk_create(domain_invitations_to_create)
+
+ self.print_summary_of_findings(
+ domains_to_create,
+ updated_domain_entries,
+ domain_invitations_to_create,
+ skipped_domain_entries,
+ debug_on,
+ )
diff --git a/src/registrar/migrations/0034_usergroup.py b/src/registrar/migrations/0034_usergroup.py
new file mode 100644
index 000000000..618188230
--- /dev/null
+++ b/src/registrar/migrations/0034_usergroup.py
@@ -0,0 +1,39 @@
+# Generated by Django 4.2.1 on 2023-09-20 19:04
+
+import django.contrib.auth.models
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("auth", "0012_alter_user_first_name_max_length"),
+ ("registrar", "0033_alter_userdomainrole_role"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="UserGroup",
+ fields=[
+ (
+ "group_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="auth.group",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "User group",
+ "verbose_name_plural": "User groups",
+ },
+ bases=("auth.group",),
+ managers=[
+ ("objects", django.contrib.auth.models.GroupManager()),
+ ],
+ ),
+ ]
diff --git a/src/registrar/migrations/0035_alter_user_options.py b/src/registrar/migrations/0035_alter_user_options.py
new file mode 100644
index 000000000..7ed81cdf5
--- /dev/null
+++ b/src/registrar/migrations/0035_alter_user_options.py
@@ -0,0 +1,21 @@
+# Generated by Django 4.2.1 on 2023-09-27 18:53
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("registrar", "0034_usergroup"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="user",
+ options={
+ "permissions": [
+ ("analyst_access_permission", "Analyst Access Permission"),
+ ("full_access_permission", "Full Access Permission"),
+ ]
+ },
+ ),
+ ]
diff --git a/src/registrar/migrations/0036_contenttypes_permissions.py b/src/registrar/migrations/0036_contenttypes_permissions.py
new file mode 100644
index 000000000..a4f980e82
--- /dev/null
+++ b/src/registrar/migrations/0036_contenttypes_permissions.py
@@ -0,0 +1,43 @@
+# From mithuntnt's answer on:
+# https://stackoverflow.com/questions/26464838/getting-model-contenttype-in-migration-django-1-7
+# The problem is that ContentType and Permission objects are not already created
+# while we're still running migrations, so we'll go ahead and speed up that process
+# a bit before we attempt to create groups which require Permissions and ContentType.
+
+from django.conf import settings
+from django.db import migrations
+
+
+def create_all_contenttypes(**kwargs):
+ from django.apps import apps
+ from django.contrib.contenttypes.management import create_contenttypes
+
+ for app_config in apps.get_app_configs():
+ create_contenttypes(app_config, **kwargs)
+
+
+def create_all_permissions(**kwargs):
+ from django.contrib.auth.management import create_permissions
+ from django.apps import apps
+
+ for app_config in apps.get_app_configs():
+ create_permissions(app_config, **kwargs)
+
+
+def forward(apps, schema_editor):
+ create_all_contenttypes()
+ create_all_permissions()
+
+
+def backward(apps, schema_editor):
+ pass
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ("contenttypes", "0002_remove_content_type_name"),
+ ("registrar", "0035_alter_user_options"),
+ ]
+
+ operations = [migrations.RunPython(forward, backward)]
diff --git a/src/registrar/migrations/0037_create_groups_v01.py b/src/registrar/migrations/0037_create_groups_v01.py
new file mode 100644
index 000000000..3540ea2f3
--- /dev/null
+++ b/src/registrar/migrations/0037_create_groups_v01.py
@@ -0,0 +1,37 @@
+# This migration creates the create_full_access_group and create_cisa_analyst_group groups
+# It is dependent on 0035 (which populates ContentType and Permissions)
+# If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS
+# in the user_group model then:
+# [NOT RECOMMENDED]
+# step 1: docker-compose exec app ./manage.py migrate --fake registrar 0035_contenttypes_permissions
+# step 2: docker-compose exec app ./manage.py migrate registrar 0036_create_groups
+# step 3: fake run the latest migration in the migrations list
+# [RECOMMENDED]
+# Alternatively:
+# step 1: duplicate the migration that loads data
+# step 2: docker-compose exec app ./manage.py migrate
+
+from django.db import migrations
+from registrar.models import UserGroup
+from typing import Any
+
+
+# For linting: RunPython expects a function reference,
+# so let's give it one
+def create_groups(apps, schema_editor) -> Any:
+ UserGroup.create_cisa_analyst_group(apps, schema_editor)
+ UserGroup.create_full_access_group(apps, schema_editor)
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("registrar", "0036_contenttypes_permissions"),
+ ]
+
+ operations = [
+ migrations.RunPython(
+ create_groups,
+ reverse_code=migrations.RunPython.noop,
+ atomic=True,
+ ),
+ ]
diff --git a/src/registrar/migrations/0038_create_groups_v02.py b/src/registrar/migrations/0038_create_groups_v02.py
new file mode 100644
index 000000000..fc61db3c0
--- /dev/null
+++ b/src/registrar/migrations/0038_create_groups_v02.py
@@ -0,0 +1,37 @@
+# This migration creates the create_full_access_group and create_cisa_analyst_group groups
+# It is dependent on 0035 (which populates ContentType and Permissions)
+# If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS
+# in the user_group model then:
+# [NOT RECOMMENDED]
+# step 1: docker-compose exec app ./manage.py migrate --fake registrar 0035_contenttypes_permissions
+# step 2: docker-compose exec app ./manage.py migrate registrar 0036_create_groups
+# step 3: fake run the latest migration in the migrations list
+# [RECOMMENDED]
+# Alternatively:
+# step 1: duplicate the migration that loads data
+# step 2: docker-compose exec app ./manage.py migrate
+
+from django.db import migrations
+from registrar.models import UserGroup
+from typing import Any
+
+
+# For linting: RunPython expects a function reference,
+# so let's give it one
+def create_groups(apps, schema_editor) -> Any:
+ UserGroup.create_cisa_analyst_group(apps, schema_editor)
+ UserGroup.create_full_access_group(apps, schema_editor)
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("registrar", "0037_create_groups_v01"),
+ ]
+
+ operations = [
+ migrations.RunPython(
+ create_groups,
+ reverse_code=migrations.RunPython.noop,
+ atomic=True,
+ ),
+ ]
diff --git a/src/registrar/migrations/0039_alter_transitiondomain_status.py b/src/registrar/migrations/0039_alter_transitiondomain_status.py
new file mode 100644
index 000000000..b6ac08770
--- /dev/null
+++ b/src/registrar/migrations/0039_alter_transitiondomain_status.py
@@ -0,0 +1,22 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("registrar", "0038_create_groups_v02"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="transitiondomain",
+ name="status",
+ field=models.CharField(
+ blank=True,
+ choices=[("ready", "Ready"), ("on hold", "On Hold")],
+ default="ready",
+ help_text="domain status during the transfer",
+ max_length=255,
+ verbose_name="Status",
+ ),
+ ),
+ ]
diff --git a/src/registrar/models/__init__.py b/src/registrar/models/__init__.py
index fa4ce7e2a..f287c401c 100644
--- a/src/registrar/models/__init__.py
+++ b/src/registrar/models/__init__.py
@@ -12,6 +12,7 @@ from .nameserver import Nameserver
from .user_domain_role import UserDomainRole
from .public_contact import PublicContact
from .user import User
+from .user_group import UserGroup
from .website import Website
from .transition_domain import TransitionDomain
@@ -28,6 +29,7 @@ __all__ = [
"UserDomainRole",
"PublicContact",
"User",
+ "UserGroup",
"Website",
"TransitionDomain",
]
@@ -42,6 +44,7 @@ auditlog.register(Host)
auditlog.register(Nameserver)
auditlog.register(UserDomainRole)
auditlog.register(PublicContact)
-auditlog.register(User)
+auditlog.register(User, m2m_fields=["user_permissions", "groups"])
+auditlog.register(UserGroup, m2m_fields=["permissions"])
auditlog.register(Website)
auditlog.register(TransitionDomain)
diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py
index 634debe60..c7d786426 100644
--- a/src/registrar/models/domain.py
+++ b/src/registrar/models/domain.py
@@ -1,11 +1,14 @@
from itertools import zip_longest
import logging
+import ipaddress
+import re
from datetime import date
from string import digits
+from typing import Optional
from django_fsm import FSMField, transition, TransitionNotAllowed # type: ignore
from django.db import models
-
+from typing import Any
from epplibwrapper import (
CLIENT as registry,
commands,
@@ -15,7 +18,15 @@ from epplibwrapper import (
RegistryError,
ErrorCode,
)
-from registrar.models.utility.contact_error import ContactError
+
+from registrar.utility.errors import (
+ ActionNotAllowed,
+ NameserverError,
+ NameserverErrorCodes as nsErrorCodes,
+)
+
+from registrar.models.utility.contact_error import ContactError, ContactErrorCodes
+
from .utility.domain_field import DomainField
from .utility.domain_helper import DomainHelper
@@ -218,13 +229,13 @@ class Domain(TimeStampedModel, DomainHelper):
raise NotImplementedError()
@Cache
- def nameservers(self) -> list[tuple[str]]:
+ def nameservers(self) -> list[tuple[str, list]]:
"""
Get or set a complete list of nameservers for this domain.
Hosts are provided as a list of tuples, e.g.
- [("ns1.example.com",), ("ns1.example.gov", "0.0.0.0")]
+ [("ns1.example.com",), ("ns1.example.gov", ["0.0.0.0"])]
Subordinate hosts (something.your-domain.gov) MUST have IP addresses,
while non-subordinate hosts MUST NOT.
@@ -232,42 +243,23 @@ class Domain(TimeStampedModel, DomainHelper):
try:
hosts = self._get_property("hosts")
except Exception as err:
- # Don't throw error as this is normal for a new domain
- # TODO - 433 error handling ticket should address this
+ # Do not raise error when missing nameservers
+ # this is a standard occurence when a domain
+ # is first created
logger.info("Domain is missing nameservers %s" % err)
return []
+ # TODO-687 fix this return value
hostList = []
for host in hosts:
- # TODO - this should actually have a second tuple value with the ip address
- # ignored because uncertain if we will even have a way to display mult.
- # and adresses can be a list of mult address
- hostList.append((host["name"],))
+ hostList.append((host["name"], host["addrs"]))
return hostList
- def _check_host(self, hostnames: list[str]):
- """check if host is available, True if available
- returns boolean"""
- checkCommand = commands.CheckHost(hostnames)
- try:
- response = registry.send(checkCommand, cleaned=True)
- return response.res_data[0].avail
- except RegistryError as err:
- logger.warning(
- "Couldn't check hosts %s. Errorcode was %s, error was %s",
- hostnames,
- err.code,
- err,
- )
- return False
-
def _create_host(self, host, addrs):
- """Call _check_host first before using this function,
- This creates the host object in the registry
+ """Creates the host object in the registry
doesn't add the created host to the domain
returns ErrorCode (int)"""
- logger.info("Creating host")
if addrs is not None:
addresses = [epp.Ip(addr=addr) for addr in addrs]
request = commands.CreateHost(name=host, addrs=addresses)
@@ -282,76 +274,381 @@ class Domain(TimeStampedModel, DomainHelper):
logger.error("Error _create_host, code was %s error was %s" % (e.code, e))
return e.code
+ def _convert_list_to_dict(self, listToConvert: list[tuple[str, list]]):
+ """converts a list of hosts into a dictionary
+ Args:
+ list[tuple[str, list]]: such as [("123",["1","2","3"])]
+ This is the list of hosts to convert
+
+ returns:
+ convertDict (dict(str,list))- such as{"123":["1","2","3"]}"""
+ newDict: dict[str, Any] = {}
+
+ for tup in listToConvert:
+ if len(tup) == 1:
+ newDict[tup[0]] = None
+ elif len(tup) == 2:
+ newDict[tup[0]] = tup[1]
+ return newDict
+
+ def isSubdomain(self, nameserver: str):
+ """Returns boolean if the domain name is found in the argument passed"""
+ subdomain_pattern = r"([\w-]+\.)*"
+ full_pattern = subdomain_pattern + self.name
+ regex = re.compile(full_pattern)
+ return bool(regex.match(nameserver))
+
+ def checkHostIPCombo(self, nameserver: str, ip: list[str]):
+ """Checks the parameters past for a valid combination
+ raises error if:
+ - nameserver is a subdomain but is missing ip
+ - nameserver is not a subdomain but has ip
+ - nameserver is a subdomain but an ip passed is invalid
+
+ Args:
+ hostname (str)- nameserver or subdomain
+ ip (list[str])-list of ip strings
+ Throws:
+ NameserverError (if exception hit)
+ Returns:
+ None"""
+ if self.isSubdomain(nameserver) and (ip is None or ip == []):
+ raise NameserverError(code=nsErrorCodes.MISSING_IP, nameserver=nameserver)
+
+ elif not self.isSubdomain(nameserver) and (ip is not None and ip != []):
+ raise NameserverError(
+ code=nsErrorCodes.GLUE_RECORD_NOT_ALLOWED, nameserver=nameserver, ip=ip
+ )
+ elif ip is not None and ip != []:
+ for addr in ip:
+ if not self._valid_ip_addr(addr):
+ raise NameserverError(
+ code=nsErrorCodes.INVALID_IP, nameserver=nameserver, ip=ip
+ )
+ return None
+
+ def _valid_ip_addr(self, ipToTest: str):
+ """returns boolean if valid ip address string
+ We currently only accept v4 or v6 ips
+ returns:
+ isValid (boolean)-True for valid ip address"""
+ try:
+ ip = ipaddress.ip_address(ipToTest)
+ return ip.version == 6 or ip.version == 4
+
+ except ValueError:
+ return False
+
+ def getNameserverChanges(
+ self, hosts: list[tuple[str, list]]
+ ) -> tuple[list, list, dict, dict]:
+ """
+ calls self.nameserver, it should pull from cache but may result
+ in an epp call
+ Args:
+ hosts: list[tuple[str, list]] such as [("123",["1","2","3"])]
+ Throws:
+ NameserverError (if exception hit)
+ Returns:
+ tuple[list, list, dict, dict]
+ These four tuple values as follows:
+ deleted_values: list[str]
+ updated_values: list[str]
+ new_values: dict(str,list)
+ prevHostDict: dict(str,list)"""
+
+ oldNameservers = self.nameservers
+
+ previousHostDict = self._convert_list_to_dict(oldNameservers)
+
+ newHostDict = self._convert_list_to_dict(hosts)
+ deleted_values = []
+ # TODO-currently a list of tuples, why not dict? for consistency
+ updated_values = []
+ new_values = {}
+
+ for prevHost in previousHostDict:
+ addrs = previousHostDict[prevHost]
+ # get deleted values-which are values in previous nameserver list
+ # but are not in the list of new host values
+ if prevHost not in newHostDict:
+ deleted_values.append(prevHost)
+ # if the host exists in both, check if the addresses changed
+ else:
+ # TODO - host is being updated when previous was None+new is empty list
+ # add check here
+ if newHostDict[prevHost] is not None and set(
+ newHostDict[prevHost]
+ ) != set(addrs):
+ self.checkHostIPCombo(nameserver=prevHost, ip=newHostDict[prevHost])
+ updated_values.append((prevHost, newHostDict[prevHost]))
+
+ new_values = {
+ key: newHostDict.get(key)
+ for key in newHostDict
+ if key not in previousHostDict and key.strip() != ""
+ }
+
+ for nameserver, ip in new_values.items():
+ self.checkHostIPCombo(nameserver=nameserver, ip=ip)
+
+ return (deleted_values, updated_values, new_values, previousHostDict)
+
+ def _update_host_values(self, updated_values, oldNameservers):
+ for hostTuple in updated_values:
+ updated_response_code = self._update_host(
+ hostTuple[0], hostTuple[1], oldNameservers.get(hostTuple[0])
+ )
+ if updated_response_code not in [
+ ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
+ ErrorCode.OBJECT_EXISTS,
+ ]:
+ logger.warning(
+ "Could not update host %s. Error code was: %s "
+ % (hostTuple[0], updated_response_code)
+ )
+
+ def createNewHostList(self, new_values: dict):
+ """convert the dictionary of new values to a list of HostObjSet
+ for use in the UpdateDomain epp message
+ Args:
+ new_values: dict(str,list)- dict of {nameserver:ips} to add to domain
+ Returns:
+ tuple [list[epp.HostObjSet], int]
+ list[epp.HostObjSet]-epp object for use in the UpdateDomain epp message
+ defaults to empty list
+ int-number of items being created default 0
+ """
+
+ hostStringList = []
+ for key, value in new_values.items():
+ createdCode = self._create_host(
+ host=key, addrs=value
+ ) # creates in registry
+ if (
+ createdCode == ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY
+ or createdCode == ErrorCode.OBJECT_EXISTS
+ ):
+ hostStringList.append(key)
+ if hostStringList == []:
+ return [], 0
+
+ addToDomainObject = epp.HostObjSet(hosts=hostStringList)
+ return [addToDomainObject], len(hostStringList)
+
+ def createDeleteHostList(self, hostsToDelete: list[str]):
+ """
+ Args:
+ hostsToDelete (list[str])- list of nameserver/host names to remove
+ Returns:
+ tuple [list[epp.HostObjSet], int]
+ list[epp.HostObjSet]-epp object for use in the UpdateDomain epp message
+ defaults to empty list
+ int-number of items being created default 0
+ """
+ deleteStrList = []
+ for nameserver in hostsToDelete:
+ deleteStrList.append(nameserver)
+ if deleteStrList == []:
+ return [], 0
+ deleteObj = epp.HostObjSet(hosts=hostsToDelete)
+
+ return [deleteObj], len(deleteStrList)
+
@Cache
- def dnssecdata(self) -> extensions.DNSSECExtension:
- return self._get_property("dnssecdata")
+ def dnssecdata(self) -> Optional[extensions.DNSSECExtension]:
+ """
+ Get a complete list of dnssecdata extensions for this domain.
+
+ dnssecdata are provided as a list of DNSSECExtension objects.
+
+ A DNSSECExtension object includes:
+ maxSigLife: Optional[int]
+ dsData: Optional[Sequence[DSData]]
+ keyData: Optional[Sequence[DNSSECKeyData]]
+
+ """
+ try:
+ return self._get_property("dnssecdata")
+ except Exception as err:
+ # Don't throw error as this is normal for a new domain
+ logger.info("Domain does not have dnssec data defined %s" % err)
+ return None
+
+ def getDnssecdataChanges(
+ self, _dnssecdata: Optional[extensions.DNSSECExtension]
+ ) -> tuple[dict, dict]:
+ """
+ calls self.dnssecdata, it should pull from cache but may result
+ in an epp call
+ returns tuple of 2 values as follows:
+ addExtension: dict
+ remExtension: dict
+
+ addExtension includes all dsData or keyData to be added
+ remExtension includes all dsData or keyData to be removed
+
+ method operates on dsData OR keyData, never a mix of the two;
+ operates based on which is present in _dnssecdata;
+ if neither is present, addExtension will be empty dict, and
+ remExtension will be all existing dnssecdata to be deleted
+ """
+
+ oldDnssecdata = self.dnssecdata
+ addDnssecdata: dict = {}
+ remDnssecdata: dict = {}
+
+ if _dnssecdata and _dnssecdata.dsData is not None:
+ # initialize addDnssecdata and remDnssecdata for dsData
+ addDnssecdata["dsData"] = _dnssecdata.dsData
+
+ if oldDnssecdata and len(oldDnssecdata.dsData) > 0:
+ # if existing dsData not in new dsData, mark for removal
+ dsDataForRemoval = [
+ dsData
+ for dsData in oldDnssecdata.dsData
+ if dsData not in _dnssecdata.dsData
+ ]
+ if len(dsDataForRemoval) > 0:
+ remDnssecdata["dsData"] = dsDataForRemoval
+
+ # if new dsData not in existing dsData, mark for add
+ dsDataForAdd = [
+ dsData
+ for dsData in _dnssecdata.dsData
+ if dsData not in oldDnssecdata.dsData
+ ]
+ if len(dsDataForAdd) > 0:
+ addDnssecdata["dsData"] = dsDataForAdd
+ else:
+ addDnssecdata["dsData"] = None
+
+ elif _dnssecdata and _dnssecdata.keyData is not None:
+ # initialize addDnssecdata and remDnssecdata for keyData
+ addDnssecdata["keyData"] = _dnssecdata.keyData
+
+ if oldDnssecdata and len(oldDnssecdata.keyData) > 0:
+ # if existing keyData not in new keyData, mark for removal
+ keyDataForRemoval = [
+ keyData
+ for keyData in oldDnssecdata.keyData
+ if keyData not in _dnssecdata.keyData
+ ]
+ if len(keyDataForRemoval) > 0:
+ remDnssecdata["keyData"] = keyDataForRemoval
+
+ # if new keyData not in existing keyData, mark for add
+ keyDataForAdd = [
+ keyData
+ for keyData in _dnssecdata.keyData
+ if keyData not in oldDnssecdata.keyData
+ ]
+ if len(keyDataForAdd) > 0:
+ addDnssecdata["keyData"] = keyDataForAdd
+ else:
+ # there are no new dsData or keyData, remove all
+ remDnssecdata["dsData"] = getattr(oldDnssecdata, "dsData", None)
+ remDnssecdata["keyData"] = getattr(oldDnssecdata, "keyData", None)
+
+ return addDnssecdata, remDnssecdata
@dnssecdata.setter # type: ignore
- def dnssecdata(self, _dnssecdata: extensions.DNSSECExtension):
- updateParams = {
- "maxSigLife": _dnssecdata.get("maxSigLife", None),
- "dsData": _dnssecdata.get("dsData", None),
- "keyData": _dnssecdata.get("keyData", None),
- "remAllDsKeyData": True,
+ def dnssecdata(self, _dnssecdata: Optional[extensions.DNSSECExtension]):
+ _addDnssecdata, _remDnssecdata = self.getDnssecdataChanges(_dnssecdata)
+ addParams = {
+ "maxSigLife": _addDnssecdata.get("maxSigLife", None),
+ "dsData": _addDnssecdata.get("dsData", None),
+ "keyData": _addDnssecdata.get("keyData", None),
}
- request = commands.UpdateDomain(name=self.name)
- extension = commands.UpdateDomainDNSSECExtension(**updateParams)
- request.add_extension(extension)
+ remParams = {
+ "maxSigLife": _remDnssecdata.get("maxSigLife", None),
+ "remDsData": _remDnssecdata.get("dsData", None),
+ "remKeyData": _remDnssecdata.get("keyData", None),
+ }
+ addRequest = commands.UpdateDomain(name=self.name)
+ addExtension = commands.UpdateDomainDNSSECExtension(**addParams)
+ addRequest.add_extension(addExtension)
+ remRequest = commands.UpdateDomain(name=self.name)
+ remExtension = commands.UpdateDomainDNSSECExtension(**remParams)
+ remRequest.add_extension(remExtension)
try:
- registry.send(request, cleaned=True)
+ if (
+ "dsData" in _addDnssecdata
+ and _addDnssecdata["dsData"] is not None
+ or "keyData" in _addDnssecdata
+ and _addDnssecdata["keyData"] is not None
+ ):
+ registry.send(addRequest, cleaned=True)
+ if (
+ "dsData" in _remDnssecdata
+ and _remDnssecdata["dsData"] is not None
+ or "keyData" in _remDnssecdata
+ and _remDnssecdata["keyData"] is not None
+ ):
+ registry.send(remRequest, cleaned=True)
except RegistryError as e:
- logger.error("Error adding DNSSEC, code was %s error was %s" % (e.code, e))
+ logger.error(
+ "Error updating DNSSEC, code was %s error was %s" % (e.code, e)
+ )
raise e
@nameservers.setter # type: ignore
- def nameservers(self, hosts: list[tuple[str]]):
- """host should be a tuple of type str, str,... where the elements are
+ def nameservers(self, hosts: list[tuple[str, list]]):
+ """Host should be a tuple of type str, str,... where the elements are
Fully qualified host name, addresses associated with the host
- example: [(ns1.okay.gov, 127.0.0.1, others ips)]"""
- # TODO: ticket #848 finish this implementation
- # must delete nameservers as well or update
- # ip version checking may need to be added in a different ticket
+ example: [(ns1.okay.gov, [127.0.0.1, others ips])]"""
if len(hosts) > 13:
- raise ValueError(
- "Too many hosts provided, you may not have more than 13 nameservers."
- )
+ raise NameserverError(code=nsErrorCodes.TOO_MANY_HOSTS)
+
+ if self.state not in [self.State.DNS_NEEDED, self.State.READY]:
+ raise ActionNotAllowed("Nameservers can not be " "set in the current state")
+
logger.info("Setting nameservers")
logger.info(hosts)
- for hostTuple in hosts:
- host = hostTuple[0]
- addrs = None
- if len(hostTuple) > 1:
- addrs = hostTuple[1:]
- avail = self._check_host([host])
- if avail:
- createdCode = self._create_host(host=host, addrs=addrs)
- # update the domain obj
- if createdCode == ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY:
- # add host to domain
- request = commands.UpdateDomain(
- name=self.name, add=[epp.HostObjSet([host])]
- )
+ # get the changes made by user and old nameserver values
+ (
+ deleted_values,
+ updated_values,
+ new_values,
+ oldNameservers,
+ ) = self.getNameserverChanges(hosts=hosts)
- try:
- registry.send(request, cleaned=True)
- except RegistryError as e:
- logger.error(
- "Error adding nameserver, code was %s error was %s"
- % (e.code, e)
- )
+ _ = self._update_host_values(
+ updated_values, oldNameservers
+ ) # returns nothing, just need to be run and errors
+ addToDomainList, addToDomainCount = self.createNewHostList(new_values)
+ deleteHostList, deleteCount = self.createDeleteHostList(deleted_values)
+ responseCode = self.addAndRemoveHostsFromDomain(
+ hostsToAdd=addToDomainList, hostsToDelete=deleteHostList
+ )
- try:
- self.ready()
- self.save()
- except Exception as err:
- logger.info(
- "nameserver setter checked for create state "
- "and it did not succeed. Error: %s" % err
- )
- # TODO - handle removed nameservers here will need to change the state
- # then go back to DNS_NEEDED
+ # if unable to update domain raise error and stop
+ if responseCode != ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY:
+ raise NameserverError(code=nsErrorCodes.UNABLE_TO_UPDATE_DOMAIN)
+
+ successTotalNameservers = len(oldNameservers) - deleteCount + addToDomainCount
+
+ self._delete_hosts_if_not_used(hostsToDelete=deleted_values)
+ if successTotalNameservers < 2:
+ try:
+ self.dns_needed()
+ self.save()
+ except Exception as err:
+ logger.info(
+ "nameserver setter checked for dns_needed state "
+ "and it did not succeed. Warning: %s" % err
+ )
+ elif successTotalNameservers >= 2 and successTotalNameservers <= 13:
+ try:
+ self.ready()
+ self.save()
+ except Exception as err:
+ logger.info(
+ "nameserver setter checked for create state "
+ "and it did not succeed. Warning: %s" % err
+ )
@Cache
def statuses(self) -> list[str]:
@@ -520,7 +817,7 @@ class Domain(TimeStampedModel, DomainHelper):
and errorCode != ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY
):
# TODO- ticket #433 look here for error handling
- raise Exception("Unable to add contact to registry")
+ raise RegistryError(code=errorCode)
# contact doesn't exist on the domain yet
logger.info("_set_singleton_contact()-> contact has been added to the registry")
@@ -625,7 +922,10 @@ class Domain(TimeStampedModel, DomainHelper):
def get_security_email(self):
logger.info("get_security_email-> getting the contact ")
secContact = self.security_contact
- return secContact.email
+ if secContact is not None:
+ return secContact.email
+ else:
+ return None
def clientHoldStatus(self):
return epp.Status(state=self.Status.CLIENT_HOLD, description="", lang="en")
@@ -698,10 +998,10 @@ class Domain(TimeStampedModel, DomainHelper):
return None
if contact_type is None:
- raise ContactError("contact_type is None")
+ raise ContactError(code=ContactErrorCodes.CONTACT_TYPE_NONE)
if contact_id is None:
- raise ContactError("contact_id is None")
+ raise ContactError(code=ContactErrorCodes.CONTACT_ID_NONE)
# Since contact_id is registry_id,
# check that its the right length
@@ -710,14 +1010,10 @@ class Domain(TimeStampedModel, DomainHelper):
contact_id_length > PublicContact.get_max_id_length()
or contact_id_length < 1
):
- raise ContactError(
- "contact_id is of invalid length. "
- "Cannot exceed 16 characters, "
- f"got {contact_id} with a length of {contact_id_length}"
- )
+ raise ContactError(code=ContactErrorCodes.CONTACT_ID_INVALID_LENGTH)
if not isinstance(contact, eppInfo.InfoContactResultData):
- raise ContactError("Contact must be of type InfoContactResultData")
+ raise ContactError(code=ContactErrorCodes.CONTACT_INVALID_TYPE)
auth_info = contact.auth_info
postal_info = contact.postal_info
@@ -881,8 +1177,8 @@ class Domain(TimeStampedModel, DomainHelper):
return self._handle_registrant_contact(desired_contact)
- _registry_id: str
- if contact_type in contacts:
+ _registry_id: str = ""
+ if contacts is not None and contact_type in contacts:
_registry_id = contacts.get(contact_type)
desired = PublicContact.objects.filter(
@@ -948,7 +1244,6 @@ class Domain(TimeStampedModel, DomainHelper):
count = 0
while not exitEarly and count < 3:
try:
- logger.info("Getting domain info from epp")
req = commands.InfoDomain(name=self.name)
domainInfoResponse = registry.send(req, cleaned=True)
exitEarly = True
@@ -964,7 +1259,7 @@ class Domain(TimeStampedModel, DomainHelper):
if e.code == ErrorCode.OBJECT_DOES_NOT_EXIST:
# avoid infinite loop
already_tried_to_create = True
- self.pendingCreate()
+ self.dns_needed_from_unknown()
self.save()
else:
logger.error(e)
@@ -978,7 +1273,7 @@ class Domain(TimeStampedModel, DomainHelper):
return registrant.registry_id
@transition(field="state", source=State.UNKNOWN, target=State.DNS_NEEDED)
- def pendingCreate(self):
+ def dns_needed_from_unknown(self):
logger.info("Changing to dns_needed")
registrantID = self.addRegistrant()
@@ -1011,20 +1306,29 @@ class Domain(TimeStampedModel, DomainHelper):
@transition(
field="state", source=[State.READY, State.ON_HOLD], target=State.ON_HOLD
)
- def place_client_hold(self):
- """place a clienthold on a domain (no longer should resolve)"""
+ def place_client_hold(self, ignoreEPP=False):
+ """place a clienthold on a domain (no longer should resolve)
+ ignoreEPP (boolean) - set to true to by-pass EPP (used for transition domains)
+ """
# TODO - ensure all requirements for client hold are made here
# (check prohibited statuses)
logger.info("clientHold()-> inside clientHold")
- self._place_client_hold()
+
+ # In order to allow transition domains to by-pass EPP calls,
+ # include this ignoreEPP flag
+ if not ignoreEPP:
+ self._place_client_hold()
# TODO -on the client hold ticket any additional error handling here
@transition(field="state", source=[State.READY, State.ON_HOLD], target=State.READY)
- def revert_client_hold(self):
- """undo a clienthold placed on a domain"""
+ def revert_client_hold(self, ignoreEPP=False):
+ """undo a clienthold placed on a domain
+ ignoreEPP (boolean) - set to true to by-pass EPP (used for transition domains)
+ """
logger.info("clientHold()-> inside clientHold")
- self._remove_client_hold()
+ if not ignoreEPP:
+ self._remove_client_hold()
# TODO -on the client hold ticket any additional error handling here
@transition(
@@ -1054,26 +1358,54 @@ class Domain(TimeStampedModel, DomainHelper):
else:
self._invalidate_cache()
+ # def is_dns_needed(self):
+ # """Commented out and kept in the codebase
+ # as this call should be made, but adds
+ # a lot of processing time
+ # when EPP calling is made more efficient
+ # this should be added back in
+
+ # The goal is to double check that
+ # the nameservers we set are in fact
+ # on the registry
+ # """
+ # self._invalidate_cache()
+ # nameserverList = self.nameservers
+ # return len(nameserverList) < 2
+
+ # def dns_not_needed(self):
+ # return not self.is_dns_needed()
+
@transition(
field="state",
source=[State.DNS_NEEDED],
target=State.READY,
+ # conditions=[dns_not_needed]
)
def ready(self):
"""Transition to the ready state
domain should have nameservers and all contacts
and now should be considered live on a domain
"""
- # TODO - in nameservers tickets 848 and 562
- # check here if updates need to be made
- # consider adding these checks as constraints
- # within the transistion itself
- nameserverList = self.nameservers
logger.info("Changing to ready state")
- if len(nameserverList) < 2 or len(nameserverList) > 13:
- raise ValueError("Not ready to become created, cannot transition yet")
logger.info("able to transition to ready state")
+ @transition(
+ field="state",
+ source=[State.READY],
+ target=State.DNS_NEEDED,
+ # conditions=[is_dns_needed]
+ )
+ def dns_needed(self):
+ """Transition to the DNS_NEEDED state
+ domain should NOT have nameservers but
+ SHOULD have all contacts
+ Going to check nameservers and will
+ result in an EPP call
+ """
+ logger.info("Changing to DNS_NEEDED state")
+ logger.info("able to transition to DNS_NEEDED state")
+
def _disclose_fields(self, contact: PublicContact):
"""creates a disclose object that can be added to a contact Create using
.disclose= on the command before sending.
@@ -1197,6 +1529,10 @@ class Domain(TimeStampedModel, DomainHelper):
raise e
+ def is_ipv6(self, ip: str):
+ ip_addr = ipaddress.ip_address(ip)
+ return ip_addr.version == 6
+
def _fetch_hosts(self, host_data):
"""Fetch host info."""
hosts = []
@@ -1214,84 +1550,214 @@ class Domain(TimeStampedModel, DomainHelper):
hosts.append({k: v for k, v in host.items() if v is not ...})
return hosts
- def _update_or_create_host(self, host):
- raise NotImplementedError()
+ def _convert_ips(self, ip_list: list[str]):
+ """Convert Ips to a list of epp.Ip objects
+ use when sending update host command.
+ if there are no ips an empty list will be returned
- def _delete_host(self, host):
- raise NotImplementedError()
+ Args:
+ ip_list (list[str]): the new list of ips, may be empty
+ Returns:
+ edited_ip_list (list[epp.Ip]): list of epp.ip objects ready to
+ be sent to the registry
+ """
+ edited_ip_list = []
+ if ip_list is None:
+ return []
+
+ for ip_addr in ip_list:
+ if self.is_ipv6(ip_addr):
+ edited_ip_list.append(epp.Ip(addr=ip_addr, ip="v6"))
+ else: # default ip addr is v4
+ edited_ip_list.append(epp.Ip(addr=ip_addr))
+
+ return edited_ip_list
+
+ def _update_host(self, nameserver: str, ip_list: list[str], old_ip_list: list[str]):
+ """Update an existing host object in EPP. Sends the update host command
+ can result in a RegistryError
+ Args:
+ nameserver (str): nameserver or subdomain
+ ip_list (list[str]): the new list of ips, may be empty
+ old_ip_list (list[str]): the old ip list, may also be empty
+
+ Returns:
+ errorCode (int): one of ErrorCode enum type values
+
+ """
+ try:
+ if (
+ ip_list is None
+ or len(ip_list) == 0
+ and isinstance(old_ip_list, list)
+ and len(old_ip_list) != 0
+ ):
+ return ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY
+
+ added_ip_list = set(ip_list).difference(old_ip_list)
+ removed_ip_list = set(old_ip_list).difference(ip_list)
+
+ request = commands.UpdateHost(
+ name=nameserver,
+ add=self._convert_ips(list(added_ip_list)),
+ rem=self._convert_ips(list(removed_ip_list)),
+ )
+ response = registry.send(request, cleaned=True)
+ logger.info("_update_host()-> sending req as %s" % request)
+ return response.code
+ except RegistryError as e:
+ logger.error("Error _update_host, code was %s error was %s" % (e.code, e))
+ return e.code
+
+ def addAndRemoveHostsFromDomain(
+ self, hostsToAdd: list[str], hostsToDelete: list[str]
+ ):
+ """sends an UpdateDomain message to the registry with the hosts provided
+ Args:
+ hostsToDelete (list[epp.HostObjSet])- list of host objects to delete
+ hostsToAdd (list[epp.HostObjSet])- list of host objects to add
+ Returns:
+ response code (int)- RegistryErrorCode integer value
+ defaults to return COMMAND_COMPLETED_SUCCESSFULLY
+ if there is nothing to add or delete
+ """
+
+ if hostsToAdd == [] and hostsToDelete == []:
+ return ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY
+
+ try:
+ updateReq = commands.UpdateDomain(
+ name=self.name, rem=hostsToDelete, add=hostsToAdd
+ )
+
+ logger.info(
+ "addAndRemoveHostsFromDomain()-> sending update domain req as %s"
+ % updateReq
+ )
+ response = registry.send(updateReq, cleaned=True)
+
+ return response.code
+ except RegistryError as e:
+ logger.error(
+ "Error addAndRemoveHostsFromDomain, code was %s error was %s"
+ % (e.code, e)
+ )
+ return e.code
+
+ def _delete_hosts_if_not_used(self, hostsToDelete: list[str]):
+ """delete the host object in registry,
+ will only delete the host object, if it's not being used by another domain
+ Performs just the DeleteHost epp call
+ Supresses regstry error, as registry can disallow delete for various reasons
+ Args:
+ hostsToDelete (list[str])- list of nameserver/host names to remove
+ Returns:
+ None
+
+ """
+ try:
+ for nameserver in hostsToDelete:
+ deleteHostReq = commands.DeleteHost(name=nameserver)
+ registry.send(deleteHostReq, cleaned=True)
+ logger.info(
+ "_delete_hosts_if_not_used()-> sending delete host req as %s"
+ % deleteHostReq
+ )
+
+ except RegistryError as e:
+ if e.code == ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION:
+ logger.info(
+ "Did not remove host %s because it is in use on another domain."
+ % nameserver
+ )
+ else:
+ logger.error(
+ "Error _delete_hosts_if_not_used, code was %s error was %s"
+ % (e.code, e)
+ )
def _fetch_cache(self, fetch_hosts=False, fetch_contacts=False):
"""Contact registry for info about a domain."""
try:
# get info from registry
- dataResponse = self._get_or_create_domain()
- data = dataResponse.res_data[0]
- # extract properties from response
- # (Ellipsis is used to mean "null")
- cache = {
- "auth_info": getattr(data, "auth_info", ...),
- "_contacts": getattr(data, "contacts", ...),
- "cr_date": getattr(data, "cr_date", ...),
- "ex_date": getattr(data, "ex_date", ...),
- "_hosts": getattr(data, "hosts", ...),
- "name": getattr(data, "name", ...),
- "registrant": getattr(data, "registrant", ...),
- "statuses": getattr(data, "statuses", ...),
- "tr_date": getattr(data, "tr_date", ...),
- "up_date": getattr(data, "up_date", ...),
- }
- # remove null properties (to distinguish between "a value of None" and null)
- cleaned = {k: v for k, v in cache.items() if v is not ...}
+ data_response = self._get_or_create_domain()
+ cache = self._extract_data_from_response(data_response)
+
+ # remove null properties (to distinguish between "a value of None" and null)
+ cleaned = self._remove_null_properties(cache)
- # statuses can just be a list no need to keep the epp object
if "statuses" in cleaned:
cleaned["statuses"] = [status.state for status in cleaned["statuses"]]
- # get extensions info, if there is any
- # DNSSECExtension is one possible extension, make sure to handle
- # only DNSSECExtension and not other type extensions
- returned_extensions = dataResponse.extensions
- cleaned["dnssecdata"] = None
- for extension in returned_extensions:
- if isinstance(extension, extensions.DNSSECExtension):
- cleaned["dnssecdata"] = extension
+ cleaned["dnssecdata"] = self._get_dnssec_data(data_response.extensions)
+
# Capture and store old hosts and contacts from cache if they exist
old_cache_hosts = self._cache.get("hosts")
old_cache_contacts = self._cache.get("contacts")
- # get contact info, if there are any
- if (
- fetch_contacts
- and "_contacts" in cleaned
- and isinstance(cleaned["_contacts"], list)
- and len(cleaned["_contacts"]) > 0
- ):
- cleaned["contacts"] = self._fetch_contacts(cleaned["_contacts"])
- # We're only getting contacts, so retain the old
- # hosts that existed in cache (if they existed)
- # and pass them along.
+ if fetch_contacts:
+ cleaned["contacts"] = self._get_contacts(cleaned.get("_contacts", []))
if old_cache_hosts is not None:
+ logger.debug("resetting cleaned['hosts'] to old_cache_hosts")
cleaned["hosts"] = old_cache_hosts
- # get nameserver info, if there are any
- if (
- fetch_hosts
- and "_hosts" in cleaned
- and isinstance(cleaned["_hosts"], list)
- and len(cleaned["_hosts"])
- ):
- cleaned["hosts"] = self._fetch_hosts(cleaned["_hosts"])
- # We're only getting hosts, so retain the old
- # contacts that existed in cache (if they existed)
- # and pass them along.
+ if fetch_hosts:
+ cleaned["hosts"] = self._get_hosts(cleaned.get("_hosts", []))
if old_cache_contacts is not None:
cleaned["contacts"] = old_cache_contacts
- # replace the prior cache with new data
+
self._cache = cleaned
except RegistryError as e:
logger.error(e)
+ def _extract_data_from_response(self, data_response):
+ data = data_response.res_data[0]
+ return {
+ "auth_info": getattr(data, "auth_info", ...),
+ "_contacts": getattr(data, "contacts", ...),
+ "cr_date": getattr(data, "cr_date", ...),
+ "ex_date": getattr(data, "ex_date", ...),
+ "_hosts": getattr(data, "hosts", ...),
+ "name": getattr(data, "name", ...),
+ "registrant": getattr(data, "registrant", ...),
+ "statuses": getattr(data, "statuses", ...),
+ "tr_date": getattr(data, "tr_date", ...),
+ "up_date": getattr(data, "up_date", ...),
+ }
+
+ def _remove_null_properties(self, cache):
+ return {k: v for k, v in cache.items() if v is not ...}
+
+ def _get_dnssec_data(self, response_extensions):
+ # get extensions info, if there is any
+ # DNSSECExtension is one possible extension, make sure to handle
+ # only DNSSECExtension and not other type extensions
+ dnssec_data = None
+ for extension in response_extensions:
+ if isinstance(extension, extensions.DNSSECExtension):
+ dnssec_data = extension
+ return dnssec_data
+
+ def _get_contacts(self, contacts):
+ choices = PublicContact.ContactTypeChoices
+ # We expect that all these fields get populated,
+ # so we can create these early, rather than waiting.
+ cleaned_contacts = {
+ choices.ADMINISTRATIVE: None,
+ choices.SECURITY: None,
+ choices.TECHNICAL: None,
+ }
+ if contacts and isinstance(contacts, list) and len(contacts) > 0:
+ cleaned_contacts = self._fetch_contacts(contacts)
+ return cleaned_contacts
+
+ def _get_hosts(self, hosts):
+ cleaned_hosts = []
+ if hosts and isinstance(hosts, list):
+ cleaned_hosts = self._fetch_hosts(hosts)
+ return cleaned_hosts
+
def _get_or_create_public_contact(self, public_contact: PublicContact):
"""Tries to find a PublicContact object in our DB.
If it can't, it'll create it. Returns PublicContact"""
diff --git a/src/registrar/models/transition_domain.py b/src/registrar/models/transition_domain.py
index 203795925..232fd9033 100644
--- a/src/registrar/models/transition_domain.py
+++ b/src/registrar/models/transition_domain.py
@@ -5,7 +5,7 @@ from .utility.time_stamped_model import TimeStampedModel
class StatusChoices(models.TextChoices):
READY = "ready", "Ready"
- HOLD = "hold", "Hold"
+ ON_HOLD = "on hold", "On Hold"
class TransitionDomain(TimeStampedModel):
@@ -13,6 +13,10 @@ class TransitionDomain(TimeStampedModel):
state of a domain upon transition between registry
providers"""
+ # This is necessary to expose the enum to external
+ # classes that import TransitionDomain
+ StatusChoices = StatusChoices
+
username = models.TextField(
null=False,
blank=False,
diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py
index 5b04c628d..acf59cb68 100644
--- a/src/registrar/models/user.py
+++ b/src/registrar/models/user.py
@@ -81,3 +81,9 @@ class User(AbstractUser):
logger.warn(
"Failed to retrieve invitation %s", invitation, exc_info=True
)
+
+ class Meta:
+ permissions = [
+ ("analyst_access_permission", "Analyst Access Permission"),
+ ("full_access_permission", "Full Access Permission"),
+ ]
diff --git a/src/registrar/models/user_group.py b/src/registrar/models/user_group.py
new file mode 100644
index 000000000..5cdb1f2ec
--- /dev/null
+++ b/src/registrar/models/user_group.py
@@ -0,0 +1,137 @@
+from django.contrib.auth.models import Group
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class UserGroup(Group):
+ class Meta:
+ verbose_name = "User group"
+ verbose_name_plural = "User groups"
+
+ def create_cisa_analyst_group(apps, schema_editor):
+ """This method gets run from a data migration."""
+
+ # Hard to pass self to these methods as the calls from migrations
+ # are only expecting apps and schema_editor, so we'll just define
+ # apps, schema_editor in the local scope instead
+ CISA_ANALYST_GROUP_PERMISSIONS = [
+ {
+ "app_label": "auditlog",
+ "model": "logentry",
+ "permissions": ["view_logentry"],
+ },
+ {
+ "app_label": "registrar",
+ "model": "contact",
+ "permissions": ["view_contact"],
+ },
+ {
+ "app_label": "registrar",
+ "model": "domaininformation",
+ "permissions": ["change_domaininformation"],
+ },
+ {
+ "app_label": "registrar",
+ "model": "domainapplication",
+ "permissions": ["change_domainapplication"],
+ },
+ {
+ "app_label": "registrar",
+ "model": "domain",
+ "permissions": ["view_domain"],
+ },
+ {
+ "app_label": "registrar",
+ "model": "draftdomain",
+ "permissions": ["change_draftdomain"],
+ },
+ {
+ "app_label": "registrar",
+ "model": "user",
+ "permissions": ["analyst_access_permission", "change_user"],
+ },
+ {
+ "app_label": "registrar",
+ "model": "domaininvitation",
+ "permissions": ["add_domaininvitation", "view_domaininvitation"],
+ },
+ ]
+
+ # Avoid error: You can't execute queries until the end
+ # of the 'atomic' block.
+ # From django docs:
+ # https://docs.djangoproject.com/en/4.2/topics/migrations/#data-migrations
+ # We can’t import the Person model directly as it may be a newer
+ # version than this migration expects. We use the historical version.
+ ContentType = apps.get_model("contenttypes", "ContentType")
+ Permission = apps.get_model("auth", "Permission")
+ UserGroup = apps.get_model("registrar", "UserGroup")
+
+ logger.info("Going to create the Analyst Group")
+ try:
+ cisa_analysts_group, _ = UserGroup.objects.get_or_create(
+ name="cisa_analysts_group",
+ )
+
+ cisa_analysts_group.permissions.clear()
+
+ for permission in CISA_ANALYST_GROUP_PERMISSIONS:
+ app_label = permission["app_label"]
+ model_name = permission["model"]
+ permissions = permission["permissions"]
+
+ # Retrieve the content type for the app and model
+ content_type = ContentType.objects.get(
+ app_label=app_label, model=model_name
+ )
+
+ # Retrieve the permissions based on their codenames
+ permissions = Permission.objects.filter(
+ content_type=content_type, codename__in=permissions
+ )
+
+ # Assign the permissions to the group
+ cisa_analysts_group.permissions.add(*permissions)
+
+ # Convert the permissions QuerySet to a list of codenames
+ permission_list = list(permissions.values_list("codename", flat=True))
+
+ logger.debug(
+ app_label
+ + " | "
+ + model_name
+ + " | "
+ + ", ".join(permission_list)
+ + " added to group "
+ + cisa_analysts_group.name
+ )
+
+ cisa_analysts_group.save()
+ logger.debug(
+ "CISA Analyt permissions added to group " + cisa_analysts_group.name
+ )
+ except Exception as e:
+ logger.error(f"Error creating analyst permissions group: {e}")
+
+ def create_full_access_group(apps, schema_editor):
+ """This method gets run from a data migration."""
+
+ Permission = apps.get_model("auth", "Permission")
+ UserGroup = apps.get_model("registrar", "UserGroup")
+
+ logger.info("Going to create the Full Access Group")
+ try:
+ full_access_group, _ = UserGroup.objects.get_or_create(
+ name="full_access_group",
+ )
+ # Get all available permissions
+ all_permissions = Permission.objects.all()
+
+ # Assign all permissions to the group
+ full_access_group.permissions.add(*all_permissions)
+
+ full_access_group.save()
+ logger.debug("All permissions added to group " + full_access_group.name)
+ except Exception as e:
+ logger.error(f"Error creating full access group: {e}")
diff --git a/src/registrar/models/utility/contact_error.py b/src/registrar/models/utility/contact_error.py
index 93084eca2..cf392cb6e 100644
--- a/src/registrar/models/utility/contact_error.py
+++ b/src/registrar/models/utility/contact_error.py
@@ -1,2 +1,51 @@
+from enum import IntEnum
+
+
+class ContactErrorCodes(IntEnum):
+ """Used in the ContactError class for
+ error mapping.
+
+ Overview of contact error codes:
+ - 2000 CONTACT_TYPE_NONE
+ - 2001 CONTACT_ID_NONE
+ - 2002 CONTACT_ID_INVALID_LENGTH
+ - 2003 CONTACT_INVALID_TYPE
+ """
+
+ CONTACT_TYPE_NONE = 2000
+ CONTACT_ID_NONE = 2001
+ CONTACT_ID_INVALID_LENGTH = 2002
+ CONTACT_INVALID_TYPE = 2003
+ CONTACT_NOT_FOUND = 2004
+
+
class ContactError(Exception):
- ...
+ """
+ Overview of contact error codes:
+ - 2000 CONTACT_TYPE_NONE
+ - 2001 CONTACT_ID_NONE
+ - 2002 CONTACT_ID_INVALID_LENGTH
+ - 2003 CONTACT_INVALID_TYPE
+ - 2004 CONTACT_NOT_FOUND
+ """
+
+ # For linter
+ _contact_id_error = "contact_id has an invalid length. Cannot exceed 16 characters."
+ _contact_invalid_error = "Contact must be of type InfoContactResultData"
+ _contact_not_found_error = "No contact was found in cache or the registry"
+ _error_mapping = {
+ ContactErrorCodes.CONTACT_TYPE_NONE: "contact_type is None",
+ ContactErrorCodes.CONTACT_ID_NONE: "contact_id is None",
+ ContactErrorCodes.CONTACT_ID_INVALID_LENGTH: _contact_id_error,
+ ContactErrorCodes.CONTACT_INVALID_TYPE: _contact_invalid_error,
+ ContactErrorCodes.CONTACT_NOT_FOUND: _contact_not_found_error,
+ }
+
+ def __init__(self, *args, code=None, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.code = code
+ if self.code in self._error_mapping:
+ self.message = self._error_mapping.get(self.code)
+
+ def __str__(self):
+ return f"{self.message}"
diff --git a/src/registrar/templates/django/admin/domain_change_form.html b/src/registrar/templates/django/admin/domain_change_form.html
index ac26fc922..2ed3d7532 100644
--- a/src/registrar/templates/django/admin/domain_change_form.html
+++ b/src/registrar/templates/django/admin/domain_change_form.html
@@ -13,10 +13,10 @@
{% elif original.state == original.State.ON_HOLD %}
{% endif %}
-
-
+
+
{% if original.state != original.State.DELETED %}
-
+
{% endif %}
{{ block.super }}
diff --git a/src/registrar/templates/domain_detail.html b/src/registrar/templates/domain_detail.html
index 6a700b393..e0d672093 100644
--- a/src/registrar/templates/domain_detail.html
+++ b/src/registrar/templates/domain_detail.html
@@ -27,7 +27,7 @@
- {% url 'domain-nameservers' pk=domain.id as url %}
+ {% url 'domain-dns-nameservers' pk=domain.id as url %}
{% if domain.nameservers|length > 0 %}
{% include "includes/summary_item.html" with title='DNS name servers' value=domain.nameservers list='true' edit_link=url %}
{% else %}
@@ -46,8 +46,11 @@
{% include "includes/summary_item.html" with title='Your contact information' value=request.user.contact contact='true' edit_link=url %}
{% url 'domain-security-email' pk=domain.id as url %}
- {% include "includes/summary_item.html" with title='Security email' value=domain.security_email edit_link=url %}
-
+ {% if security_email is not None and security_email != default_security_email%}
+ {% include "includes/summary_item.html" with title='Security email' value=security_email edit_link=url %}
+ {% else %}
+ {% include "includes/summary_item.html" with title='Security email' value='None provided' edit_link=url %}
+ {% endif %}
{% url 'domain-users' pk=domain.id as url %}
{% include "includes/summary_item.html" with title='User management' users='true' list=True value=domain.permissions.all edit_link=url %}
diff --git a/src/registrar/templates/domain_dns.html b/src/registrar/templates/domain_dns.html
new file mode 100644
index 000000000..b16c1cb8b
--- /dev/null
+++ b/src/registrar/templates/domain_dns.html
@@ -0,0 +1,20 @@
+{% extends "domain_base.html" %}
+{% load static field_helpers url_helpers %}
+
+{% block title %}DNS | {{ domain.name }} | {% endblock %}
+
+{% block domain_content %}
+
+
DNS
+
+
The Domain Name System (DNS) is the internet service that translates your domain name into an IP address. Before your .gov domain can be used, you'll need to connect it to your DNS hosting service and provide us with your name server information.
+
+
You can enter your name servers, as well as other DNS-related information, in the following sections:
+
+ {% url 'domain-dns-nameservers' pk=domain.id as url %}
+
DNSSEC, or DNS Security Extensions, is additional security layer to protect your domain. Enabling DNSSEC ensures that when someone visits your domain, they can be certain that it's connecting to the correct server, preventing potential hijacking or tampering with your domain's records.
+
+
+
+
+ {% include 'includes/modal.html' with modal_heading="Are you sure you want to continue?" modal_description="Your DNSSEC records will be deleted from the registry." modal_button=modal_button|safe %}
+
+
+{% endblock %} {# domain_content #}
diff --git a/src/registrar/templates/domain_dsdata.html b/src/registrar/templates/domain_dsdata.html
new file mode 100644
index 000000000..ca4dce783
--- /dev/null
+++ b/src/registrar/templates/domain_dsdata.html
@@ -0,0 +1,123 @@
+{% extends "domain_base.html" %}
+{% load static field_helpers url_helpers %}
+
+{% block title %}DS Data | {{ domain.name }} | {% endblock %}
+
+{% block domain_content %}
+ {% for form in formset %}
+ {% include "includes/form_errors.html" with form=form %}
+ {% endfor %}
+
+ {% if domain.dnssecdata is None and not dnssec_ds_confirmed %}
+
+
+ You have no DS Data added. Enable DNSSEC by adding DS Data or return to the DNSSEC page and click 'enable.'
+
+
+ {% endif %}
+
+
DS Data
+
+ {% if domain.dnssecdata is not None and domain.dnssecdata.keyData is not None %}
+
+
+
Warning, you cannot add DS Data
+
+ You cannot add DS Data because you have already added Key Data. Delete your Key Data records in order to add DS Data.
+
+
+
+ {% elif not dnssec_ds_confirmed %}
+
In order to enable DNSSEC, you must first configure it with your DNS hosting service.
+
Enter the values given by your DNS provider for DS Data.
+
Required fields are marked with an asterisk (*).
+
+ {% else %}
+
+
Enter the values given by your DNS provider for DS Data.
+ {% include "includes/required_fields.html" %}
+
+
+
+
+ {% endif %}
+{% endblock %} {# domain_content #}
diff --git a/src/registrar/templates/domain_keydata.html b/src/registrar/templates/domain_keydata.html
new file mode 100644
index 000000000..167d86370
--- /dev/null
+++ b/src/registrar/templates/domain_keydata.html
@@ -0,0 +1,110 @@
+{% extends "domain_base.html" %}
+{% load static field_helpers url_helpers %}
+
+{% block title %}Key Data | {{ domain.name }} | {% endblock %}
+
+{% block domain_content %}
+ {% for form in formset %}
+ {% include "includes/form_errors.html" with form=form %}
+ {% endfor %}
+
+
Key Data
+
+ {% if domain.dnssecdata is not None and domain.dnssecdata.dsData is not None %}
+
+
+
Warning, you cannot add Key Data
+
+ You cannot add Key Data because you have already added DS Data. Delete your DS Data records in order to add Key Data.
+
+
+
+ {% elif not dnssec_key_confirmed %}
+
In order to enable DNSSEC and add DS records, you must first configure it with your DNS hosting service. Your configuration will determine whether you need to add DS Data or Key Data. Contact your DNS hosting provider if you are unsure which record type to add.
+
+ {% else %}
+
+
Enter the values given by your DNS provider for DS Key Data.
+ {% include "includes/required_fields.html" %}
+
+
+
+
+ {% endif %}
+{% endblock %} {# domain_content #}
diff --git a/src/registrar/templates/domain_nameservers.html b/src/registrar/templates/domain_nameservers.html
index 2dabac1af..596eec524 100644
--- a/src/registrar/templates/domain_nameservers.html
+++ b/src/registrar/templates/domain_nameservers.html
@@ -1,7 +1,7 @@
{% extends "domain_base.html" %}
{% load static field_helpers%}
-{% block title %}Domain name servers | {{ domain.name }} | {% endblock %}
+{% block title %}DNS name servers | {{ domain.name }} | {% endblock %}
{% block domain_content %}
{# this is right after the messages block in the parent template #}
@@ -9,7 +9,7 @@
{% include "includes/form_errors.html" with form=form %}
{% endfor %}
-
Domain name servers
+
DNS name servers
Before your domain can be used we'll need information about your domain
name servers.