mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-05-14 16:47:02 +02:00
merge main
This commit is contained in:
commit
4f37636382
40 changed files with 4426 additions and 3742 deletions
4
.github/pull_request_template.md
vendored
4
.github/pull_request_template.md
vendored
|
@ -48,7 +48,7 @@ All other changes require just a single approving review.-->
|
||||||
- [ ] Added at least 2 developers as PR reviewers (only 1 will need to approve)
|
- [ ] Added at least 2 developers as PR reviewers (only 1 will need to approve)
|
||||||
- [ ] Messaged on Slack or in standup to notify the team that a PR is ready for review
|
- [ ] Messaged on Slack or in standup to notify the team that a PR is ready for review
|
||||||
- [ ] Changes to “how we do things” are documented in READMEs and or onboarding guide
|
- [ ] Changes to “how we do things” are documented in READMEs and or onboarding guide
|
||||||
- [ ] If any model was updated to modify/add/delete columns, makemigrations was ran and the assoicated migrations file has been commited.
|
- [ ] If any model was updated to modify/add/delete columns, makemigrations was ran and the associated migrations file has been commited.
|
||||||
|
|
||||||
#### Ensured code standards are met (Original Developer)
|
#### Ensured code standards are met (Original Developer)
|
||||||
|
|
||||||
|
@ -72,7 +72,7 @@ All other changes require just a single approving review.-->
|
||||||
- [ ] Reviewed this code and left comments
|
- [ ] Reviewed this code and left comments
|
||||||
- [ ] Checked that all code is adequately covered by tests
|
- [ ] Checked that all code is adequately covered by tests
|
||||||
- [ ] Made it clear which comments need to be addressed before this work is merged
|
- [ ] Made it clear which comments need to be addressed before this work is merged
|
||||||
- [ ] If any model was updated to modify/add/delete columns, makemigrations was ran and the assoicated migrations file has been commited.
|
- [ ] If any model was updated to modify/add/delete columns, makemigrations was ran and the associated migrations file has been commited.
|
||||||
|
|
||||||
#### Ensured code standards are met (Code reviewer)
|
#### Ensured code standards are met (Code reviewer)
|
||||||
|
|
||||||
|
|
2
.github/workflows/test.yaml
vendored
2
.github/workflows/test.yaml
vendored
|
@ -36,7 +36,7 @@ jobs:
|
||||||
|
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
working-directory: ./src
|
working-directory: ./src
|
||||||
run: docker compose run app python manage.py test
|
run: docker compose run app python manage.py test --parallel
|
||||||
|
|
||||||
django-migrations-complete:
|
django-migrations-complete:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
@ -237,3 +237,7 @@ Bugs on production software need to be documented quickly and triaged to determi
|
||||||
3. In the case where the engineering lead is is unresponsive or unavailable to assign the ticket immediately, the product team will make sure an engineer volunteers or is assigned to the ticket/PR review ASAP.
|
3. In the case where the engineering lead is is unresponsive or unavailable to assign the ticket immediately, the product team will make sure an engineer volunteers or is assigned to the ticket/PR review ASAP.
|
||||||
4. Once done, the developer must make a PR and should tag the assigned PR reviewers in our Slack dev channel stating that the PR is now waiting on their review. These reviewers should drop other tasks in order to review this promptly.
|
4. Once done, the developer must make a PR and should tag the assigned PR reviewers in our Slack dev channel stating that the PR is now waiting on their review. These reviewers should drop other tasks in order to review this promptly.
|
||||||
5. See the the section above on [Making bug fixes on stable](#making-bug-fixes-on-stable-during-production) for how to push changes to stable once the PR is approved
|
5. See the the section above on [Making bug fixes on stable](#making-bug-fixes-on-stable-during-production) for how to push changes to stable once the PR is approved
|
||||||
|
|
||||||
|
# Investigating and monitoring the health of the Registrar
|
||||||
|
|
||||||
|
Sometimes, we may want individuals to routinely monitor the Registrar's health, such as after big feature launches. The cadence of such monitoring and what we look for is subject to change and is instead documented in [Checklist for production verification document](https://docs.google.com/document/d/15b_qwEZMiL76BHeRHnznV1HxDQcxNRt--vPSEfixBOI). All project team members should feel free to suggest edits to this document and should refer to it if production-level monitoring is underway.
|
|
@ -626,7 +626,7 @@ class DomainInformationAdmin(ListHeaderAdmin):
|
||||||
search_help_text = "Search by domain."
|
search_help_text = "Search by domain."
|
||||||
|
|
||||||
fieldsets = [
|
fieldsets = [
|
||||||
(None, {"fields": ["creator", "domain_application"]}),
|
(None, {"fields": ["creator", "domain_application", "notes"]}),
|
||||||
(
|
(
|
||||||
"Type of organization",
|
"Type of organization",
|
||||||
{
|
{
|
||||||
|
@ -793,7 +793,7 @@ class DomainApplicationAdmin(ListHeaderAdmin):
|
||||||
# Detail view
|
# Detail view
|
||||||
form = DomainApplicationAdminForm
|
form = DomainApplicationAdminForm
|
||||||
fieldsets = [
|
fieldsets = [
|
||||||
(None, {"fields": ["status", "investigator", "creator", "approved_domain"]}),
|
(None, {"fields": ["status", "investigator", "creator", "approved_domain", "notes"]}),
|
||||||
(
|
(
|
||||||
"Type of organization",
|
"Type of organization",
|
||||||
{
|
{
|
||||||
|
@ -1047,6 +1047,13 @@ class DomainAdmin(ListHeaderAdmin):
|
||||||
"deleted",
|
"deleted",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
{"fields": ["name", "state", "expiration_date", "first_ready", "deleted"]},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# this ordering effects the ordering of results
|
# this ordering effects the ordering of results
|
||||||
# in autocomplete_fields for domain
|
# in autocomplete_fields for domain
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
@ -1295,7 +1302,7 @@ class DraftDomainAdmin(ListHeaderAdmin):
|
||||||
search_help_text = "Search by draft domain name."
|
search_help_text = "Search by draft domain name."
|
||||||
|
|
||||||
|
|
||||||
class VeryImportantPersonAdmin(ListHeaderAdmin):
|
class VerifiedByStaffAdmin(ListHeaderAdmin):
|
||||||
list_display = ("email", "requestor", "truncated_notes", "created_at")
|
list_display = ("email", "requestor", "truncated_notes", "created_at")
|
||||||
search_fields = ["email"]
|
search_fields = ["email"]
|
||||||
search_help_text = "Search by email."
|
search_help_text = "Search by email."
|
||||||
|
@ -1338,4 +1345,4 @@ admin.site.register(models.Website, WebsiteAdmin)
|
||||||
admin.site.register(models.PublicContact, AuditedAdmin)
|
admin.site.register(models.PublicContact, AuditedAdmin)
|
||||||
admin.site.register(models.DomainApplication, DomainApplicationAdmin)
|
admin.site.register(models.DomainApplication, DomainApplicationAdmin)
|
||||||
admin.site.register(models.TransitionDomain, TransitionDomainAdmin)
|
admin.site.register(models.TransitionDomain, TransitionDomainAdmin)
|
||||||
admin.site.register(models.VeryImportantPerson, VeryImportantPersonAdmin)
|
admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin)
|
||||||
|
|
|
@ -283,19 +283,20 @@ function enableRelatedWidgetButtons(changeLink, deleteLink, viewLink, elementPk,
|
||||||
(function (){
|
(function (){
|
||||||
|
|
||||||
// Get the current date in the format YYYY-MM-DD
|
// Get the current date in the format YYYY-MM-DD
|
||||||
var currentDate = new Date().toISOString().split('T')[0];
|
let currentDate = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
// Default the value of the start date input field to the current date
|
// Default the value of the start date input field to the current date
|
||||||
let startDateInput =document.getElementById('start');
|
let startDateInput =document.getElementById('start');
|
||||||
startDateInput.value = currentDate;
|
|
||||||
|
|
||||||
// Default the value of the end date input field to the current date
|
// Default the value of the end date input field to the current date
|
||||||
let endDateInput =document.getElementById('end');
|
let endDateInput =document.getElementById('end');
|
||||||
endDateInput.value = currentDate;
|
|
||||||
|
|
||||||
let exportGrowthReportButton = document.getElementById('exportLink');
|
let exportGrowthReportButton = document.getElementById('exportLink');
|
||||||
|
|
||||||
if (exportGrowthReportButton) {
|
if (exportGrowthReportButton) {
|
||||||
|
startDateInput.value = currentDate;
|
||||||
|
endDateInput.value = currentDate;
|
||||||
|
|
||||||
exportGrowthReportButton.addEventListener('click', function() {
|
exportGrowthReportButton.addEventListener('click', function() {
|
||||||
// Get the selected start and end dates
|
// Get the selected start and end dates
|
||||||
let startDate = startDateInput.value;
|
let startDate = startDateInput.value;
|
||||||
|
|
|
@ -130,7 +130,7 @@ function inlineToast(el, id, style, msg) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _checkDomainAvailability(el) {
|
function checkDomainAvailability(el) {
|
||||||
const callback = (response) => {
|
const callback = (response) => {
|
||||||
toggleInputValidity(el, (response && response.available), msg=response.message);
|
toggleInputValidity(el, (response && response.available), msg=response.message);
|
||||||
announce(el.id, response.message);
|
announce(el.id, response.message);
|
||||||
|
@ -154,9 +154,6 @@ function _checkDomainAvailability(el) {
|
||||||
fetchJSON(`available/?domain=${el.value}`, callback);
|
fetchJSON(`available/?domain=${el.value}`, callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Call the API to see if the domain is good. */
|
|
||||||
const checkDomainAvailability = debounce(_checkDomainAvailability);
|
|
||||||
|
|
||||||
/** Hides the toast message and clears the aira live region. */
|
/** Hides the toast message and clears the aira live region. */
|
||||||
function clearDomainAvailability(el) {
|
function clearDomainAvailability(el) {
|
||||||
el.classList.remove('usa-input--success');
|
el.classList.remove('usa-input--success');
|
||||||
|
@ -206,13 +203,33 @@ function handleInputValidation(e) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** On button click, handles running any associated validators. */
|
/** On button click, handles running any associated validators. */
|
||||||
function handleValidationClick(e) {
|
function validateFieldInput(e) {
|
||||||
const attribute = e.target.getAttribute("validate-for") || "";
|
const attribute = e.target.getAttribute("validate-for") || "";
|
||||||
if (!attribute.length) return;
|
if (!attribute.length) return;
|
||||||
const input = document.getElementById(attribute);
|
const input = document.getElementById(attribute);
|
||||||
|
removeFormErrors(input, true);
|
||||||
runValidators(input);
|
runValidators(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function validateFormsetInputs(e, availabilityButton) {
|
||||||
|
|
||||||
|
// Collect input IDs from the repeatable forms
|
||||||
|
let inputs = Array.from(document.querySelectorAll('.repeatable-form input'))
|
||||||
|
|
||||||
|
// Run validators for each input
|
||||||
|
inputs.forEach(input => {
|
||||||
|
removeFormErrors(input, true);
|
||||||
|
runValidators(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set the validate-for attribute on the button with the collected input IDs
|
||||||
|
// Not needed for functionality but nice for accessibility
|
||||||
|
inputs = inputs.map(input => input.id).join(', ');
|
||||||
|
availabilityButton.setAttribute('validate-for', inputs);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>>
|
// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>>
|
||||||
// Initialization code.
|
// Initialization code.
|
||||||
|
|
||||||
|
@ -232,14 +249,64 @@ function handleValidationClick(e) {
|
||||||
for(const input of needsValidation) {
|
for(const input of needsValidation) {
|
||||||
input.addEventListener('input', handleInputValidation);
|
input.addEventListener('input', handleInputValidation);
|
||||||
}
|
}
|
||||||
|
const alternativeDomainsAvailability = document.getElementById('validate-alt-domains-availability');
|
||||||
const activatesValidation = document.querySelectorAll('[validate-for]');
|
const activatesValidation = document.querySelectorAll('[validate-for]');
|
||||||
|
|
||||||
for(const button of activatesValidation) {
|
for(const button of activatesValidation) {
|
||||||
button.addEventListener('click', handleValidationClick);
|
// Adds multi-field validation for alternative domains
|
||||||
|
if (button === alternativeDomainsAvailability) {
|
||||||
|
button.addEventListener('click', (e) => {
|
||||||
|
validateFormsetInputs(e, alternativeDomainsAvailability)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
button.addEventListener('click', validateFieldInput);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete method for formsets that diff in the view and delete in the model (Nameservers, DS Data)
|
* Removes form errors surrounding a form input
|
||||||
|
*/
|
||||||
|
function removeFormErrors(input, removeStaleAlerts=false){
|
||||||
|
// Remove error message
|
||||||
|
let errorMessage = document.getElementById(`${input.id}__error-message`);
|
||||||
|
if (errorMessage) {
|
||||||
|
errorMessage.remove();
|
||||||
|
}else{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove error classes
|
||||||
|
if (input.classList.contains('usa-input--error')) {
|
||||||
|
input.classList.remove('usa-input--error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the form label
|
||||||
|
let label = document.querySelector(`label[for="${input.id}"]`);
|
||||||
|
if (label) {
|
||||||
|
label.classList.remove('usa-label--error');
|
||||||
|
|
||||||
|
// Remove error classes from parent div
|
||||||
|
let parentDiv = label.parentElement;
|
||||||
|
if (parentDiv) {
|
||||||
|
parentDiv.classList.remove('usa-form-group--error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removeStaleAlerts){
|
||||||
|
let staleAlerts = document.querySelectorAll(".usa-alert--error")
|
||||||
|
for (let alert of staleAlerts){
|
||||||
|
// Don't remove the error associated with the input
|
||||||
|
if (alert.id !== `${input.id}--toast`) {
|
||||||
|
alert.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare the namerservers and DS data forms delete buttons
|
||||||
|
* We will call this on the forms init, and also every time we add a form
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
function removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier){
|
function removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier){
|
||||||
|
@ -460,6 +527,7 @@ function hideDeletedForms() {
|
||||||
let isNameserversForm = document.querySelector(".nameservers-form");
|
let isNameserversForm = document.querySelector(".nameservers-form");
|
||||||
let isOtherContactsForm = document.querySelector(".other-contacts-form");
|
let isOtherContactsForm = document.querySelector(".other-contacts-form");
|
||||||
let isDsDataForm = document.querySelector(".ds-data-form");
|
let isDsDataForm = document.querySelector(".ds-data-form");
|
||||||
|
let isDotgovDomain = document.querySelector(".dotgov-domain-form");
|
||||||
// The Nameservers formset features 2 required and 11 optionals
|
// The Nameservers formset features 2 required and 11 optionals
|
||||||
if (isNameserversForm) {
|
if (isNameserversForm) {
|
||||||
cloneIndex = 2;
|
cloneIndex = 2;
|
||||||
|
@ -472,6 +540,8 @@ function hideDeletedForms() {
|
||||||
formLabel = "Organization contact";
|
formLabel = "Organization contact";
|
||||||
container = document.querySelector("#other-employees");
|
container = document.querySelector("#other-employees");
|
||||||
formIdentifier = "other_contacts"
|
formIdentifier = "other_contacts"
|
||||||
|
} else if (isDotgovDomain) {
|
||||||
|
formIdentifier = "dotgov_domain"
|
||||||
}
|
}
|
||||||
let totalForms = document.querySelector(`#id_${formIdentifier}-TOTAL_FORMS`);
|
let totalForms = document.querySelector(`#id_${formIdentifier}-TOTAL_FORMS`);
|
||||||
|
|
||||||
|
@ -554,6 +624,7 @@ function hideDeletedForms() {
|
||||||
// Reset the values of each input to blank
|
// Reset the values of each input to blank
|
||||||
inputs.forEach((input) => {
|
inputs.forEach((input) => {
|
||||||
input.classList.remove("usa-input--error");
|
input.classList.remove("usa-input--error");
|
||||||
|
input.classList.remove("usa-input--success");
|
||||||
if (input.type === "text" || input.type === "number" || input.type === "password" || input.type === "email" || input.type === "tel") {
|
if (input.type === "text" || input.type === "number" || input.type === "password" || input.type === "email" || input.type === "tel") {
|
||||||
input.value = ""; // Set the value to an empty string
|
input.value = ""; // Set the value to an empty string
|
||||||
|
|
||||||
|
@ -566,22 +637,25 @@ function hideDeletedForms() {
|
||||||
let selects = newForm.querySelectorAll("select");
|
let selects = newForm.querySelectorAll("select");
|
||||||
selects.forEach((select) => {
|
selects.forEach((select) => {
|
||||||
select.classList.remove("usa-input--error");
|
select.classList.remove("usa-input--error");
|
||||||
|
select.classList.remove("usa-input--success");
|
||||||
select.selectedIndex = 0; // Set the value to an empty string
|
select.selectedIndex = 0; // Set the value to an empty string
|
||||||
});
|
});
|
||||||
|
|
||||||
let labels = newForm.querySelectorAll("label");
|
let labels = newForm.querySelectorAll("label");
|
||||||
labels.forEach((label) => {
|
labels.forEach((label) => {
|
||||||
label.classList.remove("usa-label--error");
|
label.classList.remove("usa-label--error");
|
||||||
|
label.classList.remove("usa-label--success");
|
||||||
});
|
});
|
||||||
|
|
||||||
let usaFormGroups = newForm.querySelectorAll(".usa-form-group");
|
let usaFormGroups = newForm.querySelectorAll(".usa-form-group");
|
||||||
usaFormGroups.forEach((usaFormGroup) => {
|
usaFormGroups.forEach((usaFormGroup) => {
|
||||||
usaFormGroup.classList.remove("usa-form-group--error");
|
usaFormGroup.classList.remove("usa-form-group--error");
|
||||||
|
usaFormGroup.classList.remove("usa-form-group--success");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove any existing error messages
|
// Remove any existing error and success messages
|
||||||
let usaErrorMessages = newForm.querySelectorAll(".usa-error-message");
|
let usaMessages = newForm.querySelectorAll(".usa-error-message, .usa-alert");
|
||||||
usaErrorMessages.forEach((usaErrorMessage) => {
|
usaMessages.forEach((usaErrorMessage) => {
|
||||||
let parentDiv = usaErrorMessage.closest('div');
|
let parentDiv = usaErrorMessage.closest('div');
|
||||||
if (parentDiv) {
|
if (parentDiv) {
|
||||||
parentDiv.remove(); // Remove the parent div if it exists
|
parentDiv.remove(); // Remove the parent div if it exists
|
||||||
|
@ -592,6 +666,7 @@ function hideDeletedForms() {
|
||||||
|
|
||||||
// Attach click event listener on the delete buttons of the new form
|
// Attach click event listener on the delete buttons of the new form
|
||||||
let newDeleteButton = newForm.querySelector(".delete-record");
|
let newDeleteButton = newForm.querySelector(".delete-record");
|
||||||
|
if (newDeleteButton)
|
||||||
prepareNewDeleteButton(newDeleteButton, formLabel);
|
prepareNewDeleteButton(newDeleteButton, formLabel);
|
||||||
|
|
||||||
// Disable the add more button if we have 13 forms
|
// Disable the add more button if we have 13 forms
|
||||||
|
|
|
@ -143,7 +143,7 @@ h1, h2, h3,
|
||||||
|
|
||||||
.module h3 {
|
.module h3 {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
color: var(--primary);
|
color: var(--link-fg);
|
||||||
margin: units(2) 0 units(1) 0;
|
margin: units(2) 0 units(1) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -44,6 +44,22 @@ a.usa-button.disabled-link:focus {
|
||||||
color: #454545 !important
|
color: #454545 !important
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.usa-button--unstyled.disabled-link,
|
||||||
|
a.usa-button--unstyled.disabled-link:hover,
|
||||||
|
a.usa-button--unstyled.disabled-link:focus {
|
||||||
|
cursor: not-allowed !important;
|
||||||
|
outline: none !important;
|
||||||
|
text-decoration: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usa-button--unstyled.disabled-button,
|
||||||
|
.usa-button--unstyled.disabled-link:hover,
|
||||||
|
.usa-button--unstyled.disabled-link:focus {
|
||||||
|
cursor: not-allowed !important;
|
||||||
|
outline: none !important;
|
||||||
|
text-decoration: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
a.usa-button:not(.usa-button--unstyled, .usa-button--outline) {
|
a.usa-button:not(.usa-button--unstyled, .usa-button--outline) {
|
||||||
color: color('white');
|
color: color('white');
|
||||||
}
|
}
|
||||||
|
|
|
@ -142,6 +142,11 @@ urlpatterns = [
|
||||||
views.DomainApplicationDeleteView.as_view(http_method_names=["post"]),
|
views.DomainApplicationDeleteView.as_view(http_method_names=["post"]),
|
||||||
name="application-delete",
|
name="application-delete",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"domain/<int:pk>/users/<int:user_pk>/delete",
|
||||||
|
views.DomainDeleteUserView.as_view(http_method_names=["post"]),
|
||||||
|
name="domain-user-delete",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
# we normally would guard these with `if settings.DEBUG` but tests run with
|
# we normally would guard these with `if settings.DEBUG` but tests run with
|
||||||
|
|
|
@ -420,7 +420,7 @@ class AlternativeDomainForm(RegistrarForm):
|
||||||
|
|
||||||
alternative_domain = forms.CharField(
|
alternative_domain = forms.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
label="",
|
label="Alternative domain",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -182,8 +182,6 @@ class LoadExtraTransitionDomain:
|
||||||
# STEP 5: Parse creation and expiration data
|
# STEP 5: Parse creation and expiration data
|
||||||
updated_transition_domain = self.parse_creation_expiration_data(domain_name, transition_domain)
|
updated_transition_domain = self.parse_creation_expiration_data(domain_name, transition_domain)
|
||||||
|
|
||||||
# Check if the instance has changed before saving
|
|
||||||
updated_transition_domain.save()
|
|
||||||
updated_transition_domains.append(updated_transition_domain)
|
updated_transition_domains.append(updated_transition_domain)
|
||||||
logger.info(f"{TerminalColors.OKCYAN}" f"Successfully updated {domain_name}" f"{TerminalColors.ENDC}")
|
logger.info(f"{TerminalColors.OKCYAN}" f"Successfully updated {domain_name}" f"{TerminalColors.ENDC}")
|
||||||
|
|
||||||
|
@ -199,6 +197,28 @@ class LoadExtraTransitionDomain:
|
||||||
)
|
)
|
||||||
failed_transition_domains.append(domain_name)
|
failed_transition_domains.append(domain_name)
|
||||||
|
|
||||||
|
updated_fields = [
|
||||||
|
"organization_name",
|
||||||
|
"organization_type",
|
||||||
|
"federal_type",
|
||||||
|
"federal_agency",
|
||||||
|
"first_name",
|
||||||
|
"middle_name",
|
||||||
|
"last_name",
|
||||||
|
"email",
|
||||||
|
"phone",
|
||||||
|
"epp_creation_date",
|
||||||
|
"epp_expiration_date",
|
||||||
|
]
|
||||||
|
|
||||||
|
batch_size = 1000
|
||||||
|
# Create a Paginator object. Bulk_update on the full dataset
|
||||||
|
# is too memory intensive for our current app config, so we can chunk this data instead.
|
||||||
|
paginator = Paginator(updated_transition_domains, batch_size)
|
||||||
|
for page_num in paginator.page_range:
|
||||||
|
page = paginator.page(page_num)
|
||||||
|
TransitionDomain.objects.bulk_update(page.object_list, updated_fields)
|
||||||
|
|
||||||
failed_count = len(failed_transition_domains)
|
failed_count = len(failed_transition_domains)
|
||||||
if failed_count == 0:
|
if failed_count == 0:
|
||||||
if self.debug:
|
if self.debug:
|
||||||
|
|
37
src/registrar/migrations/0065_create_groups_v06.py
Normal file
37
src/registrar/migrations/0065_create_groups_v06.py
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
# This migration creates the create_full_access_group and create_cisa_analyst_group groups
|
||||||
|
# It is dependent on 0035 (which populates ContentType and Permissions)
|
||||||
|
# If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS
|
||||||
|
# in the user_group model then:
|
||||||
|
# [NOT RECOMMENDED]
|
||||||
|
# step 1: docker-compose exec app ./manage.py migrate --fake registrar 0035_contenttypes_permissions
|
||||||
|
# step 2: docker-compose exec app ./manage.py migrate registrar 0036_create_groups
|
||||||
|
# step 3: fake run the latest migration in the migrations list
|
||||||
|
# [RECOMMENDED]
|
||||||
|
# Alternatively:
|
||||||
|
# step 1: duplicate the migration that loads data
|
||||||
|
# step 2: docker-compose exec app ./manage.py migrate
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
from registrar.models import UserGroup
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
# For linting: RunPython expects a function reference,
|
||||||
|
# so let's give it one
|
||||||
|
def create_groups(apps, schema_editor) -> Any:
|
||||||
|
UserGroup.create_cisa_analyst_group(apps, schema_editor)
|
||||||
|
UserGroup.create_full_access_group(apps, schema_editor)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("registrar", "0064_alter_domainapplication_address_line1_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(
|
||||||
|
create_groups,
|
||||||
|
reverse_code=migrations.RunPython.noop,
|
||||||
|
atomic=True,
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Generated by Django 4.2.7 on 2024-01-29 22:21
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("registrar", "0065_create_groups_v06"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameModel(
|
||||||
|
old_name="VeryImportantPerson",
|
||||||
|
new_name="VerifiedByStaff",
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="verifiedbystaff",
|
||||||
|
options={"verbose_name_plural": "Verified by staff"},
|
||||||
|
),
|
||||||
|
]
|
37
src/registrar/migrations/0067_create_groups_v07.py
Normal file
37
src/registrar/migrations/0067_create_groups_v07.py
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
# This migration creates the create_full_access_group and create_cisa_analyst_group groups
|
||||||
|
# It is dependent on 0035 (which populates ContentType and Permissions)
|
||||||
|
# If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS
|
||||||
|
# in the user_group model then:
|
||||||
|
# [NOT RECOMMENDED]
|
||||||
|
# step 1: docker-compose exec app ./manage.py migrate --fake registrar 0035_contenttypes_permissions
|
||||||
|
# step 2: docker-compose exec app ./manage.py migrate registrar 0036_create_groups
|
||||||
|
# step 3: fake run the latest migration in the migrations list
|
||||||
|
# [RECOMMENDED]
|
||||||
|
# Alternatively:
|
||||||
|
# step 1: duplicate the migration that loads data
|
||||||
|
# step 2: docker-compose exec app ./manage.py migrate
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
from registrar.models import UserGroup
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
# For linting: RunPython expects a function reference,
|
||||||
|
# so let's give it one
|
||||||
|
def create_groups(apps, schema_editor) -> Any:
|
||||||
|
UserGroup.create_cisa_analyst_group(apps, schema_editor)
|
||||||
|
UserGroup.create_full_access_group(apps, schema_editor)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("registrar", "0066_rename_veryimportantperson_verifiedbystaff_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(
|
||||||
|
create_groups,
|
||||||
|
reverse_code=migrations.RunPython.noop,
|
||||||
|
atomic=True,
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Generated by Django 4.2.7 on 2024-01-26 20:09
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("registrar", "0067_create_groups_v07"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="domainapplication",
|
||||||
|
name="notes",
|
||||||
|
field=models.TextField(blank=True, help_text="Notes about this request", null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="domaininformation",
|
||||||
|
name="notes",
|
||||||
|
field=models.TextField(blank=True, help_text="Notes about the request", null=True),
|
||||||
|
),
|
||||||
|
]
|
|
@ -13,7 +13,7 @@ from .user import User
|
||||||
from .user_group import UserGroup
|
from .user_group import UserGroup
|
||||||
from .website import Website
|
from .website import Website
|
||||||
from .transition_domain import TransitionDomain
|
from .transition_domain import TransitionDomain
|
||||||
from .very_important_person import VeryImportantPerson
|
from .verified_by_staff import VerifiedByStaff
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Contact",
|
"Contact",
|
||||||
|
@ -30,7 +30,7 @@ __all__ = [
|
||||||
"UserGroup",
|
"UserGroup",
|
||||||
"Website",
|
"Website",
|
||||||
"TransitionDomain",
|
"TransitionDomain",
|
||||||
"VeryImportantPerson",
|
"VerifiedByStaff",
|
||||||
]
|
]
|
||||||
|
|
||||||
auditlog.register(Contact)
|
auditlog.register(Contact)
|
||||||
|
@ -47,4 +47,4 @@ auditlog.register(User, m2m_fields=["user_permissions", "groups"])
|
||||||
auditlog.register(UserGroup, m2m_fields=["permissions"])
|
auditlog.register(UserGroup, m2m_fields=["permissions"])
|
||||||
auditlog.register(Website)
|
auditlog.register(Website)
|
||||||
auditlog.register(TransitionDomain)
|
auditlog.register(TransitionDomain)
|
||||||
auditlog.register(VeryImportantPerson)
|
auditlog.register(VerifiedByStaff)
|
||||||
|
|
|
@ -911,9 +911,14 @@ class Domain(TimeStampedModel, DomainHelper):
|
||||||
|
|
||||||
def get_security_email(self):
|
def get_security_email(self):
|
||||||
logger.info("get_security_email-> getting the contact")
|
logger.info("get_security_email-> getting the contact")
|
||||||
secContact = self.security_contact
|
|
||||||
if secContact is not None:
|
security = PublicContact.ContactTypeChoices.SECURITY
|
||||||
return secContact.email
|
security_contact = self.generic_contact_getter(security)
|
||||||
|
|
||||||
|
# If we get a valid value for security_contact, pull its email
|
||||||
|
# Otherwise, just return nothing
|
||||||
|
if security_contact is not None and isinstance(security_contact, PublicContact):
|
||||||
|
return security_contact.email
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@ -1121,7 +1126,6 @@ class Domain(TimeStampedModel, DomainHelper):
|
||||||
If you wanted to setup getter logic for Security, you would call:
|
If you wanted to setup getter logic for Security, you would call:
|
||||||
cache_contact_helper(PublicContact.ContactTypeChoices.SECURITY),
|
cache_contact_helper(PublicContact.ContactTypeChoices.SECURITY),
|
||||||
or cache_contact_helper("security").
|
or cache_contact_helper("security").
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# registrant_contact(s) are an edge case. They exist on
|
# registrant_contact(s) are an edge case. They exist on
|
||||||
# the "registrant" property as opposed to contacts.
|
# the "registrant" property as opposed to contacts.
|
||||||
|
|
|
@ -558,6 +558,12 @@ class DomainApplication(TimeStampedModel):
|
||||||
help_text="Date submitted",
|
help_text="Date submitted",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
notes = models.TextField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Notes about this request",
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
try:
|
try:
|
||||||
if self.requested_domain and self.requested_domain.name:
|
if self.requested_domain and self.requested_domain.name:
|
||||||
|
@ -707,7 +713,7 @@ class DomainApplication(TimeStampedModel):
|
||||||
|
|
||||||
# copy the information from domainapplication into domaininformation
|
# copy the information from domainapplication into domaininformation
|
||||||
DomainInformation = apps.get_model("registrar.DomainInformation")
|
DomainInformation = apps.get_model("registrar.DomainInformation")
|
||||||
DomainInformation.create_from_da(self, domain=created_domain)
|
DomainInformation.create_from_da(domain_application=self, domain=created_domain)
|
||||||
|
|
||||||
# create the permission for the user
|
# create the permission for the user
|
||||||
UserDomainRole = apps.get_model("registrar.UserDomainRole")
|
UserDomainRole = apps.get_model("registrar.UserDomainRole")
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from registrar.models.utility.domain_helper import DomainHelper
|
||||||
from .domain_application import DomainApplication
|
from .domain_application import DomainApplication
|
||||||
from .utility.time_stamped_model import TimeStampedModel
|
from .utility.time_stamped_model import TimeStampedModel
|
||||||
|
|
||||||
|
@ -202,6 +205,12 @@ class DomainInformation(TimeStampedModel):
|
||||||
help_text="Acknowledged .gov acceptable use policy",
|
help_text="Acknowledged .gov acceptable use policy",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
notes = models.TextField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Notes about the request",
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
try:
|
try:
|
||||||
if self.domain and self.domain.name:
|
if self.domain and self.domain.name:
|
||||||
|
@ -212,37 +221,63 @@ class DomainInformation(TimeStampedModel):
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_from_da(cls, domain_application, domain=None):
|
def create_from_da(cls, domain_application: DomainApplication, domain=None):
|
||||||
"""Takes in a DomainApplication dict and converts it into DomainInformation"""
|
"""Takes in a DomainApplication and converts it into DomainInformation"""
|
||||||
da_dict = domain_application.to_dict()
|
|
||||||
# remove the id so one can be assinged on creation
|
# Throw an error if we get None - we can't create something from nothing
|
||||||
da_id = da_dict.pop("id", None)
|
if domain_application is None:
|
||||||
|
raise ValueError("The provided DomainApplication is None")
|
||||||
|
|
||||||
|
# Throw an error if the da doesn't have an id
|
||||||
|
if not hasattr(domain_application, "id"):
|
||||||
|
raise ValueError("The provided DomainApplication has no id")
|
||||||
|
|
||||||
# check if we have a record that corresponds with the domain
|
# check if we have a record that corresponds with the domain
|
||||||
# application, if so short circuit the create
|
# application, if so short circuit the create
|
||||||
domain_info = cls.objects.filter(domain_application__id=da_id).first()
|
existing_domain_info = cls.objects.filter(domain_application__id=domain_application.id).first()
|
||||||
if domain_info:
|
if existing_domain_info:
|
||||||
return domain_info
|
return existing_domain_info
|
||||||
# the following information below is not needed in the domain information:
|
|
||||||
da_dict.pop("status", None)
|
|
||||||
da_dict.pop("current_websites", None)
|
|
||||||
da_dict.pop("investigator", None)
|
|
||||||
da_dict.pop("alternative_domains", None)
|
|
||||||
da_dict.pop("requested_domain", None)
|
|
||||||
da_dict.pop("approved_domain", None)
|
|
||||||
da_dict.pop("submission_date", None)
|
|
||||||
other_contacts = da_dict.pop("other_contacts", [])
|
|
||||||
domain_info = cls(**da_dict)
|
|
||||||
domain_info.domain_application = domain_application
|
|
||||||
# Save so the object now have PK
|
|
||||||
# (needed to process the manytomany below before, first)
|
|
||||||
domain_info.save()
|
|
||||||
|
|
||||||
# Process the remaining "many to many" stuff
|
# Get the fields that exist on both DomainApplication and DomainInformation
|
||||||
domain_info.other_contacts.add(*other_contacts)
|
common_fields = DomainHelper.get_common_fields(DomainApplication, DomainInformation)
|
||||||
|
|
||||||
|
# Get a list of all many_to_many relations on DomainInformation (needs to be saved differently)
|
||||||
|
info_many_to_many_fields = DomainInformation._get_many_to_many_fields()
|
||||||
|
|
||||||
|
# Create a dictionary with only the common fields, and create a DomainInformation from it
|
||||||
|
da_dict = {}
|
||||||
|
da_many_to_many_dict = {}
|
||||||
|
for field in common_fields:
|
||||||
|
# If the field isn't many_to_many, populate the da_dict.
|
||||||
|
# If it is, populate da_many_to_many_dict as we need to save this later.
|
||||||
|
if hasattr(domain_application, field):
|
||||||
|
if field not in info_many_to_many_fields:
|
||||||
|
da_dict[field] = getattr(domain_application, field)
|
||||||
|
else:
|
||||||
|
da_many_to_many_dict[field] = getattr(domain_application, field).all()
|
||||||
|
|
||||||
|
# Create a placeholder DomainInformation object
|
||||||
|
domain_info = DomainInformation(**da_dict)
|
||||||
|
|
||||||
|
# Add the domain_application and domain fields
|
||||||
|
domain_info.domain_application = domain_application
|
||||||
if domain:
|
if domain:
|
||||||
domain_info.domain = domain
|
domain_info.domain = domain
|
||||||
|
|
||||||
|
# Save the instance and set the many-to-many fields.
|
||||||
|
# Lumped under .atomic to ensure we don't make redundant DB calls.
|
||||||
|
# This bundles them all together, and then saves it in a single call.
|
||||||
|
with transaction.atomic():
|
||||||
domain_info.save()
|
domain_info.save()
|
||||||
|
for field, value in da_many_to_many_dict.items():
|
||||||
|
getattr(domain_info, field).set(value)
|
||||||
|
|
||||||
return domain_info
|
return domain_info
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_many_to_many_fields():
|
||||||
|
"""Returns a set of each field.name that has the many to many relation"""
|
||||||
|
return {field.name for field in DomainInformation._meta.many_to_many} # type: ignore
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name_plural = "Domain information"
|
verbose_name_plural = "Domain information"
|
||||||
|
|
|
@ -7,7 +7,7 @@ from registrar.models.user_domain_role import UserDomainRole
|
||||||
|
|
||||||
from .domain_invitation import DomainInvitation
|
from .domain_invitation import DomainInvitation
|
||||||
from .transition_domain import TransitionDomain
|
from .transition_domain import TransitionDomain
|
||||||
from .very_important_person import VeryImportantPerson
|
from .verified_by_staff import VerifiedByStaff
|
||||||
from .domain import Domain
|
from .domain import Domain
|
||||||
|
|
||||||
from phonenumber_field.modelfields import PhoneNumberField # type: ignore
|
from phonenumber_field.modelfields import PhoneNumberField # type: ignore
|
||||||
|
@ -91,7 +91,7 @@ class User(AbstractUser):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# New users flagged by Staff to bypass ial2
|
# New users flagged by Staff to bypass ial2
|
||||||
if VeryImportantPerson.objects.filter(email=email).exists():
|
if VerifiedByStaff.objects.filter(email=email).exists():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# A new incoming user who is being invited to be a domain manager (that is,
|
# A new incoming user who is being invited to be a domain manager (that is,
|
||||||
|
|
|
@ -66,6 +66,11 @@ class UserGroup(Group):
|
||||||
"model": "userdomainrole",
|
"model": "userdomainrole",
|
||||||
"permissions": ["view_userdomainrole", "delete_userdomainrole"],
|
"permissions": ["view_userdomainrole", "delete_userdomainrole"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"app_label": "registrar",
|
||||||
|
"model": "verifiedbystaff",
|
||||||
|
"permissions": ["add_verifiedbystaff", "change_verifiedbystaff", "delete_verifiedbystaff"],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
# Avoid error: You can't execute queries until the end
|
# Avoid error: You can't execute queries until the end
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import re
|
import re
|
||||||
|
from typing import Type
|
||||||
|
from django.db import models
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
|
|
||||||
|
@ -29,7 +30,6 @@ class DomainHelper:
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate(cls, domain: str, blank_ok=False) -> str:
|
def validate(cls, domain: str, blank_ok=False) -> str:
|
||||||
"""Attempt to determine if a domain name could be requested."""
|
"""Attempt to determine if a domain name could be requested."""
|
||||||
|
|
||||||
# Split into pieces for the linter
|
# Split into pieces for the linter
|
||||||
domain = cls._validate_domain_string(domain, blank_ok)
|
domain = cls._validate_domain_string(domain, blank_ok)
|
||||||
|
|
||||||
|
@ -161,3 +161,29 @@ class DomainHelper:
|
||||||
"""Get the top level domain. Example: `gsa.gov` -> `gov`."""
|
"""Get the top level domain. Example: `gsa.gov` -> `gov`."""
|
||||||
parts = domain.rsplit(".")
|
parts = domain.rsplit(".")
|
||||||
return parts[-1] if len(parts) > 1 else ""
|
return parts[-1] if len(parts) > 1 else ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_common_fields(model_1: Type[models.Model], model_2: Type[models.Model]):
|
||||||
|
"""
|
||||||
|
Returns a set of field names that two Django models have in common, excluding the 'id' field.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_1 (Type[models.Model]): The first Django model class.
|
||||||
|
model_2 (Type[models.Model]): The second Django model class.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Set[str]: A set of field names that both models share.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
If model_1 has fields {"id", "name", "color"} and model_2 has fields {"id", "color"},
|
||||||
|
the function will return {"color"}.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Get a list of the existing fields on model_1 and model_2
|
||||||
|
model_1_fields = set(field.name for field in model_1._meta.get_fields() if field != "id")
|
||||||
|
model_2_fields = set(field.name for field in model_2._meta.get_fields() if field != "id")
|
||||||
|
|
||||||
|
# Get the fields that exist on both DomainApplication and DomainInformation
|
||||||
|
common_fields = model_1_fields & model_2_fields
|
||||||
|
|
||||||
|
return common_fields
|
||||||
|
|
|
@ -3,7 +3,7 @@ from django.db import models
|
||||||
from .utility.time_stamped_model import TimeStampedModel
|
from .utility.time_stamped_model import TimeStampedModel
|
||||||
|
|
||||||
|
|
||||||
class VeryImportantPerson(TimeStampedModel):
|
class VerifiedByStaff(TimeStampedModel):
|
||||||
|
|
||||||
"""emails that get added to this table will bypass ial2 on login."""
|
"""emails that get added to this table will bypass ial2 on login."""
|
||||||
|
|
||||||
|
@ -28,5 +28,8 @@ class VeryImportantPerson(TimeStampedModel):
|
||||||
help_text="Notes",
|
help_text="Notes",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name_plural = "Verified by staff"
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.email
|
return self.email
|
|
@ -11,7 +11,8 @@
|
||||||
</ul>
|
</ul>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>Names that <em>uniquely apply to your organization</em> are likely to be approved over names that could also apply to other organizations. In most instances, this requires including your state’s two-letter abbreviation.</p>
|
<p>Names that <em>uniquely apply to your organization</em> are likely to be approved over names that could also apply to other organizations.
|
||||||
|
{% if not is_federal %}In most instances, this requires including your state’s two-letter abbreviation.{% endif %}</p>
|
||||||
|
|
||||||
<p>Requests for your organization’s initials or an abbreviated name might not be approved, but we encourage you to request the name you want.</p>
|
<p>Requests for your organization’s initials or an abbreviated name might not be approved, but we encourage you to request the name you want.</p>
|
||||||
|
|
||||||
|
@ -48,16 +49,15 @@
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
<button
|
<button
|
||||||
id="check-availability-button"
|
|
||||||
type="button"
|
type="button"
|
||||||
class="usa-button"
|
class="usa-button usa-button--outline"
|
||||||
validate-for="{{ forms.0.requested_domain.auto_id }}"
|
validate-for="{{ forms.0.requested_domain.auto_id }}"
|
||||||
>Check availability</button>
|
>Check availability</button>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
{{ forms.1.management_form }}
|
{{ forms.1.management_form }}
|
||||||
|
|
||||||
<fieldset class="usa-fieldset margin-top-1">
|
<fieldset class="usa-fieldset margin-top-1 dotgov-domain-form" id="form-container">
|
||||||
<legend>
|
<legend>
|
||||||
<h2>Alternative domains (optional)</h2>
|
<h2>Alternative domains (optional)</h2>
|
||||||
</legend>
|
</legend>
|
||||||
|
@ -66,23 +66,34 @@
|
||||||
you your first choice?</p>
|
you your first choice?</p>
|
||||||
|
|
||||||
{% with attr_aria_describedby="alt_domain_instructions" %}
|
{% with attr_aria_describedby="alt_domain_instructions" %}
|
||||||
{# attr_validate / validate="domain" invokes code in get-gov.js #}
|
{# Will probably want to remove blank-ok and do related cleanup when we implement delete #}
|
||||||
{# attr_auto_validate likewise triggers behavior in get-gov.js #}
|
{% with attr_validate="domain" append_gov=True add_label_class="usa-sr-only" add_class="blank-ok alternate-domain-input" %}
|
||||||
{% with append_gov=True attr_validate="domain" attr_auto_validate=True %}
|
|
||||||
{% with add_class="blank-ok alternate-domain-input" %}
|
|
||||||
{% for form in forms.1 %}
|
{% for form in forms.1 %}
|
||||||
|
<div class="repeatable-form">
|
||||||
{% input_with_errors form.alternative_domain %}
|
{% input_with_errors form.alternative_domain %}
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
<button type="submit" name="submit_button" value="save" class="usa-button usa-button--unstyled">
|
<button type="button" value="save" class="usa-button usa-button--unstyled" id="add-form">
|
||||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||||
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
|
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
|
||||||
</svg><span class="margin-left-05">Add another alternative</span>
|
</svg><span class="margin-left-05">Add another alternative</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div class="margin-bottom-3">
|
||||||
|
<button
|
||||||
|
id="validate-alt-domains-availability"
|
||||||
|
type="button"
|
||||||
|
class="usa-button usa-button--outline"
|
||||||
|
validate-for="{{ forms.1.requested_domain.auto_id }}"
|
||||||
|
>Check availability</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<p class="margin-top-05">If you’re not sure this is the domain you want, that’s ok. You can change the domain later. </p>
|
||||||
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -3,6 +3,10 @@
|
||||||
{% block wrapper %}
|
{% block wrapper %}
|
||||||
|
|
||||||
<div id="wrapper" class="dashboard">
|
<div id="wrapper" class="dashboard">
|
||||||
|
{% block section_nav %}{% endblock %}
|
||||||
|
|
||||||
|
{% block hero %}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
{% block messages %}
|
{% block messages %}
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
<ul class="messages">
|
<ul class="messages">
|
||||||
|
@ -14,11 +18,7 @@
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
{% endblock %}
|
||||||
{% block section_nav %}{% endblock %}
|
|
||||||
|
|
||||||
{% block hero %}{% endblock %}
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
|
|
||||||
<div role="complementary">{% block complementary %}{% endblock %}</div>
|
<div role="complementary">{% block complementary %}{% endblock %}</div>
|
||||||
|
|
||||||
|
|
|
@ -16,10 +16,8 @@
|
||||||
<li>There is no limit to the number of domain managers you can add.</li>
|
<li>There is no limit to the number of domain managers you can add.</li>
|
||||||
<li>After adding a domain manager, an email invitation will be sent to that user with
|
<li>After adding a domain manager, an email invitation will be sent to that user with
|
||||||
instructions on how to set up an account.</li>
|
instructions on how to set up an account.</li>
|
||||||
<li>To remove a domain manager, <a href="{% public_site_url 'contact/' %}"
|
|
||||||
target="_blank" rel="noopener noreferrer" class="usa-link">contact us</a> for
|
|
||||||
assistance.</li>
|
|
||||||
<li>All domain managers must keep their contact information updated and be responsive if contacted by the .gov team.</li>
|
<li>All domain managers must keep their contact information updated and be responsive if contacted by the .gov team.</li>
|
||||||
|
<li>Domains must have at least one domain manager. You can’t remove yourself as a domain manager if you’re the only one assigned to this domain. Add another domain manager before you remove yourself from this domain.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{% if domain.permissions %}
|
{% if domain.permissions %}
|
||||||
|
@ -30,7 +28,8 @@
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th data-sortable scope="col" role="columnheader">Email</th>
|
<th data-sortable scope="col" role="columnheader">Email</th>
|
||||||
<th data-sortable scope="col" role="columnheader">Role</th>
|
<th class="grid-col-2" data-sortable scope="col" role="columnheader">Role</th>
|
||||||
|
<th class="grid-col-1" scope="col" role="columnheader"><span class="sr-only">Action</span></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
@ -40,6 +39,61 @@
|
||||||
{{ permission.user.email }}
|
{{ permission.user.email }}
|
||||||
</th>
|
</th>
|
||||||
<td data-label="Role">{{ permission.role|title }}</td>
|
<td data-label="Role">{{ permission.role|title }}</td>
|
||||||
|
<td>
|
||||||
|
{% if can_delete_users %}
|
||||||
|
<a
|
||||||
|
id="button-toggle-user-alert-{{ forloop.counter }}"
|
||||||
|
href="#toggle-user-alert-{{ forloop.counter }}"
|
||||||
|
class="usa-button--unstyled text-no-underline"
|
||||||
|
aria-controls="toggle-user-alert-{{ forloop.counter }}"
|
||||||
|
data-open-modal
|
||||||
|
aria-disabled="false"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</a>
|
||||||
|
{# Display a custom message if the user is trying to delete themselves #}
|
||||||
|
{% if permission.user.email == current_user_email %}
|
||||||
|
<div
|
||||||
|
class="usa-modal"
|
||||||
|
id="toggle-user-alert-{{ forloop.counter }}"
|
||||||
|
aria-labelledby="Are you sure you want to continue?"
|
||||||
|
aria-describedby="You will be removed from this domain"
|
||||||
|
data-force-action
|
||||||
|
>
|
||||||
|
<form method="POST" action="{% url "domain-user-delete" pk=domain.id user_pk=permission.user.id %}">
|
||||||
|
{% with domain_name=domain.name|force_escape %}
|
||||||
|
{% include 'includes/modal.html' with modal_heading="Are you sure you want to remove yourself as a domain manager?" modal_description="You will no longer be able to manage the domain <strong>"|add:domain_name|add:"</strong>."|safe modal_button=modal_button_self|safe %}
|
||||||
|
{% endwith %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div
|
||||||
|
class="usa-modal"
|
||||||
|
id="toggle-user-alert-{{ forloop.counter }}"
|
||||||
|
aria-labelledby="Are you sure you want to continue?"
|
||||||
|
aria-describedby="{{ permission.user.email }} will be removed"
|
||||||
|
data-force-action
|
||||||
|
>
|
||||||
|
<form method="POST" action="{% url "domain-user-delete" pk=domain.id user_pk=permission.user.id %}">
|
||||||
|
{% with email=permission.user.email|default:permission.user|force_escape domain_name=domain.name|force_escape %}
|
||||||
|
{% include 'includes/modal.html' with modal_heading="Are you sure you want to remove " heading_value=email|add:"?" modal_description="<strong>"|add:email|add:"</strong> will no longer be able to manage the domain <strong>"|add:domain_name|add:"</strong>."|safe modal_button=modal_button|safe %}
|
||||||
|
{% endwith %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<input
|
||||||
|
type="submit"
|
||||||
|
class="usa-button--unstyled disabled-button usa-tooltip"
|
||||||
|
value="Remove"
|
||||||
|
data-position="bottom"
|
||||||
|
title="Domains must have at least one domain manager"
|
||||||
|
data-tooltip="true"
|
||||||
|
aria-disabled="true"
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
@ -66,8 +120,8 @@
|
||||||
<tr>
|
<tr>
|
||||||
<th data-sortable scope="col" role="columnheader">Email</th>
|
<th data-sortable scope="col" role="columnheader">Email</th>
|
||||||
<th data-sortable scope="col" role="columnheader">Date created</th>
|
<th data-sortable scope="col" role="columnheader">Date created</th>
|
||||||
<th data-sortable scope="col" role="columnheader">Status</th>
|
<th class="grid-col-2" data-sortable scope="col" role="columnheader">Status</th>
|
||||||
<th scope="col" role="columnheader"><span class="sr-only">Action</span></th>
|
<th class="grid-col-1" scope="col" role="columnheader"><span class="sr-only">Action</span></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
@ -78,8 +132,9 @@
|
||||||
</th>
|
</th>
|
||||||
<td data-sort-value="{{ invitation.created_at|date:"U" }}" data-label="Date created">{{ invitation.created_at|date }} </td>
|
<td data-sort-value="{{ invitation.created_at|date:"U" }}" data-label="Date created">{{ invitation.created_at|date }} </td>
|
||||||
<td data-label="Status">{{ invitation.status|title }}</td>
|
<td data-label="Status">{{ invitation.status|title }}</td>
|
||||||
<td><form method="POST" action="{% url "invitation-delete" pk=invitation.id %}">
|
<td>
|
||||||
{% csrf_token %}<input type="submit" class="usa-button--unstyled" value="Cancel">
|
<form method="POST" action="{% url "invitation-delete" pk=invitation.id %}">
|
||||||
|
{% csrf_token %}<input type="submit" class="usa-button--unstyled text-no-underline" value="Cancel">
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
@ -10,6 +10,9 @@
|
||||||
{# the entire logged in page goes here #}
|
{# the entire logged in page goes here #}
|
||||||
|
|
||||||
<div class="tablet:grid-col-11 desktop:grid-col-10 tablet:grid-offset-1">
|
<div class="tablet:grid-col-11 desktop:grid-col-10 tablet:grid-offset-1">
|
||||||
|
{% block messages %}
|
||||||
|
{% include "includes/form_messages.html" %}
|
||||||
|
{% endblock %}
|
||||||
<h1>Manage your domains</h2>
|
<h1>Manage your domains</h2>
|
||||||
|
|
||||||
<p class="margin-top-4">
|
<p class="margin-top-4">
|
||||||
|
|
|
@ -14,11 +14,11 @@ from registrar.admin import (
|
||||||
ContactAdmin,
|
ContactAdmin,
|
||||||
DomainInformationAdmin,
|
DomainInformationAdmin,
|
||||||
UserDomainRoleAdmin,
|
UserDomainRoleAdmin,
|
||||||
VeryImportantPersonAdmin,
|
VerifiedByStaffAdmin,
|
||||||
)
|
)
|
||||||
from registrar.models import Domain, DomainApplication, DomainInformation, User, DomainInvitation, Contact, Website
|
from registrar.models import Domain, DomainApplication, DomainInformation, User, DomainInvitation, Contact, Website
|
||||||
from registrar.models.user_domain_role import UserDomainRole
|
from registrar.models.user_domain_role import UserDomainRole
|
||||||
from registrar.models.very_important_person import VeryImportantPerson
|
from registrar.models.verified_by_staff import VerifiedByStaff
|
||||||
from .common import (
|
from .common import (
|
||||||
MockSESClient,
|
MockSESClient,
|
||||||
AuditedAdminMockData,
|
AuditedAdminMockData,
|
||||||
|
@ -614,6 +614,7 @@ class TestDomainApplicationAdmin(MockEppLib):
|
||||||
"anything_else",
|
"anything_else",
|
||||||
"is_policy_acknowledged",
|
"is_policy_acknowledged",
|
||||||
"submission_date",
|
"submission_date",
|
||||||
|
"notes",
|
||||||
"current_websites",
|
"current_websites",
|
||||||
"other_contacts",
|
"other_contacts",
|
||||||
"alternative_domains",
|
"alternative_domains",
|
||||||
|
@ -1804,7 +1805,7 @@ class ContactAdminTest(TestCase):
|
||||||
User.objects.all().delete()
|
User.objects.all().delete()
|
||||||
|
|
||||||
|
|
||||||
class VeryImportantPersonAdminTestCase(TestCase):
|
class VerifiedByStaffAdminTestCase(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.superuser = create_superuser()
|
self.superuser = create_superuser()
|
||||||
self.factory = RequestFactory()
|
self.factory = RequestFactory()
|
||||||
|
@ -1813,13 +1814,13 @@ class VeryImportantPersonAdminTestCase(TestCase):
|
||||||
self.client.force_login(self.superuser)
|
self.client.force_login(self.superuser)
|
||||||
|
|
||||||
# Create an instance of the admin class
|
# Create an instance of the admin class
|
||||||
admin_instance = VeryImportantPersonAdmin(model=VeryImportantPerson, admin_site=None)
|
admin_instance = VerifiedByStaffAdmin(model=VerifiedByStaff, admin_site=None)
|
||||||
|
|
||||||
# Create a VeryImportantPerson instance
|
# Create a VerifiedByStaff instance
|
||||||
vip_instance = VeryImportantPerson(email="test@example.com", notes="Test Notes")
|
vip_instance = VerifiedByStaff(email="test@example.com", notes="Test Notes")
|
||||||
|
|
||||||
# Create a request object
|
# Create a request object
|
||||||
request = self.factory.post("/admin/yourapp/veryimportantperson/add/")
|
request = self.factory.post("/admin/yourapp/VerifiedByStaff/add/")
|
||||||
request.user = self.superuser
|
request.user = self.superuser
|
||||||
|
|
||||||
# Call the save_model method
|
# Call the save_model method
|
||||||
|
|
|
@ -43,6 +43,9 @@ class TestGroups(TestCase):
|
||||||
"change_user",
|
"change_user",
|
||||||
"delete_userdomainrole",
|
"delete_userdomainrole",
|
||||||
"view_userdomainrole",
|
"view_userdomainrole",
|
||||||
|
"add_verifiedbystaff",
|
||||||
|
"change_verifiedbystaff",
|
||||||
|
"delete_verifiedbystaff",
|
||||||
"change_website",
|
"change_website",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,7 @@ from registrar.models import (
|
||||||
|
|
||||||
import boto3_mocking
|
import boto3_mocking
|
||||||
from registrar.models.transition_domain import TransitionDomain
|
from registrar.models.transition_domain import TransitionDomain
|
||||||
from registrar.models.very_important_person import VeryImportantPerson # type: ignore
|
from registrar.models.verified_by_staff import VerifiedByStaff # type: ignore
|
||||||
from .common import MockSESClient, less_console_noise, completed_application
|
from .common import MockSESClient, less_console_noise, completed_application
|
||||||
from django_fsm import TransitionNotAllowed
|
from django_fsm import TransitionNotAllowed
|
||||||
|
|
||||||
|
@ -559,9 +559,9 @@ class TestPermissions(TestCase):
|
||||||
self.assertTrue(UserDomainRole.objects.get(user=user, domain=domain))
|
self.assertTrue(UserDomainRole.objects.get(user=user, domain=domain))
|
||||||
|
|
||||||
|
|
||||||
class TestDomainInfo(TestCase):
|
class TestDomainInformation(TestCase):
|
||||||
|
|
||||||
"""Test creation of Domain Information when approved."""
|
"""Test the DomainInformation model, when approved or otherwise"""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
@ -570,12 +570,18 @@ class TestDomainInfo(TestCase):
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
super().tearDown()
|
super().tearDown()
|
||||||
self.mock_client.EMAILS_SENT.clear()
|
self.mock_client.EMAILS_SENT.clear()
|
||||||
|
Domain.objects.all().delete()
|
||||||
|
DomainInformation.objects.all().delete()
|
||||||
|
DomainApplication.objects.all().delete()
|
||||||
|
User.objects.all().delete()
|
||||||
|
DraftDomain.objects.all().delete()
|
||||||
|
|
||||||
@boto3_mocking.patching
|
@boto3_mocking.patching
|
||||||
def test_approval_creates_info(self):
|
def test_approval_creates_info(self):
|
||||||
|
self.maxDiff = None
|
||||||
draft_domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov")
|
draft_domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov")
|
||||||
user, _ = User.objects.get_or_create()
|
user, _ = User.objects.get_or_create()
|
||||||
application = DomainApplication.objects.create(creator=user, requested_domain=draft_domain)
|
application = DomainApplication.objects.create(creator=user, requested_domain=draft_domain, notes="test notes")
|
||||||
|
|
||||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
|
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
|
||||||
with less_console_noise():
|
with less_console_noise():
|
||||||
|
@ -585,7 +591,25 @@ class TestDomainInfo(TestCase):
|
||||||
|
|
||||||
# should be an information present for this domain
|
# should be an information present for this domain
|
||||||
domain = Domain.objects.get(name="igorville.gov")
|
domain = Domain.objects.get(name="igorville.gov")
|
||||||
self.assertTrue(DomainInformation.objects.get(domain=domain))
|
domain_information = DomainInformation.objects.filter(domain=domain)
|
||||||
|
self.assertTrue(domain_information.exists())
|
||||||
|
|
||||||
|
# Test that both objects are what we expect
|
||||||
|
current_domain_information = domain_information.get().__dict__
|
||||||
|
expected_domain_information = DomainInformation(
|
||||||
|
creator=user,
|
||||||
|
domain=domain,
|
||||||
|
notes="test notes",
|
||||||
|
domain_application=application,
|
||||||
|
).__dict__
|
||||||
|
|
||||||
|
# Test the two records for consistency
|
||||||
|
self.assertEqual(self.clean_dict(current_domain_information), self.clean_dict(expected_domain_information))
|
||||||
|
|
||||||
|
def clean_dict(self, dict_obj):
|
||||||
|
"""Cleans dynamic fields in a dictionary"""
|
||||||
|
bad_fields = ["_state", "created_at", "id", "updated_at"]
|
||||||
|
return {k: v for k, v in dict_obj.items() if k not in bad_fields}
|
||||||
|
|
||||||
|
|
||||||
class TestInvitations(TestCase):
|
class TestInvitations(TestCase):
|
||||||
|
@ -667,7 +691,7 @@ class TestUser(TestCase):
|
||||||
def test_identity_verification_with_very_important_person(self):
|
def test_identity_verification_with_very_important_person(self):
|
||||||
"""A Very Important Person should return False
|
"""A Very Important Person should return False
|
||||||
when tested with class method needs_identity_verification"""
|
when tested with class method needs_identity_verification"""
|
||||||
VeryImportantPerson.objects.get_or_create(email=self.user.email)
|
VerifiedByStaff.objects.get_or_create(email=self.user.email)
|
||||||
self.assertFalse(User.needs_identity_verification(self.user.email, self.user.username))
|
self.assertFalse(User.needs_identity_verification(self.user.email, self.user.username))
|
||||||
|
|
||||||
def test_identity_verification_with_invited_user(self):
|
def test_identity_verification_with_invited_user(self):
|
||||||
|
|
|
@ -23,6 +23,7 @@ SAMPLE_KWARGS = {
|
||||||
"content_type_id": "2",
|
"content_type_id": "2",
|
||||||
"object_id": "3",
|
"object_id": "3",
|
||||||
"domain": "whitehouse.gov",
|
"domain": "whitehouse.gov",
|
||||||
|
"user_pk": "1",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Our test suite will ignore some namespaces.
|
# Our test suite will ignore some namespaces.
|
||||||
|
|
File diff suppressed because it is too large
Load diff
2199
src/registrar/tests/test_views_application.py
Normal file
2199
src/registrar/tests/test_views_application.py
Normal file
File diff suppressed because it is too large
Load diff
1436
src/registrar/tests/test_views_domain.py
Normal file
1436
src/registrar/tests/test_views_domain.py
Normal file
File diff suppressed because it is too large
Load diff
|
@ -3,10 +3,13 @@ import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from registrar.models.domain import Domain
|
from registrar.models.domain import Domain
|
||||||
from registrar.models.domain_information import DomainInformation
|
from registrar.models.domain_information import DomainInformation
|
||||||
from registrar.models.public_contact import PublicContact
|
|
||||||
from django.db.models import Value
|
|
||||||
from django.db.models.functions import Coalesce
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.core.paginator import Paginator
|
||||||
|
from django.db.models import F, Value, CharField
|
||||||
|
from django.db.models.functions import Concat, Coalesce
|
||||||
|
|
||||||
|
from registrar.models.public_contact import PublicContact
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -20,50 +23,77 @@ def write_header(writer, columns):
|
||||||
|
|
||||||
|
|
||||||
def get_domain_infos(filter_condition, sort_fields):
|
def get_domain_infos(filter_condition, sort_fields):
|
||||||
domain_infos = DomainInformation.objects.filter(**filter_condition).order_by(*sort_fields)
|
domain_infos = (
|
||||||
return domain_infos
|
DomainInformation.objects.select_related("domain", "authorizing_official")
|
||||||
|
.filter(**filter_condition)
|
||||||
|
.order_by(*sort_fields)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Do a mass concat of the first and last name fields for authorizing_official.
|
||||||
|
# The old operation was computationally heavy for some reason, so if we precompute
|
||||||
|
# this here, it is vastly more efficient.
|
||||||
|
domain_infos_cleaned = domain_infos.annotate(
|
||||||
|
ao=Concat(
|
||||||
|
Coalesce(F("authorizing_official__first_name"), Value("")),
|
||||||
|
Value(" "),
|
||||||
|
Coalesce(F("authorizing_official__last_name"), Value("")),
|
||||||
|
output_field=CharField(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return domain_infos_cleaned
|
||||||
|
|
||||||
|
|
||||||
def write_row(writer, columns, domain_info):
|
def parse_row(columns, domain_info: DomainInformation, security_emails_dict=None):
|
||||||
security_contacts = domain_info.domain.contacts.filter(contact_type=PublicContact.ContactTypeChoices.SECURITY)
|
"""Given a set of columns, generate a new row from cleaned column data"""
|
||||||
|
|
||||||
# For linter
|
# Domain should never be none when parsing this information
|
||||||
ao = " "
|
if domain_info.domain is None:
|
||||||
if domain_info.authorizing_official:
|
raise ValueError("Domain is none")
|
||||||
first_name = domain_info.authorizing_official.first_name or ""
|
|
||||||
last_name = domain_info.authorizing_official.last_name or ""
|
|
||||||
ao = first_name + " " + last_name
|
|
||||||
|
|
||||||
security_email = " "
|
domain = domain_info.domain # type: ignore
|
||||||
if security_contacts:
|
|
||||||
security_email = security_contacts[0].email
|
# Grab the security email from a preset dictionary.
|
||||||
|
# If nothing exists in the dictionary, grab from .contacts.
|
||||||
|
if security_emails_dict is not None and domain.name in security_emails_dict:
|
||||||
|
_email = security_emails_dict.get(domain.name)
|
||||||
|
security_email = _email if _email is not None else " "
|
||||||
|
else:
|
||||||
|
# If the dictionary doesn't contain that data, lets filter for it manually.
|
||||||
|
# This is a last resort as this is a more expensive operation.
|
||||||
|
security_contacts = domain.contacts.filter(contact_type=PublicContact.ContactTypeChoices.SECURITY)
|
||||||
|
_email = security_contacts[0].email if security_contacts else None
|
||||||
|
security_email = _email if _email is not None else " "
|
||||||
|
|
||||||
invalid_emails = {"registrar@dotgov.gov", "dotgov@cisa.dhs.gov"}
|
|
||||||
# These are default emails that should not be displayed in the csv report
|
# These are default emails that should not be displayed in the csv report
|
||||||
if security_email is not None and security_email.lower() in invalid_emails:
|
invalid_emails = {"registrar@dotgov.gov", "dotgov@cisa.dhs.gov"}
|
||||||
|
if security_email.lower() in invalid_emails:
|
||||||
security_email = "(blank)"
|
security_email = "(blank)"
|
||||||
|
|
||||||
|
if domain_info.federal_type:
|
||||||
|
domain_type = f"{domain_info.get_organization_type_display()} - {domain_info.get_federal_type_display()}"
|
||||||
|
else:
|
||||||
|
domain_type = domain_info.get_organization_type_display()
|
||||||
|
|
||||||
# create a dictionary of fields which can be included in output
|
# create a dictionary of fields which can be included in output
|
||||||
FIELDS = {
|
FIELDS = {
|
||||||
"Domain name": domain_info.domain.name,
|
"Domain name": domain.name,
|
||||||
"Domain type": domain_info.get_organization_type_display() + " - " + domain_info.get_federal_type_display()
|
"Domain type": domain_type,
|
||||||
if domain_info.federal_type
|
|
||||||
else domain_info.get_organization_type_display(),
|
|
||||||
"Agency": domain_info.federal_agency,
|
"Agency": domain_info.federal_agency,
|
||||||
"Organization name": domain_info.organization_name,
|
"Organization name": domain_info.organization_name,
|
||||||
"City": domain_info.city,
|
"City": domain_info.city,
|
||||||
"State": domain_info.state_territory,
|
"State": domain_info.state_territory,
|
||||||
"AO": ao,
|
"AO": domain_info.ao, # type: ignore
|
||||||
"AO email": domain_info.authorizing_official.email if domain_info.authorizing_official else " ",
|
"AO email": domain_info.authorizing_official.email if domain_info.authorizing_official else " ",
|
||||||
"Security contact email": security_email,
|
"Security contact email": security_email,
|
||||||
"Status": domain_info.domain.get_state_display(),
|
"Status": domain.get_state_display(),
|
||||||
"Expiration date": domain_info.domain.expiration_date,
|
"Expiration date": domain.expiration_date,
|
||||||
"Created at": domain_info.domain.created_at,
|
"Created at": domain.created_at,
|
||||||
"First ready": domain_info.domain.first_ready,
|
"First ready": domain.first_ready,
|
||||||
"Deleted": domain_info.domain.deleted,
|
"Deleted": domain.deleted,
|
||||||
}
|
}
|
||||||
|
|
||||||
writer.writerow([FIELDS.get(column, "") for column in columns])
|
row = [FIELDS.get(column, "") for column in columns]
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
def write_body(
|
def write_body(
|
||||||
|
@ -78,13 +108,41 @@ def write_body(
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Get the domainInfos
|
# Get the domainInfos
|
||||||
domain_infos = get_domain_infos(filter_condition, sort_fields)
|
all_domain_infos = get_domain_infos(filter_condition, sort_fields)
|
||||||
|
|
||||||
all_domain_infos = list(domain_infos)
|
# Store all security emails to avoid epp calls or excessive filters
|
||||||
|
sec_contact_ids = all_domain_infos.values_list("domain__security_contact_registry_id", flat=True)
|
||||||
|
security_emails_dict = {}
|
||||||
|
public_contacts = (
|
||||||
|
PublicContact.objects.only("email", "domain__name")
|
||||||
|
.select_related("domain")
|
||||||
|
.filter(registry_id__in=sec_contact_ids)
|
||||||
|
)
|
||||||
|
|
||||||
# Write rows to CSV
|
# Populate a dictionary of domain names and their security contacts
|
||||||
for domain_info in all_domain_infos:
|
for contact in public_contacts:
|
||||||
write_row(writer, columns, domain_info)
|
domain: Domain = contact.domain
|
||||||
|
if domain is not None and domain.name not in security_emails_dict:
|
||||||
|
security_emails_dict[domain.name] = contact.email
|
||||||
|
else:
|
||||||
|
logger.warning("csv_export -> Domain was none for PublicContact")
|
||||||
|
|
||||||
|
# Reduce the memory overhead when performing the write operation
|
||||||
|
paginator = Paginator(all_domain_infos, 1000)
|
||||||
|
for page_num in paginator.page_range:
|
||||||
|
page = paginator.page(page_num)
|
||||||
|
rows = []
|
||||||
|
for domain_info in page.object_list:
|
||||||
|
try:
|
||||||
|
row = parse_row(columns, domain_info, security_emails_dict)
|
||||||
|
rows.append(row)
|
||||||
|
except ValueError:
|
||||||
|
# This should not happen. If it does, just skip this row.
|
||||||
|
# It indicates that DomainInformation.domain is None.
|
||||||
|
logger.error("csv_export -> Error when parsing row, domain was None")
|
||||||
|
continue
|
||||||
|
|
||||||
|
writer.writerows(rows)
|
||||||
|
|
||||||
|
|
||||||
def export_data_type_to_csv(csv_file):
|
def export_data_type_to_csv(csv_file):
|
||||||
|
|
|
@ -12,6 +12,7 @@ from .domain import (
|
||||||
DomainUsersView,
|
DomainUsersView,
|
||||||
DomainAddUserView,
|
DomainAddUserView,
|
||||||
DomainInvitationDeleteView,
|
DomainInvitationDeleteView,
|
||||||
|
DomainDeleteUserView,
|
||||||
)
|
)
|
||||||
from .health import *
|
from .health import *
|
||||||
from .index import *
|
from .index import *
|
||||||
|
|
|
@ -33,6 +33,7 @@ from registrar.utility.errors import (
|
||||||
SecurityEmailErrorCodes,
|
SecurityEmailErrorCodes,
|
||||||
)
|
)
|
||||||
from registrar.models.utility.contact_error import ContactError
|
from registrar.models.utility.contact_error import ContactError
|
||||||
|
from registrar.views.utility.permission_views import UserDomainRolePermissionDeleteView
|
||||||
|
|
||||||
from ..forms import (
|
from ..forms import (
|
||||||
ContactForm,
|
ContactForm,
|
||||||
|
@ -630,6 +631,55 @@ class DomainUsersView(DomainBaseView):
|
||||||
|
|
||||||
template_name = "domain_users.html"
|
template_name = "domain_users.html"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
"""The initial value for the form (which is a formset here)."""
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
# Add conditionals to the context (such as "can_delete_users")
|
||||||
|
context = self._add_booleans_to_context(context)
|
||||||
|
|
||||||
|
# Add modal buttons to the context (such as for delete)
|
||||||
|
context = self._add_modal_buttons_to_context(context)
|
||||||
|
|
||||||
|
# Get the email of the current user
|
||||||
|
context["current_user_email"] = self.request.user.email
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
def _add_booleans_to_context(self, context):
|
||||||
|
# Determine if the current user can delete managers
|
||||||
|
domain_pk = None
|
||||||
|
can_delete_users = False
|
||||||
|
|
||||||
|
if self.kwargs is not None and "pk" in self.kwargs:
|
||||||
|
domain_pk = self.kwargs["pk"]
|
||||||
|
# Prevent the end user from deleting themselves as a manager if they are the
|
||||||
|
# only manager that exists on a domain.
|
||||||
|
can_delete_users = UserDomainRole.objects.filter(domain__id=domain_pk).count() > 1
|
||||||
|
|
||||||
|
context["can_delete_users"] = can_delete_users
|
||||||
|
return context
|
||||||
|
|
||||||
|
def _add_modal_buttons_to_context(self, context):
|
||||||
|
"""Adds modal buttons (and their HTML) to the context"""
|
||||||
|
# Create HTML for the modal button
|
||||||
|
modal_button = (
|
||||||
|
'<button type="submit" '
|
||||||
|
'class="usa-button usa-button--secondary" '
|
||||||
|
'name="delete_domain_manager">Yes, remove domain manager</button>'
|
||||||
|
)
|
||||||
|
context["modal_button"] = modal_button
|
||||||
|
|
||||||
|
# Create HTML for the modal button when deleting yourself
|
||||||
|
modal_button_self = (
|
||||||
|
'<button type="submit" '
|
||||||
|
'class="usa-button usa-button--secondary" '
|
||||||
|
'name="delete_domain_manager_self">Yes, remove myself</button>'
|
||||||
|
)
|
||||||
|
context["modal_button_self"] = modal_button_self
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
class DomainAddUserView(DomainFormBaseView):
|
class DomainAddUserView(DomainFormBaseView):
|
||||||
"""Inside of a domain's user management, a form for adding users.
|
"""Inside of a domain's user management, a form for adding users.
|
||||||
|
@ -743,3 +793,60 @@ class DomainInvitationDeleteView(DomainInvitationPermissionDeleteView, SuccessMe
|
||||||
|
|
||||||
def get_success_message(self, cleaned_data):
|
def get_success_message(self, cleaned_data):
|
||||||
return f"Successfully canceled invitation for {self.object.email}."
|
return f"Successfully canceled invitation for {self.object.email}."
|
||||||
|
|
||||||
|
|
||||||
|
class DomainDeleteUserView(UserDomainRolePermissionDeleteView):
|
||||||
|
"""Inside of a domain's user management, a form for deleting users."""
|
||||||
|
|
||||||
|
object: UserDomainRole # workaround for type mismatch in DeleteView
|
||||||
|
|
||||||
|
def get_object(self, queryset=None):
|
||||||
|
"""Custom get_object definition to grab a UserDomainRole object from a domain_id and user_id"""
|
||||||
|
domain_id = self.kwargs.get("pk")
|
||||||
|
user_id = self.kwargs.get("user_pk")
|
||||||
|
return UserDomainRole.objects.get(domain=domain_id, user=user_id)
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
"""Refreshes the page after a delete is successful"""
|
||||||
|
return reverse("domain-users", kwargs={"pk": self.object.domain.id})
|
||||||
|
|
||||||
|
def get_success_message(self, delete_self=False):
|
||||||
|
"""Returns confirmation content for the deletion event"""
|
||||||
|
|
||||||
|
# Grab the text representation of the user we want to delete
|
||||||
|
email_or_name = self.object.user.email
|
||||||
|
if email_or_name is None or email_or_name.strip() == "":
|
||||||
|
email_or_name = self.object.user
|
||||||
|
|
||||||
|
# If the user is deleting themselves, return a specific message.
|
||||||
|
# If not, return something more generic.
|
||||||
|
if delete_self:
|
||||||
|
message = f"You are no longer managing the domain {self.object.domain}."
|
||||||
|
else:
|
||||||
|
message = f"Removed {email_or_name} as a manager for this domain."
|
||||||
|
|
||||||
|
return message
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
"""Delete the specified user on this domain."""
|
||||||
|
|
||||||
|
# Delete the object
|
||||||
|
super().form_valid(form)
|
||||||
|
|
||||||
|
# Is the user deleting themselves? If so, display a different message
|
||||||
|
delete_self = self.request.user == self.object.user
|
||||||
|
|
||||||
|
# Add a success message
|
||||||
|
messages.success(self.request, self.get_success_message(delete_self))
|
||||||
|
return redirect(self.get_success_url())
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
"""Custom post implementation to redirect to home in the event that the user deletes themselves"""
|
||||||
|
response = super().post(request, *args, **kwargs)
|
||||||
|
|
||||||
|
# If the user is deleting themselves, redirect to home
|
||||||
|
delete_self = self.request.user == self.object.user
|
||||||
|
if delete_self:
|
||||||
|
return redirect(reverse("home"))
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
|
@ -286,6 +286,43 @@ class DomainApplicationPermission(PermissionsLoginMixin):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class UserDeleteDomainRolePermission(PermissionsLoginMixin):
|
||||||
|
|
||||||
|
"""Permission mixin for UserDomainRole if user
|
||||||
|
has access, otherwise 403"""
|
||||||
|
|
||||||
|
def has_permission(self):
|
||||||
|
"""Check if this user has access to this domain application.
|
||||||
|
|
||||||
|
The user is in self.request.user and the domain needs to be looked
|
||||||
|
up from the domain's primary key in self.kwargs["pk"]
|
||||||
|
"""
|
||||||
|
domain_pk = self.kwargs["pk"]
|
||||||
|
user_pk = self.kwargs["user_pk"]
|
||||||
|
|
||||||
|
# Check if the user is authenticated
|
||||||
|
if not self.request.user.is_authenticated:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if the UserDomainRole object exists, then check
|
||||||
|
# if the user requesting the delete has permissions to do so
|
||||||
|
has_delete_permission = UserDomainRole.objects.filter(
|
||||||
|
user=user_pk,
|
||||||
|
domain=domain_pk,
|
||||||
|
domain__permissions__user=self.request.user,
|
||||||
|
).exists()
|
||||||
|
if not has_delete_permission:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if more than one manager exists on the domain.
|
||||||
|
# If only one exists, prevent this from happening
|
||||||
|
has_multiple_managers = len(UserDomainRole.objects.filter(domain=domain_pk)) > 1
|
||||||
|
if not has_multiple_managers:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
class DomainApplicationPermissionWithdraw(PermissionsLoginMixin):
|
class DomainApplicationPermissionWithdraw(PermissionsLoginMixin):
|
||||||
|
|
||||||
"""Permission mixin that redirects to withdraw action on domain application
|
"""Permission mixin that redirects to withdraw action on domain application
|
||||||
|
|
|
@ -4,6 +4,7 @@ import abc # abstract base class
|
||||||
|
|
||||||
from django.views.generic import DetailView, DeleteView, TemplateView
|
from django.views.generic import DetailView, DeleteView, TemplateView
|
||||||
from registrar.models import Domain, DomainApplication, DomainInvitation
|
from registrar.models import Domain, DomainApplication, DomainInvitation
|
||||||
|
from registrar.models.user_domain_role import UserDomainRole
|
||||||
|
|
||||||
from .mixins import (
|
from .mixins import (
|
||||||
DomainPermission,
|
DomainPermission,
|
||||||
|
@ -11,6 +12,7 @@ from .mixins import (
|
||||||
DomainApplicationPermissionWithdraw,
|
DomainApplicationPermissionWithdraw,
|
||||||
DomainInvitationPermission,
|
DomainInvitationPermission,
|
||||||
ApplicationWizardPermission,
|
ApplicationWizardPermission,
|
||||||
|
UserDeleteDomainRolePermission,
|
||||||
)
|
)
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
@ -130,3 +132,20 @@ class DomainApplicationPermissionDeleteView(DomainApplicationPermission, DeleteV
|
||||||
|
|
||||||
model = DomainApplication
|
model = DomainApplication
|
||||||
object: DomainApplication
|
object: DomainApplication
|
||||||
|
|
||||||
|
|
||||||
|
class UserDomainRolePermissionDeleteView(UserDeleteDomainRolePermission, DeleteView, abc.ABC):
|
||||||
|
|
||||||
|
"""Abstract base view for deleting a UserDomainRole.
|
||||||
|
|
||||||
|
This abstract view cannot be instantiated. Actual views must specify
|
||||||
|
`template_name`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# DetailView property for what model this is viewing
|
||||||
|
model = UserDomainRole
|
||||||
|
# workaround for type mismatch in DeleteView
|
||||||
|
object: UserDomainRole
|
||||||
|
|
||||||
|
# variable name in template context for the model object
|
||||||
|
context_object_name = "userdomainrole"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue