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/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/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/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 79fe46add..8abc7e14a 100644
--- a/src/registrar/forms/domain.py
+++ b/src/registrar/forms/domain.py
@@ -1,23 +1,27 @@
"""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", strip=True)
@@ -31,7 +35,6 @@ NameserverFormset = formset_factory(
class ContactForm(forms.ModelForm):
-
"""Form for updating contacts."""
class Meta:
@@ -62,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", required=False)
class DomainOrgNameAddressForm(forms.ModelForm):
-
"""Form for updating the organization name and mailing address."""
zipcode = forms.CharField(
@@ -140,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_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/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/domain.py b/src/registrar/models/domain.py
index 7aaeebe21..ad1fbebd6 100644
--- a/src/registrar/models/domain.py
+++ b/src/registrar/models/domain.py
@@ -4,6 +4,7 @@ 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
@@ -259,7 +260,6 @@ class Domain(TimeStampedModel, DomainHelper):
"""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)
@@ -456,24 +456,140 @@ class Domain(TimeStampedModel, DomainHelper):
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
@@ -701,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")
@@ -1128,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
@@ -1191,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(
@@ -1558,74 +1682,84 @@ class Domain(TimeStampedModel, DomainHelper):
"""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/templates/domain_detail.html b/src/registrar/templates/domain_detail.html
index bcf775fe5..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 %}
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.