mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-05-19 02:49:21 +02:00
Merge remote-tracking branch 'origin/main' into ms/2307-send-notification-emails
This commit is contained in:
commit
a722d3c9c5
75 changed files with 4137 additions and 953 deletions
|
@ -5,6 +5,11 @@ from django import forms
|
||||||
from django.db.models import Value, CharField, Q
|
from django.db.models import Value, CharField, Q
|
||||||
from django.db.models.functions import Concat, Coalesce
|
from django.db.models.functions import Concat, Coalesce
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect
|
||||||
|
from registrar.utility.admin_helpers import (
|
||||||
|
get_action_needed_reason_default_email,
|
||||||
|
get_rejection_reason_default_email,
|
||||||
|
get_field_links_as_list,
|
||||||
|
)
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django_fsm import get_available_FIELD_transitions, FSMField
|
from django_fsm import get_available_FIELD_transitions, FSMField
|
||||||
|
@ -20,11 +25,6 @@ from epplibwrapper.errors import ErrorCode, RegistryError
|
||||||
from registrar.models.user_domain_role import UserDomainRole
|
from registrar.models.user_domain_role import UserDomainRole
|
||||||
from waffle.admin import FlagAdmin
|
from waffle.admin import FlagAdmin
|
||||||
from waffle.models import Sample, Switch
|
from waffle.models import Sample, Switch
|
||||||
from registrar.utility.admin_helpers import (
|
|
||||||
get_all_action_needed_reason_emails,
|
|
||||||
get_action_needed_reason_default_email,
|
|
||||||
get_field_links_as_list,
|
|
||||||
)
|
|
||||||
from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial
|
from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial
|
||||||
from registrar.utility.constants import BranchChoices
|
from registrar.utility.constants import BranchChoices
|
||||||
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
|
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
|
||||||
|
@ -190,11 +190,11 @@ class PortfolioInvitationAdminForm(UserChangeForm):
|
||||||
model = models.PortfolioInvitation
|
model = models.PortfolioInvitation
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
widgets = {
|
widgets = {
|
||||||
"portfolio_roles": FilteredSelectMultipleArrayWidget(
|
"roles": FilteredSelectMultipleArrayWidget(
|
||||||
"portfolio_roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices
|
"roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices
|
||||||
),
|
),
|
||||||
"portfolio_additional_permissions": FilteredSelectMultipleArrayWidget(
|
"additional_permissions": FilteredSelectMultipleArrayWidget(
|
||||||
"portfolio_additional_permissions",
|
"additional_permissions",
|
||||||
is_stacked=False,
|
is_stacked=False,
|
||||||
choices=UserPortfolioPermissionChoices.choices,
|
choices=UserPortfolioPermissionChoices.choices,
|
||||||
),
|
),
|
||||||
|
@ -237,6 +237,7 @@ class DomainRequestAdminForm(forms.ModelForm):
|
||||||
}
|
}
|
||||||
labels = {
|
labels = {
|
||||||
"action_needed_reason_email": "Email",
|
"action_needed_reason_email": "Email",
|
||||||
|
"rejection_reason_email": "Email",
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
@ -1408,8 +1409,8 @@ class PortfolioInvitationAdmin(ListHeaderAdmin):
|
||||||
list_display = [
|
list_display = [
|
||||||
"email",
|
"email",
|
||||||
"portfolio",
|
"portfolio",
|
||||||
"portfolio_roles",
|
"roles",
|
||||||
"portfolio_additional_permissions",
|
"additional_permissions",
|
||||||
"status",
|
"status",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -1750,6 +1751,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
"status_history",
|
"status_history",
|
||||||
"status",
|
"status",
|
||||||
"rejection_reason",
|
"rejection_reason",
|
||||||
|
"rejection_reason_email",
|
||||||
"action_needed_reason",
|
"action_needed_reason",
|
||||||
"action_needed_reason_email",
|
"action_needed_reason_email",
|
||||||
"investigator",
|
"investigator",
|
||||||
|
@ -1905,25 +1907,11 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
# Get the original domain request from the database.
|
# Get the original domain request from the database.
|
||||||
original_obj = models.DomainRequest.objects.get(pk=obj.pk)
|
original_obj = models.DomainRequest.objects.get(pk=obj.pk)
|
||||||
|
|
||||||
# == Handle action_needed_reason == #
|
# == Handle action needed and rejected emails == #
|
||||||
|
# Edge case: this logic is handled by javascript, so contexts outside that must be handled
|
||||||
reason_changed = obj.action_needed_reason != original_obj.action_needed_reason
|
obj = self._handle_custom_emails(obj)
|
||||||
if reason_changed:
|
|
||||||
# Track the fact that we sent out an email
|
|
||||||
request.session["action_needed_email_sent"] = True
|
|
||||||
|
|
||||||
# Set the action_needed_reason_email to the default if nothing exists.
|
|
||||||
# Since this check occurs after save, if the user enters a value then we won't update.
|
|
||||||
|
|
||||||
default_email = get_action_needed_reason_default_email(request, obj, obj.action_needed_reason)
|
|
||||||
if obj.action_needed_reason_email:
|
|
||||||
emails = get_all_action_needed_reason_emails(request, obj)
|
|
||||||
is_custom_email = obj.action_needed_reason_email not in emails.values()
|
|
||||||
if not is_custom_email:
|
|
||||||
obj.action_needed_reason_email = default_email
|
|
||||||
else:
|
|
||||||
obj.action_needed_reason_email = default_email
|
|
||||||
|
|
||||||
|
# == Handle allowed emails == #
|
||||||
if obj.status in DomainRequest.get_statuses_that_send_emails() and not settings.IS_PRODUCTION:
|
if obj.status in DomainRequest.get_statuses_that_send_emails() and not settings.IS_PRODUCTION:
|
||||||
self._check_for_valid_email(request, obj)
|
self._check_for_valid_email(request, obj)
|
||||||
|
|
||||||
|
@ -1939,6 +1927,15 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
if should_save:
|
if should_save:
|
||||||
return super().save_model(request, obj, form, change)
|
return super().save_model(request, obj, form, change)
|
||||||
|
|
||||||
|
def _handle_custom_emails(self, obj):
|
||||||
|
if obj.status == DomainRequest.DomainRequestStatus.ACTION_NEEDED:
|
||||||
|
if obj.action_needed_reason and not obj.action_needed_reason_email:
|
||||||
|
obj.action_needed_reason_email = get_action_needed_reason_default_email(obj, obj.action_needed_reason)
|
||||||
|
elif obj.status == DomainRequest.DomainRequestStatus.REJECTED:
|
||||||
|
if obj.rejection_reason and not obj.rejection_reason_email:
|
||||||
|
obj.rejection_reason_email = get_rejection_reason_default_email(obj, obj.rejection_reason)
|
||||||
|
return obj
|
||||||
|
|
||||||
def _check_for_valid_email(self, request, obj):
|
def _check_for_valid_email(self, request, obj):
|
||||||
"""Certain emails are whitelisted in non-production environments,
|
"""Certain emails are whitelisted in non-production environments,
|
||||||
so we should display that information using this function.
|
so we should display that information using this function.
|
||||||
|
@ -1976,18 +1973,30 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
|
|
||||||
# If the status is not mapped properly, saving could cause
|
# If the status is not mapped properly, saving could cause
|
||||||
# weird issues down the line. Instead, we should block this.
|
# weird issues down the line. Instead, we should block this.
|
||||||
|
# NEEDS A UNIT TEST
|
||||||
should_proceed = False
|
should_proceed = False
|
||||||
return should_proceed
|
return (obj, should_proceed)
|
||||||
|
|
||||||
request_is_not_approved = obj.status != models.DomainRequest.DomainRequestStatus.APPROVED
|
obj_is_not_approved = obj.status != models.DomainRequest.DomainRequestStatus.APPROVED
|
||||||
if request_is_not_approved and not obj.domain_is_not_active():
|
if obj_is_not_approved and not obj.domain_is_not_active():
|
||||||
# If an admin tried to set an approved domain request to
|
# REDUNDANT CHECK / ERROR SCREEN AVOIDANCE:
|
||||||
# another status and the related domain is already
|
# This action (moving a request from approved to
|
||||||
# active, shortcut the action and throw a friendly
|
# another status) when the domain is already active (READY),
|
||||||
# error message. This action would still not go through
|
# would still not go through even without this check as the rules are
|
||||||
# shortcut or not as the rules are duplicated on the model,
|
# duplicated in the model and the error is raised from the model.
|
||||||
# but the error would be an ugly Django error screen.
|
# This avoids an ugly Django error screen.
|
||||||
error_message = "This action is not permitted. The domain is already active."
|
error_message = "This action is not permitted. The domain is already active."
|
||||||
|
elif (
|
||||||
|
original_obj.status != models.DomainRequest.DomainRequestStatus.APPROVED
|
||||||
|
and obj.status == models.DomainRequest.DomainRequestStatus.APPROVED
|
||||||
|
and original_obj.requested_domain is not None
|
||||||
|
and Domain.objects.filter(name=original_obj.requested_domain.name).exists()
|
||||||
|
):
|
||||||
|
# REDUNDANT CHECK:
|
||||||
|
# This action (approving a request when the domain exists)
|
||||||
|
# would still not go through even without this check as the rules are
|
||||||
|
# duplicated in the model and the error is raised from the model.
|
||||||
|
error_message = FSMDomainRequestError.get_error_message(FSMErrorCodes.APPROVE_DOMAIN_IN_USE)
|
||||||
elif obj.status == models.DomainRequest.DomainRequestStatus.REJECTED and not obj.rejection_reason:
|
elif obj.status == models.DomainRequest.DomainRequestStatus.REJECTED and not obj.rejection_reason:
|
||||||
# This condition should never be triggered.
|
# This condition should never be triggered.
|
||||||
# The opposite of this condition is acceptable (rejected -> other status and rejection_reason)
|
# The opposite of this condition is acceptable (rejected -> other status and rejection_reason)
|
||||||
|
@ -2464,7 +2473,10 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
generic_org_type.admin_order_field = "domain_info__generic_org_type" # type: ignore
|
generic_org_type.admin_order_field = "domain_info__generic_org_type" # type: ignore
|
||||||
|
|
||||||
def federal_agency(self, obj):
|
def federal_agency(self, obj):
|
||||||
return obj.domain_info.federal_agency if obj.domain_info else None
|
if obj.domain_info:
|
||||||
|
return obj.domain_info.federal_agency
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
federal_agency.admin_order_field = "domain_info__federal_agency" # type: ignore
|
federal_agency.admin_order_field = "domain_info__federal_agency" # type: ignore
|
||||||
|
|
||||||
|
|
|
@ -344,69 +344,6 @@ function initializeWidgetOnList(list, parentId) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An IIFE for admin in DjangoAdmin to listen to changes on the domain request
|
|
||||||
* status select and to show/hide the rejection reason
|
|
||||||
*/
|
|
||||||
(function (){
|
|
||||||
let rejectionReasonFormGroup = document.querySelector('.field-rejection_reason')
|
|
||||||
// This is the "action needed reason" field
|
|
||||||
let actionNeededReasonFormGroup = document.querySelector('.field-action_needed_reason');
|
|
||||||
// This is the "Email" field
|
|
||||||
let actionNeededReasonEmailFormGroup = document.querySelector('.field-action_needed_reason_email')
|
|
||||||
|
|
||||||
if (rejectionReasonFormGroup && actionNeededReasonFormGroup && actionNeededReasonEmailFormGroup) {
|
|
||||||
let statusSelect = document.getElementById('id_status')
|
|
||||||
let isRejected = statusSelect.value == "rejected"
|
|
||||||
let isActionNeeded = statusSelect.value == "action needed"
|
|
||||||
|
|
||||||
// Initial handling of rejectionReasonFormGroup display
|
|
||||||
showOrHideObject(rejectionReasonFormGroup, show=isRejected)
|
|
||||||
showOrHideObject(actionNeededReasonFormGroup, show=isActionNeeded)
|
|
||||||
showOrHideObject(actionNeededReasonEmailFormGroup, show=isActionNeeded)
|
|
||||||
|
|
||||||
// Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage
|
|
||||||
statusSelect.addEventListener('change', function() {
|
|
||||||
// Show the rejection reason field if the status is rejected.
|
|
||||||
// Then track if its shown or hidden in our session cache.
|
|
||||||
isRejected = statusSelect.value == "rejected"
|
|
||||||
showOrHideObject(rejectionReasonFormGroup, show=isRejected)
|
|
||||||
addOrRemoveSessionBoolean("showRejectionReason", add=isRejected)
|
|
||||||
|
|
||||||
isActionNeeded = statusSelect.value == "action needed"
|
|
||||||
showOrHideObject(actionNeededReasonFormGroup, show=isActionNeeded)
|
|
||||||
showOrHideObject(actionNeededReasonEmailFormGroup, show=isActionNeeded)
|
|
||||||
addOrRemoveSessionBoolean("showActionNeededReason", add=isActionNeeded)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen to Back/Forward button navigation and handle rejectionReasonFormGroup display based on session storage
|
|
||||||
|
|
||||||
// When you navigate using forward/back after changing status but not saving, when you land back on the DA page the
|
|
||||||
// status select will say (for example) Rejected but the selected option can be something else. To manage the show/hide
|
|
||||||
// accurately for this edge case, we use cache and test for the back/forward navigation.
|
|
||||||
const observer = new PerformanceObserver((list) => {
|
|
||||||
list.getEntries().forEach((entry) => {
|
|
||||||
if (entry.type === "back_forward") {
|
|
||||||
let showRejectionReason = sessionStorage.getItem("showRejectionReason") !== null
|
|
||||||
showOrHideObject(rejectionReasonFormGroup, show=showRejectionReason)
|
|
||||||
|
|
||||||
let showActionNeededReason = sessionStorage.getItem("showActionNeededReason") !== null
|
|
||||||
showOrHideObject(actionNeededReasonFormGroup, show=showActionNeededReason)
|
|
||||||
showOrHideObject(actionNeededReasonEmailFormGroup, show=isActionNeeded)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
observer.observe({ type: "navigation" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adds or removes the display-none class to object depending on the value of boolean show
|
|
||||||
function showOrHideObject(object, show){
|
|
||||||
if (show){
|
|
||||||
object.classList.remove("display-none");
|
|
||||||
}else {
|
|
||||||
object.classList.add("display-none");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
/** An IIFE for toggling the submit bar on domain request forms
|
/** An IIFE for toggling the submit bar on domain request forms
|
||||||
*/
|
*/
|
||||||
|
@ -501,82 +438,110 @@ function initializeWidgetOnList(list, parentId) {
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
||||||
/** An IIFE that hooks to the show/hide button underneath action needed reason.
|
class CustomizableEmailBase {
|
||||||
* This shows the auto generated email on action needed reason.
|
|
||||||
*/
|
/**
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
* @param {Object} config - must contain the following:
|
||||||
const dropdown = document.getElementById("id_action_needed_reason");
|
* @property {HTMLElement} dropdown - The dropdown element.
|
||||||
const textarea = document.getElementById("id_action_needed_reason_email")
|
* @property {HTMLElement} textarea - The textarea element.
|
||||||
const domainRequestId = dropdown ? document.getElementById("domain_request_id").value : null
|
* @property {HTMLElement} lastSentEmailContent - The last sent email content element.
|
||||||
const textareaPlaceholder = document.querySelector(".field-action_needed_reason_email__placeholder");
|
* @property {HTMLElement} textAreaFormGroup - The form group for the textarea.
|
||||||
const directEditButton = document.querySelector('.field-action_needed_reason_email__edit');
|
* @property {HTMLElement} dropdownFormGroup - The form group for the dropdown.
|
||||||
const modalTrigger = document.querySelector('.field-action_needed_reason_email__modal-trigger');
|
* @property {HTMLElement} modalConfirm - The confirm button in the modal.
|
||||||
const modalConfirm = document.getElementById('confirm-edit-email');
|
* @property {string} apiUrl - The API URL for fetching email content.
|
||||||
const formLabel = document.querySelector('label[for="id_action_needed_reason_email"]');
|
* @property {string} statusToCheck - The status to check against. Used for show/hide on textAreaFormGroup/dropdownFormGroup.
|
||||||
let lastSentEmailContent = document.getElementById("last-sent-email-content");
|
* @property {string} sessionVariableName - The session variable name. Used for show/hide on textAreaFormGroup/dropdownFormGroup.
|
||||||
const initialDropdownValue = dropdown ? dropdown.value : null;
|
* @property {string} apiErrorMessage - The error message that the ajax call returns.
|
||||||
const initialEmailValue = textarea.value;
|
*/
|
||||||
|
constructor(config) {
|
||||||
|
this.config = config;
|
||||||
|
this.dropdown = config.dropdown;
|
||||||
|
this.textarea = config.textarea;
|
||||||
|
this.lastSentEmailContent = config.lastSentEmailContent;
|
||||||
|
this.apiUrl = config.apiUrl;
|
||||||
|
this.apiErrorMessage = config.apiErrorMessage;
|
||||||
|
this.modalConfirm = config.modalConfirm;
|
||||||
|
|
||||||
|
// These fields are hidden/shown on pageload depending on the current status
|
||||||
|
this.textAreaFormGroup = config.textAreaFormGroup;
|
||||||
|
this.dropdownFormGroup = config.dropdownFormGroup;
|
||||||
|
this.statusToCheck = config.statusToCheck;
|
||||||
|
this.sessionVariableName = config.sessionVariableName;
|
||||||
|
|
||||||
|
// Non-configurable variables
|
||||||
|
this.statusSelect = document.getElementById("id_status");
|
||||||
|
this.domainRequestId = this.dropdown ? document.getElementById("domain_request_id").value : null
|
||||||
|
this.initialDropdownValue = this.dropdown ? this.dropdown.value : null;
|
||||||
|
this.initialEmailValue = this.textarea ? this.textarea.value : null;
|
||||||
|
|
||||||
|
// Find other fields near the textarea
|
||||||
|
const parentDiv = this.textarea ? this.textarea.closest(".flex-container") : null;
|
||||||
|
this.directEditButton = parentDiv ? parentDiv.querySelector(".edit-email-button") : null;
|
||||||
|
this.modalTrigger = parentDiv ? parentDiv.querySelector(".edit-button-modal-trigger") : null;
|
||||||
|
|
||||||
|
this.textareaPlaceholder = parentDiv ? parentDiv.querySelector(".custom-email-placeholder") : null;
|
||||||
|
this.formLabel = this.textarea ? document.querySelector(`label[for="${this.textarea.id}"]`) : null;
|
||||||
|
|
||||||
|
this.isEmailAlreadySentConst;
|
||||||
|
if (this.lastSentEmailContent && this.textarea) {
|
||||||
|
this.isEmailAlreadySentConst = this.lastSentEmailContent.value.replace(/\s+/g, '') === this.textarea.value.replace(/\s+/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
// We will use the const to control the modal
|
|
||||||
let isEmailAlreadySentConst = lastSentEmailContent.value.replace(/\s+/g, '') === textarea.value.replace(/\s+/g, '');
|
|
||||||
// We will use the function to control the label and help
|
|
||||||
function isEmailAlreadySent() {
|
|
||||||
return lastSentEmailContent.value.replace(/\s+/g, '') === textarea.value.replace(/\s+/g, '');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!dropdown || !textarea || !domainRequestId || !formLabel || !modalConfirm) return;
|
// Handle showing/hiding the related fields on page load.
|
||||||
const apiUrl = document.getElementById("get-action-needed-email-for-user-json").value;
|
initializeFormGroups() {
|
||||||
|
let isStatus = this.statusSelect.value == this.statusToCheck;
|
||||||
|
|
||||||
function updateUserInterface(reason) {
|
// Initial handling of these groups.
|
||||||
if (!reason) {
|
this.updateFormGroupVisibility(isStatus);
|
||||||
// No reason selected, we will set the label to "Email", show the "Make a selection" placeholder, hide the trigger, textarea, hide the help text
|
|
||||||
formLabel.innerHTML = "Email:";
|
|
||||||
textareaPlaceholder.innerHTML = "Select an action needed reason to see email";
|
|
||||||
showElement(textareaPlaceholder);
|
|
||||||
hideElement(directEditButton);
|
|
||||||
hideElement(modalTrigger);
|
|
||||||
hideElement(textarea);
|
|
||||||
} else if (reason === 'other') {
|
|
||||||
// 'Other' selected, we will set the label to "Email", show the "No email will be sent" placeholder, hide the trigger, textarea, hide the help text
|
|
||||||
formLabel.innerHTML = "Email:";
|
|
||||||
textareaPlaceholder.innerHTML = "No email will be sent";
|
|
||||||
showElement(textareaPlaceholder);
|
|
||||||
hideElement(directEditButton);
|
|
||||||
hideElement(modalTrigger);
|
|
||||||
hideElement(textarea);
|
|
||||||
} else {
|
|
||||||
// A triggering selection is selected, all hands on board:
|
|
||||||
textarea.setAttribute('readonly', true);
|
|
||||||
showElement(textarea);
|
|
||||||
hideElement(textareaPlaceholder);
|
|
||||||
|
|
||||||
if (isEmailAlreadySentConst) {
|
// Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage
|
||||||
hideElement(directEditButton);
|
this.statusSelect.addEventListener('change', () => {
|
||||||
showElement(modalTrigger);
|
// Show the action needed field if the status is what we expect.
|
||||||
} else {
|
// Then track if its shown or hidden in our session cache.
|
||||||
showElement(directEditButton);
|
isStatus = this.statusSelect.value == this.statusToCheck;
|
||||||
hideElement(modalTrigger);
|
this.updateFormGroupVisibility(isStatus);
|
||||||
}
|
addOrRemoveSessionBoolean(this.sessionVariableName, isStatus);
|
||||||
if (isEmailAlreadySent()) {
|
});
|
||||||
formLabel.innerHTML = "Email sent to creator:";
|
|
||||||
} else {
|
// Listen to Back/Forward button navigation and handle rejectionReasonFormGroup display based on session storage
|
||||||
formLabel.innerHTML = "Email:";
|
// When you navigate using forward/back after changing status but not saving, when you land back on the DA page the
|
||||||
}
|
// status select will say (for example) Rejected but the selected option can be something else. To manage the show/hide
|
||||||
|
// accurately for this edge case, we use cache and test for the back/forward navigation.
|
||||||
|
const observer = new PerformanceObserver((list) => {
|
||||||
|
list.getEntries().forEach((entry) => {
|
||||||
|
if (entry.type === "back_forward") {
|
||||||
|
let showTextAreaFormGroup = sessionStorage.getItem(this.sessionVariableName) !== null;
|
||||||
|
this.updateFormGroupVisibility(showTextAreaFormGroup);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
observer.observe({ type: "navigation" });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFormGroupVisibility(showFormGroups) {
|
||||||
|
if (showFormGroups) {
|
||||||
|
showElement(this.textAreaFormGroup);
|
||||||
|
showElement(this.dropdownFormGroup);
|
||||||
|
}else {
|
||||||
|
hideElement(this.textAreaFormGroup);
|
||||||
|
hideElement(this.dropdownFormGroup);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize UI
|
initializeDropdown() {
|
||||||
updateUserInterface(dropdown.value);
|
this.dropdown.addEventListener("change", () => {
|
||||||
|
let reason = this.dropdown.value;
|
||||||
dropdown.addEventListener("change", function() {
|
if (this.initialDropdownValue !== this.dropdown.value || this.initialEmailValue !== this.textarea.value) {
|
||||||
const reason = dropdown.value;
|
let searchParams = new URLSearchParams(
|
||||||
// Update the UI
|
{
|
||||||
updateUserInterface(reason);
|
"reason": reason,
|
||||||
if (reason && reason !== "other") {
|
"domain_request_id": this.domainRequestId,
|
||||||
// If it's not the initial value
|
}
|
||||||
if (initialDropdownValue !== dropdown.value || initialEmailValue !== textarea.value) {
|
);
|
||||||
// Replace the email content
|
// Replace the email content
|
||||||
fetch(`${apiUrl}?reason=${reason}&domain_request_id=${domainRequestId}`)
|
fetch(`${this.apiUrl}?${searchParams.toString()}`)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
return response.json().then(data => data);
|
return response.json().then(data => data);
|
||||||
})
|
})
|
||||||
|
@ -584,30 +549,213 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
console.error("Error in AJAX call: " + data.error);
|
console.error("Error in AJAX call: " + data.error);
|
||||||
}else {
|
}else {
|
||||||
textarea.value = data.action_needed_email;
|
this.textarea.value = data.email;
|
||||||
}
|
}
|
||||||
updateUserInterface(reason);
|
this.updateUserInterface(reason);
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error("Error action needed email: ", error)
|
console.error(this.apiErrorMessage, error)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeModalConfirm() {
|
||||||
|
this.modalConfirm.addEventListener("click", () => {
|
||||||
|
this.textarea.removeAttribute('readonly');
|
||||||
|
this.textarea.focus();
|
||||||
|
hideElement(this.directEditButton);
|
||||||
|
hideElement(this.modalTrigger);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeDirectEditButton() {
|
||||||
|
this.directEditButton.addEventListener("click", () => {
|
||||||
|
this.textarea.removeAttribute('readonly');
|
||||||
|
this.textarea.focus();
|
||||||
|
hideElement(this.directEditButton);
|
||||||
|
hideElement(this.modalTrigger);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isEmailAlreadySent() {
|
||||||
|
return this.lastSentEmailContent.value.replace(/\s+/g, '') === this.textarea.value.replace(/\s+/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUserInterface(reason=this.dropdown.value, excluded_reasons=["other"]) {
|
||||||
|
if (!reason) {
|
||||||
|
// No reason selected, we will set the label to "Email", show the "Make a selection" placeholder, hide the trigger, textarea, hide the help text
|
||||||
|
this.showPlaceholderNoReason();
|
||||||
|
} else if (excluded_reasons.includes(reason)) {
|
||||||
|
// 'Other' selected, we will set the label to "Email", show the "No email will be sent" placeholder, hide the trigger, textarea, hide the help text
|
||||||
|
this.showPlaceholderOtherReason();
|
||||||
|
} else {
|
||||||
|
this.showReadonlyTextarea();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function that makes overriding the readonly textarea easy
|
||||||
|
showReadonlyTextarea() {
|
||||||
|
// A triggering selection is selected, all hands on board:
|
||||||
|
this.textarea.setAttribute('readonly', true);
|
||||||
|
showElement(this.textarea);
|
||||||
|
hideElement(this.textareaPlaceholder);
|
||||||
|
|
||||||
|
if (this.isEmailAlreadySentConst) {
|
||||||
|
hideElement(this.directEditButton);
|
||||||
|
showElement(this.modalTrigger);
|
||||||
|
} else {
|
||||||
|
showElement(this.directEditButton);
|
||||||
|
hideElement(this.modalTrigger);
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
if (this.isEmailAlreadySent()) {
|
||||||
|
this.formLabel.innerHTML = "Email sent to creator:";
|
||||||
|
} else {
|
||||||
|
this.formLabel.innerHTML = "Email:";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
modalConfirm.addEventListener("click", () => {
|
// Helper function that makes overriding the placeholder reason easy
|
||||||
textarea.removeAttribute('readonly');
|
showPlaceholderNoReason() {
|
||||||
textarea.focus();
|
this.showPlaceholder("Email:", "Select a reason to see email");
|
||||||
hideElement(directEditButton);
|
}
|
||||||
hideElement(modalTrigger);
|
|
||||||
});
|
// Helper function that makes overriding the placeholder reason easy
|
||||||
directEditButton.addEventListener("click", () => {
|
showPlaceholderOtherReason() {
|
||||||
textarea.removeAttribute('readonly');
|
this.showPlaceholder("Email:", "No email will be sent");
|
||||||
textarea.focus();
|
}
|
||||||
hideElement(directEditButton);
|
|
||||||
hideElement(modalTrigger);
|
showPlaceholder(formLabelText, placeholderText) {
|
||||||
});
|
this.formLabel.innerHTML = formLabelText;
|
||||||
|
this.textareaPlaceholder.innerHTML = placeholderText;
|
||||||
|
showElement(this.textareaPlaceholder);
|
||||||
|
hideElement(this.directEditButton);
|
||||||
|
hideElement(this.modalTrigger);
|
||||||
|
hideElement(this.textarea);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class customActionNeededEmail extends CustomizableEmailBase {
|
||||||
|
constructor() {
|
||||||
|
const emailConfig = {
|
||||||
|
dropdown: document.getElementById("id_action_needed_reason"),
|
||||||
|
textarea: document.getElementById("id_action_needed_reason_email"),
|
||||||
|
lastSentEmailContent: document.getElementById("last-sent-action-needed-email-content"),
|
||||||
|
modalConfirm: document.getElementById("action-needed-reason__confirm-edit-email"),
|
||||||
|
apiUrl: document.getElementById("get-action-needed-email-for-user-json")?.value || null,
|
||||||
|
textAreaFormGroup: document.querySelector('.field-action_needed_reason'),
|
||||||
|
dropdownFormGroup: document.querySelector('.field-action_needed_reason_email'),
|
||||||
|
statusToCheck: "action needed",
|
||||||
|
sessionVariableName: "showActionNeededReason",
|
||||||
|
apiErrorMessage: "Error when attempting to grab action needed email: "
|
||||||
|
}
|
||||||
|
super(emailConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadActionNeededEmail() {
|
||||||
|
// Hide/show the email fields depending on the current status
|
||||||
|
this.initializeFormGroups();
|
||||||
|
// Setup the textarea, edit button, helper text
|
||||||
|
this.updateUserInterface();
|
||||||
|
this.initializeDropdown();
|
||||||
|
this.initializeModalConfirm();
|
||||||
|
this.initializeDirectEditButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overrides the placeholder text when no reason is selected
|
||||||
|
showPlaceholderNoReason() {
|
||||||
|
this.showPlaceholder("Email:", "Select an action needed reason to see email");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overrides the placeholder text when the reason other is selected
|
||||||
|
showPlaceholderOtherReason() {
|
||||||
|
this.showPlaceholder("Email:", "No email will be sent");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An IIFE that hooks to the show/hide button underneath action needed reason.
|
||||||
|
* This shows the auto generated email on action needed reason.
|
||||||
|
*/
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const domainRequestForm = document.getElementById("domainrequest_form");
|
||||||
|
if (!domainRequestForm) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize UI
|
||||||
|
const customEmail = new customActionNeededEmail();
|
||||||
|
|
||||||
|
// Check that every variable was setup correctly
|
||||||
|
const nullItems = Object.entries(customEmail.config).filter(([key, value]) => value === null).map(([key]) => key);
|
||||||
|
if (nullItems.length > 0) {
|
||||||
|
console.error(`Failed to load customActionNeededEmail(). Some variables were null: ${nullItems.join(", ")}`)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
customEmail.loadActionNeededEmail()
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
class customRejectedEmail extends CustomizableEmailBase {
|
||||||
|
constructor() {
|
||||||
|
const emailConfig = {
|
||||||
|
dropdown: document.getElementById("id_rejection_reason"),
|
||||||
|
textarea: document.getElementById("id_rejection_reason_email"),
|
||||||
|
lastSentEmailContent: document.getElementById("last-sent-rejection-email-content"),
|
||||||
|
modalConfirm: document.getElementById("rejection-reason__confirm-edit-email"),
|
||||||
|
apiUrl: document.getElementById("get-rejection-email-for-user-json")?.value || null,
|
||||||
|
textAreaFormGroup: document.querySelector('.field-rejection_reason'),
|
||||||
|
dropdownFormGroup: document.querySelector('.field-rejection_reason_email'),
|
||||||
|
statusToCheck: "rejected",
|
||||||
|
sessionVariableName: "showRejectionReason",
|
||||||
|
errorMessage: "Error when attempting to grab rejected email: "
|
||||||
|
};
|
||||||
|
super(emailConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadRejectedEmail() {
|
||||||
|
this.initializeFormGroups();
|
||||||
|
this.updateUserInterface();
|
||||||
|
this.initializeDropdown();
|
||||||
|
this.initializeModalConfirm();
|
||||||
|
this.initializeDirectEditButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overrides the placeholder text when no reason is selected
|
||||||
|
showPlaceholderNoReason() {
|
||||||
|
this.showPlaceholder("Email:", "Select a rejection reason to see email");
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUserInterface(reason=this.dropdown.value, excluded_reasons=[]) {
|
||||||
|
super.updateUserInterface(reason, excluded_reasons);
|
||||||
|
}
|
||||||
|
// Overrides the placeholder text when the reason other is selected
|
||||||
|
// showPlaceholderOtherReason() {
|
||||||
|
// this.showPlaceholder("Email:", "No email will be sent");
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** An IIFE that hooks to the show/hide button underneath rejected reason.
|
||||||
|
* This shows the auto generated email on action needed reason.
|
||||||
|
*/
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const domainRequestForm = document.getElementById("domainrequest_form");
|
||||||
|
if (!domainRequestForm) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize UI
|
||||||
|
const customEmail = new customRejectedEmail();
|
||||||
|
// Check that every variable was setup correctly
|
||||||
|
const nullItems = Object.entries(customEmail.config).filter(([key, value]) => value === null).map(([key]) => key);
|
||||||
|
if (nullItems.length > 0) {
|
||||||
|
console.error(`Failed to load customRejectedEmail(). Some variables were null: ${nullItems.join(", ")}`)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
customEmail.loadRejectedEmail()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
@ -706,18 +854,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
// Extract the submitter name, title, email, and phone number
|
|
||||||
const submitterDiv = document.querySelector('.form-row.field-submitter');
|
|
||||||
const submitterNameElement = document.getElementById('id_submitter');
|
|
||||||
// We have to account for different superuser and analyst markups
|
|
||||||
const submitterName = submitterNameElement
|
|
||||||
? submitterNameElement.options[submitterNameElement.selectedIndex].text
|
|
||||||
: submitterDiv.querySelector('a').text;
|
|
||||||
const submitterTitle = extractTextById('contact_info_title', submitterDiv);
|
|
||||||
const submitterEmail = extractTextById('contact_info_email', submitterDiv);
|
|
||||||
const submitterPhone = extractTextById('contact_info_phone', submitterDiv);
|
|
||||||
let submitterInfo = `${submitterName}${submitterTitle}${submitterEmail}${submitterPhone}`;
|
|
||||||
|
|
||||||
|
|
||||||
//------ Senior Official
|
//------ Senior Official
|
||||||
const seniorOfficialDiv = document.querySelector('.form-row.field-senior_official');
|
const seniorOfficialDiv = document.querySelector('.form-row.field-senior_official');
|
||||||
|
@ -734,7 +870,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
`<strong>Current Websites:</strong> ${existingWebsites.join(', ')}</br>` +
|
`<strong>Current Websites:</strong> ${existingWebsites.join(', ')}</br>` +
|
||||||
`<strong>Rationale:</strong></br>` +
|
`<strong>Rationale:</strong></br>` +
|
||||||
`<strong>Alternative Domains:</strong> ${alternativeDomains.join(', ')}</br>` +
|
`<strong>Alternative Domains:</strong> ${alternativeDomains.join(', ')}</br>` +
|
||||||
`<strong>Submitter:</strong> ${submitterInfo}</br>` +
|
|
||||||
`<strong>Senior Official:</strong> ${seniorOfficialInfo}</br>` +
|
`<strong>Senior Official:</strong> ${seniorOfficialInfo}</br>` +
|
||||||
`<strong>Other Employees:</strong> ${otherContactsSummary}</br>`;
|
`<strong>Other Employees:</strong> ${otherContactsSummary}</br>`;
|
||||||
|
|
||||||
|
|
|
@ -1498,12 +1498,23 @@ class DomainsTable extends LoadTableBase {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class DomainRequestsTable extends LoadTableBase {
|
class DomainRequestsTable extends LoadTableBase {
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('.domain-requests__table', '.domain-requests__table-wrapper', '#domain-requests__search-field', '#domain-requests__search-field-submit', '.domain-requests__reset-search', '.domain-requests__reset-filters', '.domain-requests__no-data', '.domain-requests__no-search-results');
|
super('.domain-requests__table', '.domain-requests__table-wrapper', '#domain-requests__search-field', '#domain-requests__search-field-submit', '.domain-requests__reset-search', '.domain-requests__reset-filters', '.domain-requests__no-data', '.domain-requests__no-search-results');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleExportButton(requests) {
|
||||||
|
const exportButton = document.getElementById('export-csv');
|
||||||
|
if (exportButton) {
|
||||||
|
if (requests.length > 0) {
|
||||||
|
showElement(exportButton);
|
||||||
|
} else {
|
||||||
|
hideElement(exportButton);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads rows in the domains list, as well as updates pagination around the domains list
|
* Loads rows in the domains list, as well as updates pagination around the domains list
|
||||||
* based on the supplied attributes.
|
* based on the supplied attributes.
|
||||||
|
@ -1517,6 +1528,7 @@ class DomainRequestsTable extends LoadTableBase {
|
||||||
*/
|
*/
|
||||||
loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm = this.currentSearchTerm, portfolio = this.portfolioValue) {
|
loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm = this.currentSearchTerm, portfolio = this.portfolioValue) {
|
||||||
let baseUrl = document.getElementById("get_domain_requests_json_url");
|
let baseUrl = document.getElementById("get_domain_requests_json_url");
|
||||||
|
|
||||||
if (!baseUrl) {
|
if (!baseUrl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -1548,6 +1560,9 @@ class DomainRequestsTable extends LoadTableBase {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Manage "export as CSV" visibility for domain requests
|
||||||
|
this.toggleExportButton(data.domain_requests);
|
||||||
|
|
||||||
// handle the display of proper messaging in the event that no requests exist in the list or search returns no results
|
// handle the display of proper messaging in the event that no requests exist in the list or search returns no results
|
||||||
this.updateDisplay(data, this.tableWrapper, this.noTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm);
|
this.updateDisplay(data, this.tableWrapper, this.noTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm);
|
||||||
|
|
||||||
|
@ -1865,11 +1880,10 @@ class MembersTable extends LoadTableBase {
|
||||||
* @param {*} sortBy - the sort column option
|
* @param {*} sortBy - the sort column option
|
||||||
* @param {*} order - the sort order {asc, desc}
|
* @param {*} order - the sort order {asc, desc}
|
||||||
* @param {*} scroll - control for the scrollToElement functionality
|
* @param {*} scroll - control for the scrollToElement functionality
|
||||||
* @param {*} status - control for the status filter
|
|
||||||
* @param {*} searchTerm - the search term
|
* @param {*} searchTerm - the search term
|
||||||
* @param {*} portfolio - the portfolio id
|
* @param {*} portfolio - the portfolio id
|
||||||
*/
|
*/
|
||||||
loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) {
|
loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) {
|
||||||
|
|
||||||
// --------- SEARCH
|
// --------- SEARCH
|
||||||
let searchParams = new URLSearchParams(
|
let searchParams = new URLSearchParams(
|
||||||
|
@ -1877,7 +1891,6 @@ class MembersTable extends LoadTableBase {
|
||||||
"page": page,
|
"page": page,
|
||||||
"sort_by": sortBy,
|
"sort_by": sortBy,
|
||||||
"order": order,
|
"order": order,
|
||||||
"status": status,
|
|
||||||
"search_term": searchTerm
|
"search_term": searchTerm
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -1913,11 +1926,40 @@ class MembersTable extends LoadTableBase {
|
||||||
const memberList = document.querySelector('.members__table tbody');
|
const memberList = document.querySelector('.members__table tbody');
|
||||||
memberList.innerHTML = '';
|
memberList.innerHTML = '';
|
||||||
|
|
||||||
|
const invited = 'Invited';
|
||||||
|
|
||||||
data.members.forEach(member => {
|
data.members.forEach(member => {
|
||||||
// const actionUrl = domain.action_url;
|
|
||||||
const member_name = member.name;
|
const member_name = member.name;
|
||||||
const member_email = member.email;
|
const member_display = member.member_display;
|
||||||
const last_active = member.last_active;
|
const options = { year: 'numeric', month: 'short', day: 'numeric' };
|
||||||
|
|
||||||
|
// Handle last_active values
|
||||||
|
let last_active = member.last_active;
|
||||||
|
let last_active_formatted = '';
|
||||||
|
let last_active_sort_value = '';
|
||||||
|
|
||||||
|
// Handle 'Invited' or null/empty values differently from valid dates
|
||||||
|
if (last_active && last_active !== invited) {
|
||||||
|
try {
|
||||||
|
// Try to parse the last_active as a valid date
|
||||||
|
last_active = new Date(last_active);
|
||||||
|
if (!isNaN(last_active)) {
|
||||||
|
last_active_formatted = last_active.toLocaleDateString('en-US', options);
|
||||||
|
last_active_sort_value = last_active.getTime(); // For sorting purposes
|
||||||
|
} else {
|
||||||
|
last_active_formatted='Invalid date'
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Error parsing date: ${last_active}. Error: ${e}`);
|
||||||
|
last_active_formatted='Invalid date'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Handle 'Invited' or null
|
||||||
|
last_active = invited;
|
||||||
|
last_active_formatted = invited;
|
||||||
|
last_active_sort_value = invited; // Keep 'Invited' as a sortable string
|
||||||
|
}
|
||||||
|
|
||||||
const action_url = member.action_url;
|
const action_url = member.action_url;
|
||||||
const action_label = member.action_label;
|
const action_label = member.action_label;
|
||||||
const svg_icon = member.svg_icon;
|
const svg_icon = member.svg_icon;
|
||||||
|
@ -1930,10 +1972,10 @@ class MembersTable extends LoadTableBase {
|
||||||
|
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<th scope="row" role="rowheader" data-label="member email">
|
<th scope="row" role="rowheader" data-label="member email">
|
||||||
${member_email ? member_email : member_name} ${admin_tagHTML}
|
${member_display} ${admin_tagHTML}
|
||||||
</th>
|
</th>
|
||||||
<td data-sort-value="${last_active}" data-label="last_active">
|
<td data-sort-value="${last_active_sort_value}" data-label="last_active">
|
||||||
${last_active}
|
${last_active_formatted}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="${action_url}">
|
<a href="${action_url}">
|
||||||
|
|
|
@ -25,6 +25,8 @@
|
||||||
/**
|
/**
|
||||||
* Edits made for dotgov project:
|
* Edits made for dotgov project:
|
||||||
* - tooltip exposed to window to be accessible in other js files
|
* - tooltip exposed to window to be accessible in other js files
|
||||||
|
* - tooltip positioning logic updated to allow position:fixed
|
||||||
|
* - tooltip dynamic content updated to include nested element (for better sizing control)
|
||||||
* - modal exposed to window to be accessible in other js files
|
* - modal exposed to window to be accessible in other js files
|
||||||
* - fixed bug in createHeaderButton which added newlines to header button tooltips
|
* - fixed bug in createHeaderButton which added newlines to header button tooltips
|
||||||
*/
|
*/
|
||||||
|
@ -5938,6 +5940,22 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
|
||||||
return offset;
|
return offset;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---- DOTGOV EDIT (Added section)
|
||||||
|
// DOTGOV: Tooltip positioning logic updated to allow position:fixed
|
||||||
|
const tooltipStyle = window.getComputedStyle(tooltipBody);
|
||||||
|
const tooltipIsFixedPositioned = tooltipStyle.position === 'fixed';
|
||||||
|
const triggerRect = tooltipTrigger.getBoundingClientRect(); //detect if tooltip is set to "fixed" position
|
||||||
|
const targetLeft = tooltipIsFixedPositioned ? triggerRect.left + triggerRect.width/2 + 'px': `50%`
|
||||||
|
const targetTop = tooltipIsFixedPositioned ? triggerRect.top + triggerRect.height/2 + 'px': `50%`
|
||||||
|
if (tooltipIsFixedPositioned) {
|
||||||
|
/* DOTGOV: Add listener to handle scrolling if tooltip position = 'fixed'
|
||||||
|
(so that the tooltip doesn't appear to stick to the screen) */
|
||||||
|
window.addEventListener('scroll', function() {
|
||||||
|
findBestPosition(tooltipBody)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// ---- END DOTGOV EDIT
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Positions tooltip at the top
|
* Positions tooltip at the top
|
||||||
* @param {HTMLElement} e - this is the tooltip body
|
* @param {HTMLElement} e - this is the tooltip body
|
||||||
|
@ -5949,8 +5967,16 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
|
||||||
const topMargin = calculateMarginOffset("top", e.offsetHeight, tooltipTrigger);
|
const topMargin = calculateMarginOffset("top", e.offsetHeight, tooltipTrigger);
|
||||||
const leftMargin = calculateMarginOffset("left", e.offsetWidth, tooltipTrigger);
|
const leftMargin = calculateMarginOffset("left", e.offsetWidth, tooltipTrigger);
|
||||||
setPositionClass("top");
|
setPositionClass("top");
|
||||||
e.style.left = `50%`; // center the element
|
|
||||||
e.style.top = `-${TRIANGLE_SIZE}px`; // consider the pseudo element
|
// ---- DOTGOV EDIT
|
||||||
|
// e.style.left = `50%`; // center the element
|
||||||
|
// e.style.top = `-${TRIANGLE_SIZE}px`; // consider the pseudo element
|
||||||
|
|
||||||
|
// DOTGOV: updated logic for position:fixed
|
||||||
|
e.style.left = targetLeft; // center the element
|
||||||
|
e.style.top = tooltipIsFixedPositioned ?`${triggerRect.top-TRIANGLE_SIZE}px`:`-${TRIANGLE_SIZE}px`; // consider the pseudo element
|
||||||
|
// ---- END DOTGOV EDIT
|
||||||
|
|
||||||
// apply our margins based on the offset
|
// apply our margins based on the offset
|
||||||
e.style.margin = `-${topMargin}px 0 0 -${leftMargin / 2}px`;
|
e.style.margin = `-${topMargin}px 0 0 -${leftMargin / 2}px`;
|
||||||
};
|
};
|
||||||
|
@ -5963,7 +5989,17 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
|
||||||
resetPositionStyles(e);
|
resetPositionStyles(e);
|
||||||
const leftMargin = calculateMarginOffset("left", e.offsetWidth, tooltipTrigger);
|
const leftMargin = calculateMarginOffset("left", e.offsetWidth, tooltipTrigger);
|
||||||
setPositionClass("bottom");
|
setPositionClass("bottom");
|
||||||
e.style.left = `50%`;
|
|
||||||
|
// ---- DOTGOV EDIT
|
||||||
|
// e.style.left = `50%`;
|
||||||
|
|
||||||
|
// DOTGOV: updated logic for position:fixed
|
||||||
|
if (tooltipIsFixedPositioned){
|
||||||
|
e.style.top = triggerRect.bottom+'px';
|
||||||
|
}
|
||||||
|
// ---- END DOTGOV EDIT
|
||||||
|
|
||||||
|
e.style.left = targetLeft;
|
||||||
e.style.margin = `${TRIANGLE_SIZE}px 0 0 -${leftMargin / 2}px`;
|
e.style.margin = `${TRIANGLE_SIZE}px 0 0 -${leftMargin / 2}px`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -5975,8 +6011,16 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
|
||||||
resetPositionStyles(e);
|
resetPositionStyles(e);
|
||||||
const topMargin = calculateMarginOffset("top", e.offsetHeight, tooltipTrigger);
|
const topMargin = calculateMarginOffset("top", e.offsetHeight, tooltipTrigger);
|
||||||
setPositionClass("right");
|
setPositionClass("right");
|
||||||
e.style.top = `50%`;
|
|
||||||
e.style.left = `${tooltipTrigger.offsetLeft + tooltipTrigger.offsetWidth + TRIANGLE_SIZE}px`;
|
// ---- DOTGOV EDIT
|
||||||
|
// e.style.top = `50%`;
|
||||||
|
// e.style.left = `${tooltipTrigger.offsetLeft + tooltipTrigger.offsetWidth + TRIANGLE_SIZE}px`;
|
||||||
|
|
||||||
|
// DOTGOV: updated logic for position:fixed
|
||||||
|
e.style.top = targetTop;
|
||||||
|
e.style.left = tooltipIsFixedPositioned ? `${triggerRect.right + TRIANGLE_SIZE}px`:`${tooltipTrigger.offsetLeft + tooltipTrigger.offsetWidth + TRIANGLE_SIZE}px`;
|
||||||
|
// ---- END DOTGOV EDIT
|
||||||
|
|
||||||
e.style.margin = `-${topMargin / 2}px 0 0 0`;
|
e.style.margin = `-${topMargin / 2}px 0 0 0`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -5991,8 +6035,16 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
|
||||||
// we have to check for some utility margins
|
// we have to check for some utility margins
|
||||||
const leftMargin = calculateMarginOffset("left", tooltipTrigger.offsetLeft > e.offsetWidth ? tooltipTrigger.offsetLeft - e.offsetWidth : e.offsetWidth, tooltipTrigger);
|
const leftMargin = calculateMarginOffset("left", tooltipTrigger.offsetLeft > e.offsetWidth ? tooltipTrigger.offsetLeft - e.offsetWidth : e.offsetWidth, tooltipTrigger);
|
||||||
setPositionClass("left");
|
setPositionClass("left");
|
||||||
e.style.top = `50%`;
|
|
||||||
e.style.left = `-${TRIANGLE_SIZE}px`;
|
// ---- DOTGOV EDIT
|
||||||
|
// e.style.top = `50%`;
|
||||||
|
// e.style.left = `-${TRIANGLE_SIZE}px`;
|
||||||
|
|
||||||
|
// DOTGOV: updated logic for position:fixed
|
||||||
|
e.style.top = targetTop;
|
||||||
|
e.style.left = tooltipIsFixedPositioned ? `${triggerRect.left-TRIANGLE_SIZE}px` : `-${TRIANGLE_SIZE}px`;
|
||||||
|
// ---- END DOTGOV EDIT
|
||||||
|
|
||||||
e.style.margin = `-${topMargin / 2}px 0 0 ${tooltipTrigger.offsetLeft > e.offsetWidth ? leftMargin : -leftMargin}px`; // adjust the margin
|
e.style.margin = `-${topMargin / 2}px 0 0 ${tooltipTrigger.offsetLeft > e.offsetWidth ? leftMargin : -leftMargin}px`; // adjust the margin
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -6017,6 +6069,7 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
|
||||||
if (i < positions.length) {
|
if (i < positions.length) {
|
||||||
const pos = positions[i];
|
const pos = positions[i];
|
||||||
pos(element);
|
pos(element);
|
||||||
|
|
||||||
if (!isElementInViewport(element)) {
|
if (!isElementInViewport(element)) {
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
tryPositions(i += 1);
|
tryPositions(i += 1);
|
||||||
|
@ -6128,7 +6181,17 @@ const setUpAttributes = tooltipTrigger => {
|
||||||
tooltipBody.setAttribute("aria-hidden", "true");
|
tooltipBody.setAttribute("aria-hidden", "true");
|
||||||
|
|
||||||
// place the text in the tooltip
|
// place the text in the tooltip
|
||||||
tooltipBody.textContent = tooltipContent;
|
|
||||||
|
// -- DOTGOV EDIT
|
||||||
|
// tooltipBody.textContent = tooltipContent;
|
||||||
|
|
||||||
|
// DOTGOV: nest the text element to allow us greater control over width and wrapping behavior
|
||||||
|
tooltipBody.innerHTML = `
|
||||||
|
<span class="usa-tooltip__content">
|
||||||
|
${tooltipContent}
|
||||||
|
</span>`
|
||||||
|
// -- END DOTGOV EDIT
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tooltipBody,
|
tooltipBody,
|
||||||
position,
|
position,
|
||||||
|
|
|
@ -385,6 +385,7 @@ a.button,
|
||||||
font-kerning: auto;
|
font-kerning: auto;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
|
text-decoration: none !important;
|
||||||
}
|
}
|
||||||
.button svg,
|
.button svg,
|
||||||
.button span,
|
.button span,
|
||||||
|
@ -392,6 +393,9 @@ a.button,
|
||||||
.usa-button--dja span {
|
.usa-button--dja span {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
.usa-button--dja.usa-button--unstyled {
|
||||||
|
color: var(--link-fg);
|
||||||
|
}
|
||||||
.usa-button--dja:not(.usa-button--unstyled, .usa-button--outline, .usa-modal__close, .usa-button--secondary) {
|
.usa-button--dja:not(.usa-button--unstyled, .usa-button--outline, .usa-modal__close, .usa-button--secondary) {
|
||||||
background: var(--button-bg);
|
background: var(--button-bg);
|
||||||
}
|
}
|
||||||
|
@ -421,11 +425,34 @@ input[type=submit].button--dja-toolbar {
|
||||||
input[type=submit].button--dja-toolbar:focus, input[type=submit].button--dja-toolbar:hover {
|
input[type=submit].button--dja-toolbar:focus, input[type=submit].button--dja-toolbar:hover {
|
||||||
border-color: var(--body-quiet-color);
|
border-color: var(--body-quiet-color);
|
||||||
}
|
}
|
||||||
// Targets the DJA buttom with a nested icon
|
.admin-icon-group {
|
||||||
button .usa-icon,
|
position: relative;
|
||||||
.button .usa-icon,
|
display: inline;
|
||||||
.button--clipboard .usa-icon {
|
align-items: center;
|
||||||
vertical-align: middle;
|
|
||||||
|
input {
|
||||||
|
// Allow for padding around the copy button
|
||||||
|
padding-right: 35px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
button {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
padding-left: 0.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
.usa-button__small-text,
|
||||||
|
.usa-button__small-text span {
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.module--custom {
|
.module--custom {
|
||||||
|
@ -673,71 +700,10 @@ address.dja-address-contact-list {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make the clipboard button "float" inside of the input box
|
|
||||||
.admin-icon-group {
|
|
||||||
position: relative;
|
|
||||||
display: inline;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
input {
|
|
||||||
// Allow for padding around the copy button
|
|
||||||
padding-right: 35px !important;
|
|
||||||
// Match the height of other inputs
|
|
||||||
min-height: 2.25rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
line-height: 14px;
|
|
||||||
width: max-content;
|
|
||||||
font-size: unset;
|
|
||||||
text-decoration: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1000px) {
|
|
||||||
button {
|
|
||||||
display: block;
|
|
||||||
padding-top: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
padding-left: 0.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-icon-group.admin-icon-group__clipboard-link {
|
|
||||||
position: relative;
|
|
||||||
display: inline;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
|
|
||||||
.usa-button--icon {
|
|
||||||
position: absolute;
|
|
||||||
right: auto;
|
|
||||||
left: 4px;
|
|
||||||
height: 100%;
|
|
||||||
top: -1px;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
font-size: unset !important;
|
|
||||||
display: inline-flex;
|
|
||||||
padding-top: 4px;
|
|
||||||
line-height: 14px;
|
|
||||||
width: max-content;
|
|
||||||
font-size: unset;
|
|
||||||
text-decoration: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-outline-on-click:focus {
|
.no-outline-on-click:focus {
|
||||||
outline: none !important;
|
outline: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.usa-button__small-text {
|
|
||||||
font-size: small;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get rid of padding on all help texts
|
// Get rid of padding on all help texts
|
||||||
form .aligned p.help, form .aligned div.help {
|
form .aligned p.help, form .aligned div.help {
|
||||||
padding-left: 0px !important;
|
padding-left: 0px !important;
|
||||||
|
@ -887,6 +853,9 @@ div.dja__model-description{
|
||||||
padding-top: 0 !important;
|
padding-top: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.padding-bottom-0 {
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.flex-container {
|
.flex-container {
|
||||||
@media screen and (min-width: 700px) and (max-width: 1150px) {
|
@media screen and (min-width: 700px) and (max-width: 1150px) {
|
||||||
|
|
|
@ -254,6 +254,7 @@ a .usa-icon,
|
||||||
// Note: Can be simplified by adding text-secondary to delete anchors in tables
|
// Note: Can be simplified by adding text-secondary to delete anchors in tables
|
||||||
button.text-secondary,
|
button.text-secondary,
|
||||||
button.text-secondary:hover,
|
button.text-secondary:hover,
|
||||||
.dotgov-table a.text-secondary {
|
a.text-secondary,
|
||||||
|
a.text-secondary:hover {
|
||||||
color: $theme-color-error;
|
color: $theme-color-error;
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,3 +28,47 @@
|
||||||
#extended-logo .usa-tooltip__body {
|
#extended-logo .usa-tooltip__body {
|
||||||
font-weight: 400 !important;
|
font-weight: 400 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.domains__table {
|
||||||
|
/*
|
||||||
|
Trick tooltips in the domains table to do 2 things...
|
||||||
|
1 - Shrink itself to a padded viewport window
|
||||||
|
(override width and wrapping properties in key areas to constrain tooltip size)
|
||||||
|
2 - NOT be clipped by the table's scrollable view
|
||||||
|
(Set tooltip position to "fixed", which prevents tooltip from being clipped by parent
|
||||||
|
containers. Fixed-position detection was added to uswds positioning logic to update positioning
|
||||||
|
calculations accordingly.)
|
||||||
|
*/
|
||||||
|
.usa-tooltip__body {
|
||||||
|
white-space: inherit;
|
||||||
|
max-width: fit-content; // prevent adjusted widths from being larger than content
|
||||||
|
position: fixed; // prevents clipping by parent containers
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
Override width adustments in this dynamically added class
|
||||||
|
(this is original to the javascript handler as a way to shrink tooltip contents within the viewport,
|
||||||
|
but is insufficient for our needs. We cannot simply override its properties
|
||||||
|
because the logic surrounding its dynamic appearance in the DOM does not account
|
||||||
|
for parent containers (basically, this class isn't in the DOM when we need it).
|
||||||
|
Intercept .usa-tooltip__content instead and nullify the effects of
|
||||||
|
.usa-tooltip__body--wrap to prevent conflicts)
|
||||||
|
*/
|
||||||
|
.usa-tooltip__body--wrap {
|
||||||
|
min-width: inherit;
|
||||||
|
width: inherit;
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
Add width and wrapping to tooltip content in order to confine it to a smaller viewport window.
|
||||||
|
*/
|
||||||
|
.usa-tooltip__content {
|
||||||
|
width: 50vw;
|
||||||
|
text-wrap: wrap;
|
||||||
|
text-align: center;
|
||||||
|
font-size: inherit; //inherit tooltip fontsize of .93rem
|
||||||
|
max-width: fit-content;
|
||||||
|
@include at-media('desktop') {
|
||||||
|
width: 70vw;
|
||||||
|
}
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
|
@ -20,6 +20,7 @@ from registrar.views.report_views import (
|
||||||
AnalyticsView,
|
AnalyticsView,
|
||||||
ExportDomainRequestDataFull,
|
ExportDomainRequestDataFull,
|
||||||
ExportDataTypeUser,
|
ExportDataTypeUser,
|
||||||
|
ExportDataTypeRequests,
|
||||||
)
|
)
|
||||||
|
|
||||||
# --jsons
|
# --jsons
|
||||||
|
@ -30,9 +31,10 @@ from registrar.views.utility.api_views import (
|
||||||
get_senior_official_from_federal_agency_json,
|
get_senior_official_from_federal_agency_json,
|
||||||
get_federal_and_portfolio_types_from_federal_agency_json,
|
get_federal_and_portfolio_types_from_federal_agency_json,
|
||||||
get_action_needed_email_for_user_json,
|
get_action_needed_email_for_user_json,
|
||||||
|
get_rejection_email_for_user_json,
|
||||||
)
|
)
|
||||||
|
|
||||||
from registrar.views.domain_request import Step
|
from registrar.views.domain_request import Step, PortfolioDomainRequestStep
|
||||||
from registrar.views.transfer_user import TransferUserView
|
from registrar.views.transfer_user import TransferUserView
|
||||||
from registrar.views.utility import always_404
|
from registrar.views.utility import always_404
|
||||||
from api.views import available, rdap, get_current_federal, get_current_full
|
from api.views import available, rdap, get_current_federal, get_current_full
|
||||||
|
@ -60,6 +62,9 @@ for step, view in [
|
||||||
(Step.ADDITIONAL_DETAILS, views.AdditionalDetails),
|
(Step.ADDITIONAL_DETAILS, views.AdditionalDetails),
|
||||||
(Step.REQUIREMENTS, views.Requirements),
|
(Step.REQUIREMENTS, views.Requirements),
|
||||||
(Step.REVIEW, views.Review),
|
(Step.REVIEW, views.Review),
|
||||||
|
# Portfolio steps
|
||||||
|
(PortfolioDomainRequestStep.REQUESTING_ENTITY, views.RequestingEntity),
|
||||||
|
(PortfolioDomainRequestStep.ADDITIONAL_DETAILS, views.PortfolioAdditionalDetails),
|
||||||
]:
|
]:
|
||||||
domain_request_urls.append(path(f"{step}/", view.as_view(), name=step))
|
domain_request_urls.append(path(f"{step}/", view.as_view(), name=step))
|
||||||
|
|
||||||
|
@ -81,6 +86,26 @@ urlpatterns = [
|
||||||
views.PortfolioMembersView.as_view(),
|
views.PortfolioMembersView.as_view(),
|
||||||
name="members",
|
name="members",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"member/<int:pk>",
|
||||||
|
views.PortfolioMemberView.as_view(),
|
||||||
|
name="member",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"member/<int:pk>/permissions",
|
||||||
|
views.PortfolioMemberEditView.as_view(),
|
||||||
|
name="member-permissions",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"invitedmember/<int:pk>",
|
||||||
|
views.PortfolioInvitedMemberView.as_view(),
|
||||||
|
name="invitedmember",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"invitedmember/<int:pk>/permissions",
|
||||||
|
views.PortfolioInvitedMemberEditView.as_view(),
|
||||||
|
name="invitedmember-permissions",
|
||||||
|
),
|
||||||
# path(
|
# path(
|
||||||
# "no-organization-members/",
|
# "no-organization-members/",
|
||||||
# views.PortfolioNoMembersView.as_view(),
|
# views.PortfolioNoMembersView.as_view(),
|
||||||
|
@ -171,6 +196,11 @@ urlpatterns = [
|
||||||
get_action_needed_email_for_user_json,
|
get_action_needed_email_for_user_json,
|
||||||
name="get-action-needed-email-for-user-json",
|
name="get-action-needed-email-for-user-json",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"admin/api/get-rejection-email-for-user-json/",
|
||||||
|
get_rejection_email_for_user_json,
|
||||||
|
name="get-rejection-email-for-user-json",
|
||||||
|
),
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path(
|
path(
|
||||||
"reports/export_data_type_user/",
|
"reports/export_data_type_user/",
|
||||||
|
@ -178,7 +208,17 @@ urlpatterns = [
|
||||||
name="export_data_type_user",
|
name="export_data_type_user",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"domain-request/<id>/edit/",
|
"reports/export_data_type_requests/",
|
||||||
|
ExportDataTypeRequests.as_view(),
|
||||||
|
name="export_data_type_requests",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"reports/export_data_type_requests/",
|
||||||
|
ExportDataTypeRequests.as_view(),
|
||||||
|
name="export_data_type_requests",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"domain-request/<int:id>/edit/",
|
||||||
views.DomainRequestWizard.as_view(),
|
views.DomainRequestWizard.as_view(),
|
||||||
name=views.DomainRequestWizard.EDIT_URL_NAME,
|
name=views.DomainRequestWizard.EDIT_URL_NAME,
|
||||||
),
|
),
|
||||||
|
|
|
@ -7,7 +7,7 @@ from registrar.fixtures.fixtures_users import UserFixture
|
||||||
from registrar.models import User
|
from registrar.models import User
|
||||||
from registrar.models.portfolio import Portfolio
|
from registrar.models.portfolio import Portfolio
|
||||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||||
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||||
|
|
||||||
fake = Faker()
|
fake = Faker()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@ -58,6 +58,7 @@ class UserPortfolioPermissionFixture:
|
||||||
user=user,
|
user=user,
|
||||||
portfolio=portfolio,
|
portfolio=portfolio,
|
||||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||||
|
additional_permissions=[UserPortfolioPermissionChoices.EDIT_MEMBERS],
|
||||||
)
|
)
|
||||||
user_portfolio_permissions_to_create.append(user_portfolio_permission)
|
user_portfolio_permissions_to_create.append(user_portfolio_permission)
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -13,4 +13,5 @@ from .domain import (
|
||||||
)
|
)
|
||||||
from .portfolio import (
|
from .portfolio import (
|
||||||
PortfolioOrgAddressForm,
|
PortfolioOrgAddressForm,
|
||||||
|
PortfolioMemberForm,
|
||||||
)
|
)
|
||||||
|
|
|
@ -4,7 +4,7 @@ import logging
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.core.validators import MinValueValidator, MaxValueValidator, RegexValidator, MaxLengthValidator
|
from django.core.validators import MinValueValidator, MaxValueValidator, RegexValidator, MaxLengthValidator
|
||||||
from django.forms import formset_factory
|
from django.forms import formset_factory
|
||||||
from registrar.models import DomainRequest
|
from registrar.models import DomainRequest, FederalAgency
|
||||||
from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
||||||
from registrar.models.suborganization import Suborganization
|
from registrar.models.suborganization import Suborganization
|
||||||
from registrar.models.utility.domain_helper import DomainHelper
|
from registrar.models.utility.domain_helper import DomainHelper
|
||||||
|
@ -35,7 +35,10 @@ class DomainAddUserForm(forms.Form):
|
||||||
email = forms.EmailField(
|
email = forms.EmailField(
|
||||||
label="Email",
|
label="Email",
|
||||||
max_length=None,
|
max_length=None,
|
||||||
error_messages={"invalid": ("Enter your email address in the required format, like name@example.com.")},
|
error_messages={
|
||||||
|
"invalid": ("Enter an email address in the required format, like name@example.com."),
|
||||||
|
"required": ("Enter an email address in the required format, like name@example.com."),
|
||||||
|
},
|
||||||
validators=[
|
validators=[
|
||||||
MaxLengthValidator(
|
MaxLengthValidator(
|
||||||
320,
|
320,
|
||||||
|
@ -285,7 +288,7 @@ class UserForm(forms.ModelForm):
|
||||||
"required": "Enter your title or role in your organization (e.g., Chief Information Officer)"
|
"required": "Enter your title or role in your organization (e.g., Chief Information Officer)"
|
||||||
}
|
}
|
||||||
self.fields["email"].error_messages = {
|
self.fields["email"].error_messages = {
|
||||||
"required": "Enter your email address in the required format, like name@example.com."
|
"required": "Enter an email address in the required format, like name@example.com."
|
||||||
}
|
}
|
||||||
self.fields["phone"].error_messages["required"] = "Enter your phone number."
|
self.fields["phone"].error_messages["required"] = "Enter your phone number."
|
||||||
self.domainInfo = None
|
self.domainInfo = None
|
||||||
|
@ -342,7 +345,7 @@ class ContactForm(forms.ModelForm):
|
||||||
"required": "Enter your title or role in your organization (e.g., Chief Information Officer)"
|
"required": "Enter your title or role in your organization (e.g., Chief Information Officer)"
|
||||||
}
|
}
|
||||||
self.fields["email"].error_messages = {
|
self.fields["email"].error_messages = {
|
||||||
"required": "Enter your email address in the required format, like name@example.com."
|
"required": "Enter an email address in the required format, like name@example.com."
|
||||||
}
|
}
|
||||||
self.fields["phone"].error_messages["required"] = "Enter your phone number."
|
self.fields["phone"].error_messages["required"] = "Enter your phone number."
|
||||||
self.domainInfo = None
|
self.domainInfo = None
|
||||||
|
@ -458,9 +461,12 @@ class DomainOrgNameAddressForm(forms.ModelForm):
|
||||||
validators=[
|
validators=[
|
||||||
RegexValidator(
|
RegexValidator(
|
||||||
"^[0-9]{5}(?:-[0-9]{4})?$|^$",
|
"^[0-9]{5}(?:-[0-9]{4})?$|^$",
|
||||||
message="Enter a zip code in the required format, like 12345 or 12345-6789.",
|
message="Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.",
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
error_messages={
|
||||||
|
"required": "Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -529,17 +535,25 @@ class DomainOrgNameAddressForm(forms.ModelForm):
|
||||||
|
|
||||||
def save(self, commit=True):
|
def save(self, commit=True):
|
||||||
"""Override the save() method of the BaseModelForm."""
|
"""Override the save() method of the BaseModelForm."""
|
||||||
|
|
||||||
if self.has_changed():
|
if self.has_changed():
|
||||||
|
|
||||||
# This action should be blocked by the UI, as the text fields are readonly.
|
# This action should be blocked by the UI, as the text fields are readonly.
|
||||||
# If they get past this point, we forbid it this way.
|
# If they get past this point, we forbid it this way.
|
||||||
# This could be malicious, so lets reserve information for the backend only.
|
# This could be malicious, so lets reserve information for the backend only.
|
||||||
if self.is_federal and not self._field_unchanged("federal_agency"):
|
|
||||||
raise ValueError("federal_agency cannot be modified when the generic_org_type is federal")
|
if self.is_federal:
|
||||||
|
if not self._field_unchanged("federal_agency"):
|
||||||
|
raise ValueError("federal_agency cannot be modified when the generic_org_type is federal")
|
||||||
|
|
||||||
elif self.is_tribal and not self._field_unchanged("organization_name"):
|
elif self.is_tribal and not self._field_unchanged("organization_name"):
|
||||||
raise ValueError("organization_name cannot be modified when the generic_org_type is tribal")
|
raise ValueError("organization_name cannot be modified when the generic_org_type is tribal")
|
||||||
|
|
||||||
super().save()
|
else: # If this error that means Non-Federal Agency is missing
|
||||||
|
non_federal_agency_instance = FederalAgency.get_non_federal_agency()
|
||||||
|
self.instance.federal_agency = non_federal_agency_instance
|
||||||
|
|
||||||
|
return super().save(commit=commit)
|
||||||
|
|
||||||
def _field_unchanged(self, field_name) -> bool:
|
def _field_unchanged(self, field_name) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -21,6 +21,13 @@ from registrar.utility.constants import BranchChoices
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RequestingEntityForm(RegistrarForm):
|
||||||
|
organization_name = forms.CharField(
|
||||||
|
label="Organization name",
|
||||||
|
error_messages={"required": "Enter the name of your organization."},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class OrganizationTypeForm(RegistrarForm):
|
class OrganizationTypeForm(RegistrarForm):
|
||||||
generic_org_type = forms.ChoiceField(
|
generic_org_type = forms.ChoiceField(
|
||||||
# use the long names in the domain request form
|
# use the long names in the domain request form
|
||||||
|
@ -137,10 +144,10 @@ class OrganizationContactForm(RegistrarForm):
|
||||||
validators=[
|
validators=[
|
||||||
RegexValidator(
|
RegexValidator(
|
||||||
"^[0-9]{5}(?:-[0-9]{4})?$|^$",
|
"^[0-9]{5}(?:-[0-9]{4})?$|^$",
|
||||||
message="Enter a zip code in the form of 12345 or 12345-6789.",
|
message="Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.",
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
error_messages={"required": ("Enter a zip code in the form of 12345 or 12345-6789.")},
|
error_messages={"required": ("Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.")},
|
||||||
)
|
)
|
||||||
urbanization = forms.CharField(
|
urbanization = forms.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
|
@ -226,7 +233,10 @@ class SeniorOfficialForm(RegistrarForm):
|
||||||
email = forms.EmailField(
|
email = forms.EmailField(
|
||||||
label="Email",
|
label="Email",
|
||||||
max_length=None,
|
max_length=None,
|
||||||
error_messages={"invalid": ("Enter an email address in the required format, like name@example.com.")},
|
error_messages={
|
||||||
|
"invalid": ("Enter an email address in the required format, like name@example.com."),
|
||||||
|
"required": ("Enter an email address in the required format, like name@example.com."),
|
||||||
|
},
|
||||||
validators=[
|
validators=[
|
||||||
MaxLengthValidator(
|
MaxLengthValidator(
|
||||||
320,
|
320,
|
||||||
|
@ -603,7 +613,8 @@ class CisaRepresentativeForm(BaseDeletableRegistrarForm):
|
||||||
max_length=None,
|
max_length=None,
|
||||||
required=False,
|
required=False,
|
||||||
error_messages={
|
error_messages={
|
||||||
"invalid": ("Enter your representative’s email address in the required format, like name@example.com."),
|
"invalid": ("Enter an email address in the required format, like name@example.com."),
|
||||||
|
"required": ("Enter an email address in the required format, like name@example.com."),
|
||||||
},
|
},
|
||||||
validators=[
|
validators=[
|
||||||
MaxLengthValidator(
|
MaxLengthValidator(
|
||||||
|
|
|
@ -4,7 +4,14 @@ import logging
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.core.validators import RegexValidator
|
from django.core.validators import RegexValidator
|
||||||
|
|
||||||
from ..models import DomainInformation, Portfolio, SeniorOfficial
|
from registrar.models import (
|
||||||
|
PortfolioInvitation,
|
||||||
|
UserPortfolioPermission,
|
||||||
|
DomainInformation,
|
||||||
|
Portfolio,
|
||||||
|
SeniorOfficial,
|
||||||
|
)
|
||||||
|
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -17,9 +24,12 @@ class PortfolioOrgAddressForm(forms.ModelForm):
|
||||||
validators=[
|
validators=[
|
||||||
RegexValidator(
|
RegexValidator(
|
||||||
"^[0-9]{5}(?:-[0-9]{4})?$|^$",
|
"^[0-9]{5}(?:-[0-9]{4})?$|^$",
|
||||||
message="Enter a zip code in the required format, like 12345 or 12345-6789.",
|
message="Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.",
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
error_messages={
|
||||||
|
"required": "Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -38,6 +48,7 @@ class PortfolioOrgAddressForm(forms.ModelForm):
|
||||||
"state_territory": {
|
"state_territory": {
|
||||||
"required": "Select the state, territory, or military post where your organization is located."
|
"required": "Select the state, territory, or military post where your organization is located."
|
||||||
},
|
},
|
||||||
|
"zipcode": {"required": "Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789."},
|
||||||
}
|
}
|
||||||
widgets = {
|
widgets = {
|
||||||
# We need to set the required attributed for State/territory
|
# We need to set the required attributed for State/territory
|
||||||
|
@ -95,3 +106,57 @@ class PortfolioSeniorOfficialForm(forms.ModelForm):
|
||||||
cleaned_data = super().clean()
|
cleaned_data = super().clean()
|
||||||
cleaned_data.pop("full_name", None)
|
cleaned_data.pop("full_name", None)
|
||||||
return cleaned_data
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioMemberForm(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Form for updating a portfolio member.
|
||||||
|
"""
|
||||||
|
|
||||||
|
roles = forms.MultipleChoiceField(
|
||||||
|
choices=UserPortfolioRoleChoices.choices,
|
||||||
|
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
|
||||||
|
required=False,
|
||||||
|
label="Roles",
|
||||||
|
)
|
||||||
|
|
||||||
|
additional_permissions = forms.MultipleChoiceField(
|
||||||
|
choices=UserPortfolioPermissionChoices.choices,
|
||||||
|
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
|
||||||
|
required=False,
|
||||||
|
label="Additional Permissions",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = UserPortfolioPermission
|
||||||
|
fields = [
|
||||||
|
"roles",
|
||||||
|
"additional_permissions",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioInvitedMemberForm(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Form for updating a portfolio invited member.
|
||||||
|
"""
|
||||||
|
|
||||||
|
roles = forms.MultipleChoiceField(
|
||||||
|
choices=UserPortfolioRoleChoices.choices,
|
||||||
|
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
|
||||||
|
required=False,
|
||||||
|
label="Roles",
|
||||||
|
)
|
||||||
|
|
||||||
|
additional_permissions = forms.MultipleChoiceField(
|
||||||
|
choices=UserPortfolioPermissionChoices.choices,
|
||||||
|
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
|
||||||
|
required=False,
|
||||||
|
label="Additional Permissions",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = PortfolioInvitation
|
||||||
|
fields = [
|
||||||
|
"roles",
|
||||||
|
"additional_permissions",
|
||||||
|
]
|
||||||
|
|
|
@ -58,7 +58,7 @@ class UserProfileForm(forms.ModelForm):
|
||||||
"required": "Enter your title or role in your organization (e.g., Chief Information Officer)"
|
"required": "Enter your title or role in your organization (e.g., Chief Information Officer)"
|
||||||
}
|
}
|
||||||
self.fields["email"].error_messages = {
|
self.fields["email"].error_messages = {
|
||||||
"required": "Enter your email address in the required format, like name@example.com."
|
"required": "Enter an email address in the required format, like name@example.com."
|
||||||
}
|
}
|
||||||
self.fields["phone"].error_messages["required"] = "Enter your phone number."
|
self.fields["phone"].error_messages["required"] = "Enter your phone number."
|
||||||
|
|
||||||
|
|
|
@ -279,11 +279,11 @@ class BaseYesNoForm(RegistrarForm):
|
||||||
return initial_value
|
return initial_value
|
||||||
|
|
||||||
|
|
||||||
def request_step_list(request_wizard):
|
def request_step_list(request_wizard, step_enum):
|
||||||
"""Dynamically generated list of steps in the form wizard."""
|
"""Dynamically generated list of steps in the form wizard."""
|
||||||
step_list = []
|
step_list = []
|
||||||
for step in request_wizard.StepEnum:
|
for step in step_enum:
|
||||||
condition = request_wizard.WIZARD_CONDITIONS.get(step, True)
|
condition = request_wizard.wizard_conditions.get(step, True)
|
||||||
if callable(condition):
|
if callable(condition):
|
||||||
condition = condition(request_wizard)
|
condition = condition(request_wizard)
|
||||||
if condition:
|
if condition:
|
||||||
|
|
|
@ -0,0 +1,35 @@
|
||||||
|
# Generated by Django 4.2.10 on 2024-10-08 18:52
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("registrar", "0132_alter_domaininformation_portfolio_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="domainrequest",
|
||||||
|
name="rejection_reason_email",
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="domainrequest",
|
||||||
|
name="rejection_reason",
|
||||||
|
field=models.TextField(
|
||||||
|
blank=True,
|
||||||
|
choices=[
|
||||||
|
("domain_purpose", "Purpose requirements not met"),
|
||||||
|
("requestor_not_eligible", "Requestor not eligible to make request"),
|
||||||
|
("org_has_domain", "Org already has a .gov domain"),
|
||||||
|
("contacts_not_verified", "Org contacts couldn't be verified"),
|
||||||
|
("org_not_eligible", "Org not eligible for a .gov domain"),
|
||||||
|
("naming_requirements", "Naming requirements not met"),
|
||||||
|
("other", "Other/Unspecified"),
|
||||||
|
],
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Generated by Django 4.2.10 on 2024-10-11 19:58
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("registrar", "0133_domainrequest_rejection_reason_email_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="portfolioinvitation",
|
||||||
|
old_name="portfolio_additional_permissions",
|
||||||
|
new_name="additional_permissions",
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="portfolioinvitation",
|
||||||
|
old_name="portfolio_roles",
|
||||||
|
new_name="roles",
|
||||||
|
),
|
||||||
|
]
|
|
@ -254,18 +254,18 @@ class DomainRequest(TimeStampedModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
class RejectionReasons(models.TextChoices):
|
class RejectionReasons(models.TextChoices):
|
||||||
DOMAIN_PURPOSE = "purpose_not_met", "Purpose requirements not met"
|
DOMAIN_PURPOSE = "domain_purpose", "Purpose requirements not met"
|
||||||
REQUESTOR = "requestor_not_eligible", "Requestor not eligible to make request"
|
REQUESTOR_NOT_ELIGIBLE = "requestor_not_eligible", "Requestor not eligible to make request"
|
||||||
SECOND_DOMAIN_REASONING = (
|
ORG_HAS_DOMAIN = (
|
||||||
"org_has_domain",
|
"org_has_domain",
|
||||||
"Org already has a .gov domain",
|
"Org already has a .gov domain",
|
||||||
)
|
)
|
||||||
CONTACTS_OR_ORGANIZATION_LEGITIMACY = (
|
CONTACTS_NOT_VERIFIED = (
|
||||||
"contacts_not_verified",
|
"contacts_not_verified",
|
||||||
"Org contacts couldn't be verified",
|
"Org contacts couldn't be verified",
|
||||||
)
|
)
|
||||||
ORGANIZATION_ELIGIBILITY = "org_not_eligible", "Org not eligible for a .gov domain"
|
ORG_NOT_ELIGIBLE = "org_not_eligible", "Org not eligible for a .gov domain"
|
||||||
NAMING_REQUIREMENTS = "naming_not_met", "Naming requirements not met"
|
NAMING_REQUIREMENTS = "naming_requirements", "Naming requirements not met"
|
||||||
OTHER = "other", "Other/Unspecified"
|
OTHER = "other", "Other/Unspecified"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -300,6 +300,11 @@ class DomainRequest(TimeStampedModel):
|
||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
rejection_reason_email = models.TextField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
action_needed_reason = models.TextField(
|
action_needed_reason = models.TextField(
|
||||||
choices=ActionNeededReasons.choices,
|
choices=ActionNeededReasons.choices,
|
||||||
null=True,
|
null=True,
|
||||||
|
@ -635,15 +640,16 @@ class DomainRequest(TimeStampedModel):
|
||||||
# Actually updates the organization_type field
|
# Actually updates the organization_type field
|
||||||
org_type_helper.create_or_update_organization_type()
|
org_type_helper.create_or_update_organization_type()
|
||||||
|
|
||||||
def _cache_status_and_action_needed_reason(self):
|
def _cache_status_and_status_reasons(self):
|
||||||
"""Maintains a cache of properties so we can avoid a DB call"""
|
"""Maintains a cache of properties so we can avoid a DB call"""
|
||||||
self._cached_action_needed_reason = self.action_needed_reason
|
self._cached_action_needed_reason = self.action_needed_reason
|
||||||
|
self._cached_rejection_reason = self.rejection_reason
|
||||||
self._cached_status = self.status
|
self._cached_status = self.status
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
# Store original values for caching purposes. Used to compare them on save.
|
# Store original values for caching purposes. Used to compare them on save.
|
||||||
self._cache_status_and_action_needed_reason()
|
self._cache_status_and_status_reasons()
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""Save override for custom properties"""
|
"""Save override for custom properties"""
|
||||||
|
@ -655,23 +661,63 @@ class DomainRequest(TimeStampedModel):
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
# Handle the action needed email.
|
# Handle custom status emails.
|
||||||
# An email is sent out when action_needed_reason is changed or added.
|
# An email is sent out when a, for example, action_needed_reason is changed or added.
|
||||||
if self.action_needed_reason and self.status == self.DomainRequestStatus.ACTION_NEEDED:
|
statuses_that_send_custom_emails = [self.DomainRequestStatus.ACTION_NEEDED, self.DomainRequestStatus.REJECTED]
|
||||||
self.sync_action_needed_reason()
|
if self.status in statuses_that_send_custom_emails:
|
||||||
|
self.send_custom_status_update_email(self.status)
|
||||||
|
|
||||||
# Update the cached values after saving
|
# Update the cached values after saving
|
||||||
self._cache_status_and_action_needed_reason()
|
self._cache_status_and_status_reasons()
|
||||||
|
|
||||||
def sync_action_needed_reason(self):
|
def send_custom_status_update_email(self, status):
|
||||||
"""Checks if we need to send another action needed email"""
|
"""Helper function to send out a second status email when the status remains the same,
|
||||||
was_already_action_needed = self._cached_status == self.DomainRequestStatus.ACTION_NEEDED
|
but the reason has changed."""
|
||||||
reason_exists = self._cached_action_needed_reason is not None and self.action_needed_reason is not None
|
|
||||||
reason_changed = self._cached_action_needed_reason != self.action_needed_reason
|
# Currently, we store all this information in three variables.
|
||||||
if was_already_action_needed and reason_exists and reason_changed:
|
# When adding new reasons, this can be a lot to manage so we store it here
|
||||||
# We don't send emails out in state "other"
|
# in a centralized location. However, this may need to change if this scales.
|
||||||
if self.action_needed_reason != self.ActionNeededReasons.OTHER:
|
status_information = {
|
||||||
self._send_action_needed_reason_email(email_content=self.action_needed_reason_email)
|
self.DomainRequestStatus.ACTION_NEEDED: {
|
||||||
|
"cached_reason": self._cached_action_needed_reason,
|
||||||
|
"reason": self.action_needed_reason,
|
||||||
|
"email": self.action_needed_reason_email,
|
||||||
|
"excluded_reasons": [DomainRequest.ActionNeededReasons.OTHER],
|
||||||
|
"wrap_email": True,
|
||||||
|
},
|
||||||
|
self.DomainRequestStatus.REJECTED: {
|
||||||
|
"cached_reason": self._cached_rejection_reason,
|
||||||
|
"reason": self.rejection_reason,
|
||||||
|
"email": self.rejection_reason_email,
|
||||||
|
"excluded_reasons": [],
|
||||||
|
# "excluded_reasons": [DomainRequest.RejectionReasons.OTHER],
|
||||||
|
"wrap_email": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
status_info = status_information.get(status)
|
||||||
|
|
||||||
|
# Don't send an email if there is nothing to send.
|
||||||
|
if status_info.get("email") is None:
|
||||||
|
logger.warning("send_custom_status_update_email() => Tried sending an empty email.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# We should never send an email if no reason was specified.
|
||||||
|
# Additionally, Don't send out emails for reasons that shouldn't send them.
|
||||||
|
if status_info.get("reason") is None or status_info.get("reason") in status_info.get("excluded_reasons"):
|
||||||
|
logger.warning("send_custom_status_update_email() => Tried sending a status email without a reason.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Only send out an email if the underlying reason itself changed or if no email was sent previously.
|
||||||
|
if status_info.get("cached_reason") != status_info.get("reason") or status_info.get("cached_reason") is None:
|
||||||
|
bcc_address = settings.DEFAULT_FROM_EMAIL if settings.IS_PRODUCTION else ""
|
||||||
|
self._send_status_update_email(
|
||||||
|
new_status=status,
|
||||||
|
email_template="emails/includes/custom_email.txt",
|
||||||
|
email_template_subject="emails/status_change_subject.txt",
|
||||||
|
bcc_address=bcc_address,
|
||||||
|
custom_email_content=status_info.get("email"),
|
||||||
|
wrap_email=status_information.get("wrap_email"),
|
||||||
|
)
|
||||||
|
|
||||||
def sync_yes_no_form_fields(self):
|
def sync_yes_no_form_fields(self):
|
||||||
"""Some yes/no forms use a db field to track whether it was checked or not.
|
"""Some yes/no forms use a db field to track whether it was checked or not.
|
||||||
|
@ -899,7 +945,7 @@ class DomainRequest(TimeStampedModel):
|
||||||
target=DomainRequestStatus.ACTION_NEEDED,
|
target=DomainRequestStatus.ACTION_NEEDED,
|
||||||
conditions=[domain_is_not_active, investigator_exists_and_is_staff],
|
conditions=[domain_is_not_active, investigator_exists_and_is_staff],
|
||||||
)
|
)
|
||||||
def action_needed(self, send_email=True):
|
def action_needed(self):
|
||||||
"""Send back an domain request that is under investigation or rejected.
|
"""Send back an domain request that is under investigation or rejected.
|
||||||
|
|
||||||
This action is logged.
|
This action is logged.
|
||||||
|
@ -907,43 +953,23 @@ class DomainRequest(TimeStampedModel):
|
||||||
This action cleans up the rejection status if moving away from rejected.
|
This action cleans up the rejection status if moving away from rejected.
|
||||||
|
|
||||||
As side effects this will delete the domain and domain_information
|
As side effects this will delete the domain and domain_information
|
||||||
(will cascade) when they exist."""
|
(will cascade) when they exist.
|
||||||
|
|
||||||
|
Afterwards, we send out an email for action_needed in def save().
|
||||||
|
See the function send_custom_status_update_email.
|
||||||
|
"""
|
||||||
|
|
||||||
if self.status == self.DomainRequestStatus.APPROVED:
|
if self.status == self.DomainRequestStatus.APPROVED:
|
||||||
self.delete_and_clean_up_domain("reject_with_prejudice")
|
self.delete_and_clean_up_domain("action_needed")
|
||||||
elif self.status == self.DomainRequestStatus.REJECTED:
|
elif self.status == self.DomainRequestStatus.REJECTED:
|
||||||
self.rejection_reason = None
|
self.rejection_reason = None
|
||||||
|
|
||||||
|
# Check if the tuple is setup correctly, then grab its value.
|
||||||
|
|
||||||
literal = DomainRequest.DomainRequestStatus.ACTION_NEEDED
|
literal = DomainRequest.DomainRequestStatus.ACTION_NEEDED
|
||||||
# Check if the tuple is setup correctly, then grab its value
|
|
||||||
action_needed = literal if literal is not None else "Action Needed"
|
action_needed = literal if literal is not None else "Action Needed"
|
||||||
logger.info(f"A status change occurred. {self} was changed to '{action_needed}'")
|
logger.info(f"A status change occurred. {self} was changed to '{action_needed}'")
|
||||||
|
|
||||||
# Send out an email if an action needed reason exists
|
|
||||||
if self.action_needed_reason and self.action_needed_reason != self.ActionNeededReasons.OTHER:
|
|
||||||
email_content = self.action_needed_reason_email
|
|
||||||
self._send_action_needed_reason_email(send_email, email_content)
|
|
||||||
|
|
||||||
def _send_action_needed_reason_email(self, send_email=True, email_content=None):
|
|
||||||
"""Sends out an automatic email for each valid action needed reason provided"""
|
|
||||||
|
|
||||||
email_template_name = "custom_email.txt"
|
|
||||||
email_template_subject_name = f"{self.action_needed_reason}_subject.txt"
|
|
||||||
|
|
||||||
bcc_address = ""
|
|
||||||
if settings.IS_PRODUCTION:
|
|
||||||
bcc_address = settings.DEFAULT_FROM_EMAIL
|
|
||||||
|
|
||||||
self._send_status_update_email(
|
|
||||||
new_status="action needed",
|
|
||||||
email_template=f"emails/action_needed_reasons/{email_template_name}",
|
|
||||||
email_template_subject=f"emails/action_needed_reasons/{email_template_subject_name}",
|
|
||||||
send_email=send_email,
|
|
||||||
bcc_address=bcc_address,
|
|
||||||
custom_email_content=email_content,
|
|
||||||
wrap_email=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
@transition(
|
@transition(
|
||||||
field="status",
|
field="status",
|
||||||
source=[
|
source=[
|
||||||
|
@ -1037,18 +1063,20 @@ class DomainRequest(TimeStampedModel):
|
||||||
def reject(self):
|
def reject(self):
|
||||||
"""Reject an domain request that has been submitted.
|
"""Reject an domain request that has been submitted.
|
||||||
|
|
||||||
|
This action is logged.
|
||||||
|
|
||||||
|
This action cleans up the action needed status if moving away from action needed.
|
||||||
|
|
||||||
As side effects this will delete the domain and domain_information
|
As side effects this will delete the domain and domain_information
|
||||||
(will cascade), and send an email notification."""
|
(will cascade) when they exist.
|
||||||
|
|
||||||
|
Afterwards, we send out an email for reject in def save().
|
||||||
|
See the function send_custom_status_update_email.
|
||||||
|
"""
|
||||||
|
|
||||||
if self.status == self.DomainRequestStatus.APPROVED:
|
if self.status == self.DomainRequestStatus.APPROVED:
|
||||||
self.delete_and_clean_up_domain("reject")
|
self.delete_and_clean_up_domain("reject")
|
||||||
|
|
||||||
self._send_status_update_email(
|
|
||||||
"action needed",
|
|
||||||
"emails/status_change_rejected.txt",
|
|
||||||
"emails/status_change_rejected_subject.txt",
|
|
||||||
)
|
|
||||||
|
|
||||||
@transition(
|
@transition(
|
||||||
field="status",
|
field="status",
|
||||||
source=[
|
source=[
|
||||||
|
|
|
@ -4,6 +4,7 @@ import logging
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django_fsm import FSMField, transition
|
from django_fsm import FSMField, transition
|
||||||
|
from registrar.models.domain_invitation import DomainInvitation
|
||||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||||
from .utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices # type: ignore
|
from .utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices # type: ignore
|
||||||
from .utility.time_stamped_model import TimeStampedModel
|
from .utility.time_stamped_model import TimeStampedModel
|
||||||
|
@ -38,7 +39,7 @@ class PortfolioInvitation(TimeStampedModel):
|
||||||
related_name="portfolios",
|
related_name="portfolios",
|
||||||
)
|
)
|
||||||
|
|
||||||
portfolio_roles = ArrayField(
|
roles = ArrayField(
|
||||||
models.CharField(
|
models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
choices=UserPortfolioRoleChoices.choices,
|
choices=UserPortfolioRoleChoices.choices,
|
||||||
|
@ -48,7 +49,7 @@ class PortfolioInvitation(TimeStampedModel):
|
||||||
help_text="Select one or more roles.",
|
help_text="Select one or more roles.",
|
||||||
)
|
)
|
||||||
|
|
||||||
portfolio_additional_permissions = ArrayField(
|
additional_permissions = ArrayField(
|
||||||
models.CharField(
|
models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
choices=UserPortfolioPermissionChoices.choices,
|
choices=UserPortfolioPermissionChoices.choices,
|
||||||
|
@ -67,6 +68,31 @@ class PortfolioInvitation(TimeStampedModel):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Invitation for {self.email} on {self.portfolio} is {self.status}"
|
return f"Invitation for {self.email} on {self.portfolio} is {self.status}"
|
||||||
|
|
||||||
|
def get_managed_domains_count(self):
|
||||||
|
"""Return the count of domain invitations managed by the invited user for this portfolio."""
|
||||||
|
# Filter the UserDomainRole model to get domains where the user has a manager role
|
||||||
|
managed_domains = DomainInvitation.objects.filter(
|
||||||
|
email=self.email, domain__domain_info__portfolio=self.portfolio
|
||||||
|
).count()
|
||||||
|
return managed_domains
|
||||||
|
|
||||||
|
def get_portfolio_permissions(self):
|
||||||
|
"""
|
||||||
|
Retrieve the permissions for the user's portfolio roles from the invite.
|
||||||
|
This is similar logic to _get_portfolio_permissions in user_portfolio_permission
|
||||||
|
"""
|
||||||
|
# Use a set to avoid duplicate permissions
|
||||||
|
portfolio_permissions = set()
|
||||||
|
|
||||||
|
if self.roles:
|
||||||
|
for role in self.roles:
|
||||||
|
portfolio_permissions.update(UserPortfolioPermission.PORTFOLIO_ROLE_PERMISSIONS.get(role, []))
|
||||||
|
|
||||||
|
if self.additional_permissions:
|
||||||
|
portfolio_permissions.update(self.additional_permissions)
|
||||||
|
|
||||||
|
return list(portfolio_permissions)
|
||||||
|
|
||||||
@transition(field="status", source=PortfolioInvitationStatus.INVITED, target=PortfolioInvitationStatus.RETRIEVED)
|
@transition(field="status", source=PortfolioInvitationStatus.INVITED, target=PortfolioInvitationStatus.RETRIEVED)
|
||||||
def retrieve(self):
|
def retrieve(self):
|
||||||
"""When an invitation is retrieved, create the corresponding permission.
|
"""When an invitation is retrieved, create the corresponding permission.
|
||||||
|
@ -88,8 +114,8 @@ class PortfolioInvitation(TimeStampedModel):
|
||||||
user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
|
user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
|
||||||
portfolio=self.portfolio, user=user
|
portfolio=self.portfolio, user=user
|
||||||
)
|
)
|
||||||
if self.portfolio_roles and len(self.portfolio_roles) > 0:
|
if self.roles and len(self.roles) > 0:
|
||||||
user_portfolio_permission.roles = self.portfolio_roles
|
user_portfolio_permission.roles = self.roles
|
||||||
if self.portfolio_additional_permissions and len(self.portfolio_additional_permissions) > 0:
|
if self.additional_permissions and len(self.additional_permissions) > 0:
|
||||||
user_portfolio_permission.additional_permissions = self.portfolio_additional_permissions
|
user_portfolio_permission.additional_permissions = self.additional_permissions
|
||||||
user_portfolio_permission.save()
|
user_portfolio_permission.save()
|
||||||
|
|
|
@ -229,6 +229,10 @@ class User(AbstractUser):
|
||||||
"""Determines if the current user can view all available domains in a given portfolio"""
|
"""Determines if the current user can view all available domains in a given portfolio"""
|
||||||
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)
|
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)
|
||||||
|
|
||||||
|
def has_view_all_domain_requests_portfolio_permission(self, portfolio):
|
||||||
|
"""Determines if the current user can view all available domains in a given portfolio"""
|
||||||
|
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)
|
||||||
|
|
||||||
def has_any_requests_portfolio_permission(self, portfolio):
|
def has_any_requests_portfolio_permission(self, portfolio):
|
||||||
# BEGIN
|
# BEGIN
|
||||||
# Note code below is to add organization_request feature
|
# Note code below is to add organization_request feature
|
||||||
|
@ -458,3 +462,12 @@ class User(AbstractUser):
|
||||||
return DomainInformation.objects.filter(portfolio=portfolio).values_list("domain_id", flat=True)
|
return DomainInformation.objects.filter(portfolio=portfolio).values_list("domain_id", flat=True)
|
||||||
else:
|
else:
|
||||||
return UserDomainRole.objects.filter(user=self).values_list("domain_id", flat=True)
|
return UserDomainRole.objects.filter(user=self).values_list("domain_id", flat=True)
|
||||||
|
|
||||||
|
def get_user_domain_request_ids(self, request):
|
||||||
|
"""Returns either the domain request ids associated with this user on UserDomainRole or Portfolio"""
|
||||||
|
portfolio = request.session.get("portfolio")
|
||||||
|
|
||||||
|
if self.is_org_user(request) and self.has_view_all_domain_requests_portfolio_permission(portfolio):
|
||||||
|
return DomainRequest.objects.filter(portfolio=portfolio).values_list("id", flat=True)
|
||||||
|
else:
|
||||||
|
return UserDomainRole.objects.filter(user=self).values_list("id", flat=True)
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.forms import ValidationError
|
from django.forms import ValidationError
|
||||||
|
from registrar.models.user_domain_role import UserDomainRole
|
||||||
from registrar.utility.waffle import flag_is_active_for_user
|
from registrar.utility.waffle import flag_is_active_for_user
|
||||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||||
from .utility.time_stamped_model import TimeStampedModel
|
from .utility.time_stamped_model import TimeStampedModel
|
||||||
|
@ -79,6 +80,14 @@ class UserPortfolioPermission(TimeStampedModel):
|
||||||
)
|
)
|
||||||
return readable_roles
|
return readable_roles
|
||||||
|
|
||||||
|
def get_managed_domains_count(self):
|
||||||
|
"""Return the count of domains managed by the user for this portfolio."""
|
||||||
|
# Filter the UserDomainRole model to get domains where the user has a manager role
|
||||||
|
managed_domains = UserDomainRole.objects.filter(
|
||||||
|
user=self.user, role=UserDomainRole.Roles.MANAGER, domain__domain_info__portfolio=self.portfolio
|
||||||
|
).count()
|
||||||
|
return managed_domains
|
||||||
|
|
||||||
def _get_portfolio_permissions(self):
|
def _get_portfolio_permissions(self):
|
||||||
"""
|
"""
|
||||||
Retrieve the permissions for the user's portfolio roles.
|
Retrieve the permissions for the user's portfolio roles.
|
||||||
|
|
|
@ -20,10 +20,11 @@
|
||||||
</li>
|
</li>
|
||||||
{% if opts.model_name == 'domainrequest' %}
|
{% if opts.model_name == 'domainrequest' %}
|
||||||
<li>
|
<li>
|
||||||
<a id="id-copy-to-clipboard-summary" class="button--clipboard" type="button" href="#">
|
<a id="id-copy-to-clipboard-summary" class="usa-button--dja" type="button" href="#">
|
||||||
<svg class="usa-icon" >
|
<svg class="usa-icon" >
|
||||||
<use xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
|
<use xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
|
||||||
</svg>
|
</svg>
|
||||||
|
<!-- the span is targeted in JS, do not remove -->
|
||||||
<span>{% translate "Copy request summary" %}</span>
|
<span>{% translate "Copy request summary" %}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
@ -8,7 +8,7 @@ Template for an input field with a clipboard
|
||||||
<div class="admin-icon-group">
|
<div class="admin-icon-group">
|
||||||
{{ field }}
|
{{ field }}
|
||||||
<button
|
<button
|
||||||
class="usa-button usa-button--unstyled padding-left-1 usa-button--icon button--clipboard copy-to-clipboard"
|
class="usa-button--dja usa-button usa-button__small-text usa-button--unstyled padding-left-1 usa-button--icon copy-to-clipboard"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<div class="no-outline-on-click">
|
<div class="no-outline-on-click">
|
||||||
|
@ -17,23 +17,27 @@ Template for an input field with a clipboard
|
||||||
>
|
>
|
||||||
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
|
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
|
||||||
</svg>
|
</svg>
|
||||||
Copy
|
<!-- the span is targeted in JS, do not remove -->
|
||||||
|
<span>Copy</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="admin-icon-group admin-icon-group__clipboard-link">
|
<div class="admin-icon-group">
|
||||||
<input aria-hidden="true" class="display-none" value="{{ field.email }}" />
|
<input aria-hidden="true" class="display-none" value="{{ field.email }}" />
|
||||||
<button
|
{% if field.email is not None %}
|
||||||
class="usa-button usa-button--unstyled padding-right-1 usa-button--icon button--clipboard copy-to-clipboard text-no-underline padding-left-05"
|
<button
|
||||||
type="button"
|
class="usa-button--dja usa-button usa-button__small-text usa-button--unstyled padding-right-1 usa-button--icon copy-to-clipboard text-no-underline padding-left-05"
|
||||||
>
|
type="button"
|
||||||
<svg
|
|
||||||
class="usa-icon"
|
|
||||||
>
|
>
|
||||||
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
|
<svg
|
||||||
</svg>
|
class="usa-icon"
|
||||||
Copy
|
>
|
||||||
</button>
|
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
|
||||||
|
</svg>
|
||||||
|
<!-- the span is targeted in JS, do not remove -->
|
||||||
|
<span>Copy</span>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -10,6 +10,8 @@
|
||||||
<input id="has_audit_logs" class="display-none" value="{%if filtered_audit_log_entries %}true{% else %}false{% endif %}"/>
|
<input id="has_audit_logs" class="display-none" value="{%if filtered_audit_log_entries %}true{% else %}false{% endif %}"/>
|
||||||
{% url 'get-action-needed-email-for-user-json' as url %}
|
{% url 'get-action-needed-email-for-user-json' as url %}
|
||||||
<input id="get-action-needed-email-for-user-json" class="display-none" value="{{ url }}" />
|
<input id="get-action-needed-email-for-user-json" class="display-none" value="{{ url }}" />
|
||||||
|
{% url 'get-rejection-email-for-user-json' as url_2 %}
|
||||||
|
<input id="get-rejection-email-for-user-json" class="display-none" value="{{ url_2 }}" />
|
||||||
{% for fieldset in adminform %}
|
{% for fieldset in adminform %}
|
||||||
{% comment %}
|
{% comment %}
|
||||||
TODO: this will eventually need to be changed to something like this
|
TODO: this will eventually need to be changed to something like this
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
{% if user.email %}
|
{% if user.email %}
|
||||||
<span id="contact_info_email">{{ user.email }}</span>
|
<span id="contact_info_email">{{ user.email }}</span>
|
||||||
{% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %}
|
{% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %}
|
||||||
<br class="admin-icon-group__br">
|
<br>
|
||||||
{% else %}
|
{% else %}
|
||||||
None<br>
|
None<br>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -137,29 +137,28 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
||||||
|
|
||||||
{% block field_other %}
|
{% block field_other %}
|
||||||
{% if field.field.name == "action_needed_reason_email" %}
|
{% if field.field.name == "action_needed_reason_email" %}
|
||||||
|
{{ field.field }}
|
||||||
|
|
||||||
<div class="margin-top-05 text-faded field-action_needed_reason_email__placeholder">
|
<div class="margin-top-05 text-faded custom-email-placeholder">
|
||||||
–
|
–
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{ field.field }}
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
aria-label="Edit email in textarea"
|
aria-label="Edit email in textarea"
|
||||||
type="button"
|
type="button"
|
||||||
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text margin-left-1 text-no-underline field-action_needed_reason_email__edit flex-align-self-start"
|
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text margin-left-1 text-no-underline flex-align-self-start edit-email-button"
|
||||||
><img src="/public/admin/img/icon-changelink.svg" alt="Change"> Edit email</button
|
><img src="/public/admin/img/icon-changelink.svg" alt="Change"> Edit email</button
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="#email-already-sent-modal"
|
href="#action-needed-email-already-sent-modal"
|
||||||
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text text-no-underline margin-left-1 field-action_needed_reason_email__modal-trigger flex-align-self-start"
|
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text text-no-underline margin-left-1 edit-button-modal-trigger flex-align-self-start"
|
||||||
aria-controls="email-already-sent-modal"
|
aria-controls="action-needed-email-already-sent-modal"
|
||||||
data-open-modal
|
data-open-modal
|
||||||
><img src="/public/admin/img/icon-changelink.svg" alt="Change"> Edit email</a
|
><img src="/public/admin/img/icon-changelink.svg" alt="Change"> Edit email</a
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="usa-modal"
|
class="usa-modal"
|
||||||
id="email-already-sent-modal"
|
id="action-needed-email-already-sent-modal"
|
||||||
aria-labelledby="Are you sure you want to edit this email?"
|
aria-labelledby="Are you sure you want to edit this email?"
|
||||||
aria-describedby="The creator of this request already received an email"
|
aria-describedby="The creator of this request already received an email"
|
||||||
>
|
>
|
||||||
|
@ -187,8 +186,8 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
||||||
<li class="usa-button-group__item">
|
<li class="usa-button-group__item">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
id="action-needed-reason__confirm-edit-email"
|
||||||
class="usa-button"
|
class="usa-button"
|
||||||
id="confirm-edit-email"
|
|
||||||
data-close-modal
|
data-close-modal
|
||||||
>
|
>
|
||||||
Yes, continue editing
|
Yes, continue editing
|
||||||
|
@ -221,11 +220,99 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if original_object.action_needed_reason_email %}
|
{% if original_object.action_needed_reason_email %}
|
||||||
<input id="last-sent-email-content" class="display-none" value="{{original_object.action_needed_reason_email}}">
|
<input id="last-sent-action-needed-email-content" class="display-none" value="{{original_object.action_needed_reason_email}}">
|
||||||
{% else %}
|
{% else %}
|
||||||
<input id="last-sent-email-content" class="display-none" value="None">
|
<input id="last-sent-action-needed-email-content" class="display-none" value="None">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% elif field.field.name == "rejection_reason_email" %}
|
||||||
|
{{ field.field }}
|
||||||
|
|
||||||
|
<div class="margin-top-05 text-faded custom-email-placeholder">
|
||||||
|
–
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
aria-label="Edit email in textarea"
|
||||||
|
type="button"
|
||||||
|
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text margin-left-1 text-no-underline flex-align-self-start edit-email-button"
|
||||||
|
><img src="/public/admin/img/icon-changelink.svg" alt="Change"> Edit email</button
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="#rejection-reason-email-already-sent-modal"
|
||||||
|
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text text-no-underline margin-left-1 edit-button-modal-trigger flex-align-self-start"
|
||||||
|
aria-controls="rejection-reason-email-already-sent-modal"
|
||||||
|
data-open-modal
|
||||||
|
><img src="/public/admin/img/icon-changelink.svg" alt="Change"> Edit email</a
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="usa-modal"
|
||||||
|
id="rejection-reason-email-already-sent-modal"
|
||||||
|
aria-labelledby="Are you sure you want to edit this email?"
|
||||||
|
aria-describedby="The creator of this request already received an email"
|
||||||
|
>
|
||||||
|
<div class="usa-modal__content">
|
||||||
|
<div class="usa-modal__main">
|
||||||
|
<h2 class="usa-modal__heading">
|
||||||
|
Are you sure you want to edit this email?
|
||||||
|
</h2>
|
||||||
|
<div class="usa-prose">
|
||||||
|
<p>
|
||||||
|
The creator of this request already received an email for this status/reason:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li class="font-body-sm">Status: <b>Rejected</b></li>
|
||||||
|
<li class="font-body-sm">Reason: <b>{{ original_object.get_rejection_reason_display }}</b></li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
If you edit this email's text, <b>the system will send another email</b> to
|
||||||
|
the creator after you “save” your changes. If you do not want to send another email, click “cancel” below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="usa-modal__footer">
|
||||||
|
<ul class="usa-button-group">
|
||||||
|
<li class="usa-button-group__item">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
id="rejection-reason__confirm-edit-email"
|
||||||
|
class="usa-button"
|
||||||
|
data-close-modal
|
||||||
|
>
|
||||||
|
Yes, continue editing
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="usa-button-group__item">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="usa-button usa-button--unstyled padding-105 text-center"
|
||||||
|
name="_cancel_edit_email"
|
||||||
|
data-close-modal
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="usa-button usa-modal__close"
|
||||||
|
aria-label="Close this window"
|
||||||
|
data-close-modal
|
||||||
|
>
|
||||||
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
|
||||||
|
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if original_object.rejection_reason_email %}
|
||||||
|
<input id="last-sent-rejection-email-content" class="display-none" value="{{original_object.rejection_reason_email}}">
|
||||||
|
{% else %}
|
||||||
|
<input id="last-sent-rejection-email-content" class="display-none" value="None">
|
||||||
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ field.field }}
|
{{ field.field }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -254,7 +341,7 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th colspan="4">Other contact information</th>
|
<th colspan="5">Other contact information</th>
|
||||||
<tr>
|
<tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
@ -267,18 +354,31 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
||||||
</td>
|
</td>
|
||||||
<td class="padding-left-1">{{ contact.phone }}</td>
|
<td class="padding-left-1">{{ contact.phone }}</td>
|
||||||
<td class="padding-left-1 text-size-small">
|
<td class="padding-left-1 text-size-small">
|
||||||
<input aria-hidden="true" class="display-none" value="{{ contact.email }}" />
|
{% if contact.email %}
|
||||||
<button
|
<input aria-hidden="true" class="display-none" value="{{ contact.email }}" />
|
||||||
class="usa-button usa-button--unstyled padding-right-1 usa-button--icon button--clipboard copy-to-clipboard usa-button__small-text text-no-underline"
|
<button
|
||||||
type="button"
|
class="
|
||||||
>
|
usa-button--dja
|
||||||
<svg
|
usa-button
|
||||||
class="usa-icon"
|
usa-button__small-text
|
||||||
|
usa-button--unstyled
|
||||||
|
padding-right-1
|
||||||
|
padding-top-0
|
||||||
|
padding-bottom-0
|
||||||
|
usa-button--icon
|
||||||
|
copy-to-clipboard
|
||||||
|
text-no-underline"
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
|
<svg
|
||||||
</svg>
|
class="usa-icon"
|
||||||
<span>Copy email</span>
|
>
|
||||||
</button>
|
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
|
||||||
|
</svg>
|
||||||
|
<!-- the span is targeted in JS, do not remove -->
|
||||||
|
<span>Copy email</span>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
@ -45,7 +45,7 @@
|
||||||
<div class="usa-alert usa-alert--info usa-alert--slim">
|
<div class="usa-alert usa-alert--info usa-alert--slim">
|
||||||
<div class="usa-alert__body">
|
<div class="usa-alert__body">
|
||||||
<p class="usa-alert__text ">
|
<p class="usa-alert__text ">
|
||||||
To manage information for this domain, you must add yourself as a domain manager.
|
You don't have access to manage {{domain.name}}. If you need to make updates, contact one of the listed domain managers.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -42,7 +42,7 @@
|
||||||
|
|
||||||
{% input_with_errors form.state_territory %}
|
{% input_with_errors form.state_territory %}
|
||||||
|
|
||||||
{% with add_class="usa-input--small" sublabel_text="Enter a zip code in the required format, like 12345 or 12345-6789." %}
|
{% with add_class="usa-input--small" sublabel_text="Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789." %}
|
||||||
{% input_with_errors form.zipcode %}
|
{% input_with_errors form.zipcode %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
|
|
|
@ -12,8 +12,10 @@
|
||||||
|
|
||||||
<p>Names that <em>uniquely apply to your organization</em> are likely to be approved over names that could also apply to other organizations.
|
<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>
|
{% if not is_federal %}In most instances, this requires including your state’s two-letter abbreviation.{% endif %}</p>
|
||||||
|
|
||||||
|
{% if not portfolio %}
|
||||||
<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>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<p>Note that <strong>only federal agencies can request generic terms</strong> like
|
<p>Note that <strong>only federal agencies can request generic terms</strong> like
|
||||||
vote.gov.</p>
|
vote.gov.</p>
|
||||||
|
|
|
@ -12,7 +12,11 @@
|
||||||
|
|
||||||
<h1>You’re about to start your .gov domain request.</h1>
|
<h1>You’re about to start your .gov domain request.</h1>
|
||||||
<p>You don’t have to complete the process in one session. You can save what you enter and come back to it when you’re ready.</p>
|
<p>You don’t have to complete the process in one session. You can save what you enter and come back to it when you’re ready.</p>
|
||||||
|
{% if portfolio %}
|
||||||
|
<p>We’ll use the information you provide to verify your domain request meets our guidelines.</p>
|
||||||
|
{% else %}
|
||||||
<p>We’ll use the information you provide to verify your organization’s eligibility for a .gov domain. We’ll also verify that the domain you request meets our guidelines.</p>
|
<p>We’ll use the information you provide to verify your organization’s eligibility for a .gov domain. We’ll also verify that the domain you request meets our guidelines.</p>
|
||||||
|
{% endif %}
|
||||||
<h2>Time to complete the form</h2>
|
<h2>Time to complete the form</h2>
|
||||||
<p>If you have <a href="{% public_site_url 'domains/before/#information-you%E2%80%99ll-need-to-complete-the-domain-request-form' %}" target="_blank" class="usa-link">all the information you need</a>,
|
<p>If you have <a href="{% public_site_url 'domains/before/#information-you%E2%80%99ll-need-to-complete-the-domain-request-form' %}" target="_blank" class="usa-link">all the information you need</a>,
|
||||||
completing your domain request might take around 15 minutes.</p>
|
completing your domain request might take around 15 minutes.</p>
|
||||||
|
|
|
@ -33,7 +33,7 @@
|
||||||
|
|
||||||
{% input_with_errors forms.0.state_territory %}
|
{% input_with_errors forms.0.state_territory %}
|
||||||
|
|
||||||
{% with add_class="usa-input--small" sublabel_text="Enter a zip code in the required format, like 12345 or 12345-6789." %}
|
{% with add_class="usa-input--small" sublabel_text="Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789." %}
|
||||||
{% input_with_errors forms.0.zipcode %}
|
{% input_with_errors forms.0.zipcode %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
{% extends 'domain_request_form.html' %}
|
||||||
|
{% load field_helpers url_helpers %}
|
||||||
|
|
||||||
|
{% block form_instructions %}
|
||||||
|
<p>🛸🛸🛸🛸 Placeholder content 🛸🛸🛸🛸</p>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block form_fields %}
|
||||||
|
<fieldset class="usa-fieldset">
|
||||||
|
<legend>
|
||||||
|
<h2>What is the name of your space vessel?</h2>
|
||||||
|
</legend>
|
||||||
|
|
||||||
|
{% input_with_errors forms.0.organization_name %}
|
||||||
|
</fieldset>
|
||||||
|
{% endblock %}
|
|
@ -19,5 +19,9 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block form_fields %}
|
{% block form_fields %}
|
||||||
{% include "includes/request_review_steps.html" with is_editable=True %}
|
{% if portfolio %}
|
||||||
|
{% include "includes/portfolio_request_review_steps.html" with is_editable=True %}
|
||||||
|
{% else %}
|
||||||
|
{% include "includes/request_review_steps.html" with is_editable=True %}
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -11,8 +11,13 @@
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
The name of your suborganization will be publicly listed as the domain registrant.
|
The name of your suborganization will be publicly listed as the domain registrant.
|
||||||
This list of suborganizations has been populated the .gov program.
|
</p>
|
||||||
If you believe there is an error please contact <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
|
<p>
|
||||||
|
When this field is blank, the domain registrant will be listed as the overarching organization: {{ portfolio }}.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If you don’t see your suborganization in the menu or need to edit one of the options,
|
||||||
|
please contact <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% if has_any_domains_portfolio_permission and has_edit_suborganization_portfolio_permission %}
|
{% if has_any_domains_portfolio_permission and has_edit_suborganization_portfolio_permission %}
|
||||||
|
|
|
@ -8,8 +8,8 @@ REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
|
||||||
STATUS: Rejected
|
STATUS: Rejected
|
||||||
|
|
||||||
----------------------------------------------------------------
|
----------------------------------------------------------------
|
||||||
{% if domain_request.rejection_reason != 'other' %}
|
{% if reason != domain_request.RejectionReasons.DOMAIN_PURPOSE.OTHER %}
|
||||||
REJECTION REASON{% endif %}{% if domain_request.rejection_reason == 'purpose_not_met' %}
|
REJECTION REASON{% endif %}{% if reason == domain_request.RejectionReasons.DOMAIN_PURPOSE %}
|
||||||
Your domain request was rejected because the purpose you provided did not meet our
|
Your domain request was rejected because the purpose you provided did not meet our
|
||||||
requirements. You didn’t provide enough information about how you intend to use the
|
requirements. You didn’t provide enough information about how you intend to use the
|
||||||
domain.
|
domain.
|
||||||
|
@ -18,7 +18,7 @@ Learn more about:
|
||||||
- Eligibility for a .gov domain <https://get.gov/domains/eligibility>
|
- Eligibility for a .gov domain <https://get.gov/domains/eligibility>
|
||||||
- What you can and can’t do with .gov domains <https://get.gov/domains/requirements/>
|
- What you can and can’t do with .gov domains <https://get.gov/domains/requirements/>
|
||||||
|
|
||||||
If you have questions or comments, reply to this email.{% elif domain_request.rejection_reason == 'requestor_not_eligible' %}
|
If you have questions or comments, reply to this email.{% elif reason == domain_request.RejectionReasons.DOMAIN_PURPOSE.REQUESTOR_NOT_ELIGIBLE %}
|
||||||
Your domain request was rejected because we don’t believe you’re eligible to request a
|
Your domain request was rejected because we don’t believe you’re eligible to request a
|
||||||
.gov domain on behalf of {{ domain_request.organization_name }}. You must be a government employee, or be
|
.gov domain on behalf of {{ domain_request.organization_name }}. You must be a government employee, or be
|
||||||
working on behalf of a government organization, to request a .gov domain.
|
working on behalf of a government organization, to request a .gov domain.
|
||||||
|
@ -26,7 +26,7 @@ working on behalf of a government organization, to request a .gov domain.
|
||||||
|
|
||||||
DEMONSTRATE ELIGIBILITY
|
DEMONSTRATE ELIGIBILITY
|
||||||
If you can provide more information that demonstrates your eligibility, or you want to
|
If you can provide more information that demonstrates your eligibility, or you want to
|
||||||
discuss further, reply to this email.{% elif domain_request.rejection_reason == 'org_has_domain' %}
|
discuss further, reply to this email.{% elif reason == domain_request.RejectionReasons.DOMAIN_PURPOSE.ORG_HAS_DOMAIN %}
|
||||||
Your domain request was rejected because {{ domain_request.organization_name }} has a .gov domain. Our
|
Your domain request was rejected because {{ domain_request.organization_name }} has a .gov domain. Our
|
||||||
practice is to approve one domain per online service per government organization. We
|
practice is to approve one domain per online service per government organization. We
|
||||||
evaluate additional requests on a case-by-case basis. You did not provide sufficient
|
evaluate additional requests on a case-by-case basis. You did not provide sufficient
|
||||||
|
@ -35,9 +35,9 @@ justification for an additional domain.
|
||||||
Read more about our practice of approving one domain per online service
|
Read more about our practice of approving one domain per online service
|
||||||
<https://get.gov/domains/before/#one-domain-per-service>.
|
<https://get.gov/domains/before/#one-domain-per-service>.
|
||||||
|
|
||||||
If you have questions or comments, reply to this email.{% elif domain_request.rejection_reason == 'contacts_not_verified' %}
|
If you have questions or comments, reply to this email.{% elif reason == 'contacts_not_verified' %}
|
||||||
Your domain request was rejected because we could not verify the organizational
|
Your domain request was rejected because we could not verify the organizational
|
||||||
contacts you provided. If you have questions or comments, reply to this email.{% elif domain_request.rejection_reason == 'org_not_eligible' %}
|
contacts you provided. If you have questions or comments, reply to this email.{% elif reason == domain_request.RejectionReasons.DOMAIN_PURPOSE.ORG_NOT_ELIGIBLE %}
|
||||||
Your domain request was rejected because we determined that {{ domain_request.organization_name }} is not
|
Your domain request was rejected because we determined that {{ domain_request.organization_name }} is not
|
||||||
eligible for a .gov domain. .Gov domains are only available to official U.S.-based
|
eligible for a .gov domain. .Gov domains are only available to official U.S.-based
|
||||||
government organizations.
|
government organizations.
|
||||||
|
@ -46,7 +46,7 @@ Learn more about eligibility for .gov domains
|
||||||
<https://get.gov/domains/eligibility/>.
|
<https://get.gov/domains/eligibility/>.
|
||||||
|
|
||||||
If you have questions or comments, reply to this email.
|
If you have questions or comments, reply to this email.
|
||||||
{% elif domain_request.rejection_reason == 'naming_not_met' %}
|
{% elif reason == domain_request.RejectionReasons.DOMAIN_PURPOSE.NAMING_REQUIREMENTS %}
|
||||||
Your domain request was rejected because it does not meet our naming requirements.
|
Your domain request was rejected because it does not meet our naming requirements.
|
||||||
Domains should uniquely identify a government organization and be clear to the
|
Domains should uniquely identify a government organization and be clear to the
|
||||||
general public. Learn more about naming requirements for your type of organization
|
general public. Learn more about naming requirements for your type of organization
|
||||||
|
@ -55,7 +55,7 @@ general public. Learn more about naming requirements for your type of organizati
|
||||||
|
|
||||||
YOU CAN SUBMIT A NEW REQUEST
|
YOU CAN SUBMIT A NEW REQUEST
|
||||||
We encourage you to request a domain that meets our requirements. If you have
|
We encourage you to request a domain that meets our requirements. If you have
|
||||||
questions or want to discuss potential domain names, reply to this email.{% elif domain_request.rejection_reason == 'other' %}
|
questions or want to discuss potential domain names, reply to this email.{% elif reason == domain_request.RejectionReasons.DOMAIN_PURPOSE.OTHER %}
|
||||||
YOU CAN SUBMIT A NEW REQUEST
|
YOU CAN SUBMIT A NEW REQUEST
|
||||||
If your organization is eligible for a .gov domain and you meet our other requirements, you can submit a new request.
|
If your organization is eligible for a .gov domain and you meet our other requirements, you can submit a new request.
|
||||||
|
|
||||||
|
|
|
@ -3,204 +3,203 @@
|
||||||
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
|
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
|
||||||
{% url 'get_domain_requests_json' as url %}
|
{% url 'get_domain_requests_json' as url %}
|
||||||
<span id="get_domain_requests_json_url" class="display-none">{{url}}</span>
|
<span id="get_domain_requests_json_url" class="display-none">{{url}}</span>
|
||||||
|
|
||||||
<section class="section-outlined domain-requests{% if portfolio %} section-outlined--border-base-light{% endif %}" id="domain-requests">
|
<section class="section-outlined domain-requests{% if portfolio %} section-outlined--border-base-light{% endif %}" id="domain-requests">
|
||||||
<div class="grid-row">
|
<div class="section-outlined__header margin-bottom-3 {% if not portfolio %} section-outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}">
|
||||||
{% if not portfolio %}
|
{% if not portfolio %}
|
||||||
<div class="mobile:grid-col-12 desktop:grid-col-6">
|
<h2 id="domain-requests-header" class="display-inline-block">Domain requests</h2>
|
||||||
<h2 id="domain-requests-header" class="flex-6">Domain requests</h2>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<!-- Embedding the portfolio value in a data attribute -->
|
<!-- Embedding the portfolio value in a data attribute -->
|
||||||
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>
|
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="mobile:grid-col-12 desktop:grid-col-6">
|
|
||||||
<section aria-label="Domain requests search component" class="flex-6 margin-y-2">
|
<div class="section-outlined__search {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6{% endif %} {% if is_widescreen_mode %} section-outlined__search--widescreen {% endif %}">
|
||||||
<form class="usa-search usa-search--small" method="POST" role="search">
|
<section aria-label="Domain requests search component" class="margin-top-2">
|
||||||
{% csrf_token %}
|
<form class="usa-search usa-search--small" method="POST" role="search">
|
||||||
<button class="usa-button usa-button--unstyled margin-right-2 domain-requests__reset-search display-none" type="button">
|
{% csrf_token %}
|
||||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
<button class="usa-button usa-button--unstyled margin-right-3 domain-requests__reset-search display-none" type="button">
|
||||||
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||||
</svg>
|
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
|
||||||
Reset
|
</svg>
|
||||||
</button>
|
Reset
|
||||||
{% if portfolio %}
|
</button>
|
||||||
<label class="usa-sr-only" for="domain-requests__search-field">Search by domain name or creator</label>
|
{% if portfolio %}
|
||||||
{% else %}
|
<label class="usa-sr-only" for="domain-requests__search-field">Search by domain name or creator</label>
|
||||||
<label class="usa-sr-only" for="domain-requests__search-field">Search by domain name</label>
|
{% else %}
|
||||||
{% endif %}
|
<label class="usa-sr-only" for="domain-requests__search-field">Search by domain name</label>
|
||||||
<input
|
{% endif %}
|
||||||
class="usa-input"
|
<input
|
||||||
id="domain-requests__search-field"
|
class="usa-input"
|
||||||
type="search"
|
id="domain-requests__search-field"
|
||||||
name="search"
|
type="search"
|
||||||
{% if portfolio %}
|
name="search"
|
||||||
placeholder="Search by domain name or creator"
|
{% if portfolio %}
|
||||||
{% else %}
|
placeholder="Search by domain name or creator"
|
||||||
placeholder="Search by domain name"
|
{% else %}
|
||||||
{% endif %}
|
placeholder="Search by domain name"
|
||||||
/>
|
{% endif %}
|
||||||
<button class="usa-button" type="submit" id="domain-requests__search-field-submit">
|
/>
|
||||||
<img
|
<button class="usa-button" type="submit" id="domain-requests__search-field-submit">
|
||||||
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
|
<img
|
||||||
class="usa-search__submit-icon"
|
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
|
||||||
alt="Search"
|
class="usa-search__submit-icon"
|
||||||
/>
|
alt="Search"
|
||||||
</button>
|
/>
|
||||||
</form>
|
</button>
|
||||||
</section>
|
</form>
|
||||||
</div>
|
</section>
|
||||||
|
</div>
|
||||||
|
{% if portfolio %}
|
||||||
|
<div class="section-outlined__utility-button mobile-lg:padding-right-105 {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}" id="export-csv">
|
||||||
|
<section aria-label="Domain Requests report component" class="margin-top-205">
|
||||||
|
<a href="{% url 'export_data_type_requests' %}" class="usa-button usa-button--unstyled usa-button--with-icon usa-button--justify-right" role="button">
|
||||||
|
<svg class="usa-icon usa-icon--big" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||||
|
<use xlink:href="{% static 'img/sprite.svg' %}#file_download"></use>
|
||||||
|
</svg>Export as CSV
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if portfolio %}
|
{% if portfolio %}
|
||||||
<div class="display-flex flex-align-center">
|
<div class="display-flex flex-align-center">
|
||||||
<span class="margin-right-2 margin-top-neg-1 usa-prose text-base-darker">Filter by</span>
|
<span class="margin-right-2 margin-top-neg-1 usa-prose text-base-darker">Filter by</span>
|
||||||
<div class="usa-accordion usa-accordion--select margin-right-2">
|
<div class="usa-accordion usa-accordion--select margin-right-2">
|
||||||
<div class="usa-accordion__heading">
|
<div class="usa-accordion__heading">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
class="usa-button usa-button--small padding--8-8-9 usa-button--outline usa-button--filter usa-accordion__button"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="filter-status"
|
||||||
|
>
|
||||||
|
<span class="filter-indicator text-bold display-none"></span> Status
|
||||||
|
<svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24">
|
||||||
|
<use xlink:href="/public/img/sprite.svg#expand_more"></use>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="filter-status" class="usa-accordion__content usa-prose shadow-1">
|
||||||
|
<h2>Status</h2>
|
||||||
|
<fieldset class="usa-fieldset margin-top-0">
|
||||||
|
<legend class="usa-legend">Select to apply <span class="sr-only">status</span> filter</legend>
|
||||||
|
<div class="usa-checkbox">
|
||||||
|
<input
|
||||||
|
class="usa-checkbox__input"
|
||||||
|
id="filter-status-started"
|
||||||
|
type="checkbox"
|
||||||
|
name="filter-status"
|
||||||
|
value="started"
|
||||||
|
/>
|
||||||
|
<label class="usa-checkbox__label" for="filter-status-started">Started</label>
|
||||||
|
</div>
|
||||||
|
<div class="usa-checkbox">
|
||||||
|
<input
|
||||||
|
class="usa-checkbox__input"
|
||||||
|
id="filter-status-submitted"
|
||||||
|
type="checkbox"
|
||||||
|
name="filter-status"
|
||||||
|
value="submitted"
|
||||||
|
/>
|
||||||
|
<label class="usa-checkbox__label" for="filter-status-submitted">Submitted</label>
|
||||||
|
</div>
|
||||||
|
<div class="usa-checkbox">
|
||||||
|
<input
|
||||||
|
class="usa-checkbox__input"
|
||||||
|
id="filter-status-in-review"
|
||||||
|
type="checkbox"
|
||||||
|
name="filter-status"
|
||||||
|
value="in review"
|
||||||
|
/>
|
||||||
|
<label class="usa-checkbox__label" for="filter-status-in-review">In review</label>
|
||||||
|
</div>
|
||||||
|
<div class="usa-checkbox">
|
||||||
|
<input
|
||||||
|
class="usa-checkbox__input"
|
||||||
|
id="filter-status-action-needed"
|
||||||
|
type="checkbox"
|
||||||
|
name="filter-status"
|
||||||
|
value="action needed"
|
||||||
|
/>
|
||||||
|
<label class="usa-checkbox__label" for="filter-status-action-needed">Action needed</label>
|
||||||
|
</div>
|
||||||
|
<div class="usa-checkbox">
|
||||||
|
<input
|
||||||
|
class="usa-checkbox__input"
|
||||||
|
id="filter-status-rejected"
|
||||||
|
type="checkbox"
|
||||||
|
name="filter-status"
|
||||||
|
value="rejected"
|
||||||
|
/>
|
||||||
|
<label class="usa-checkbox__label" for="filter-status-rejected">Rejected</label>
|
||||||
|
</div>
|
||||||
|
<div class="usa-checkbox">
|
||||||
|
<input
|
||||||
|
class="usa-checkbox__input"
|
||||||
|
id="filter-status-withdrawn"
|
||||||
|
type="checkbox"
|
||||||
|
name="filter-status"
|
||||||
|
value="withdrawn"
|
||||||
|
/>
|
||||||
|
<label class="usa-checkbox__label" for="filter-status-withdrawn">Withdrawn</label>
|
||||||
|
</div>
|
||||||
|
<div class="usa-checkbox">
|
||||||
|
<input
|
||||||
|
class="usa-checkbox__input"
|
||||||
|
id="filter-status-ineligible"
|
||||||
|
type="checkbox"
|
||||||
|
name="filter-status"
|
||||||
|
value="ineligible"
|
||||||
|
/>
|
||||||
|
<label class="usa-checkbox__label" for="filter-status-ineligible">Ineligible</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="usa-button usa-button--small padding--8-8-9 usa-button--outline usa-button--filter usa-accordion__button"
|
class="usa-button usa-button--small padding--8-12-9-12 usa-button--outline usa-button--filter domain-requests__reset-filters display-none"
|
||||||
aria-expanded="false"
|
>
|
||||||
aria-controls="filter-status"
|
Clear filters
|
||||||
>
|
<svg class="usa-icon top-1px" aria-hidden="true" focusable="false" role="img" width="24">
|
||||||
<span class="filter-indicator text-bold display-none"></span> Status
|
<use xlink:href="/public/img/sprite.svg#close"></use>
|
||||||
<svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24">
|
|
||||||
<use xlink:href="/public/img/sprite.svg#expand_more"></use>
|
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
<div id="filter-status" class="usa-accordion__content usa-prose shadow-1">
|
|
||||||
<h2>Status</h2>
|
|
||||||
<fieldset class="usa-fieldset margin-top-0">
|
|
||||||
<legend class="usa-legend">Select to apply <span class="sr-only">status</span> filter</legend>
|
|
||||||
<div class="usa-checkbox">
|
|
||||||
<input
|
|
||||||
class="usa-checkbox__input"
|
|
||||||
id="filter-status-started"
|
|
||||||
type="checkbox"
|
|
||||||
name="filter-status"
|
|
||||||
value="started"
|
|
||||||
/>
|
|
||||||
<label class="usa-checkbox__label" for="filter-status-started"
|
|
||||||
>Started</label
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="usa-checkbox">
|
|
||||||
<input
|
|
||||||
class="usa-checkbox__input"
|
|
||||||
id="filter-status-submitted"
|
|
||||||
type="checkbox"
|
|
||||||
name="filter-status"
|
|
||||||
value="submitted"
|
|
||||||
/>
|
|
||||||
<label class="usa-checkbox__label" for="filter-status-submitted"
|
|
||||||
>Submitted</label
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="usa-checkbox">
|
|
||||||
<input
|
|
||||||
class="usa-checkbox__input"
|
|
||||||
id="filter-status-in-review"
|
|
||||||
type="checkbox"
|
|
||||||
name="filter-status"
|
|
||||||
value="in review"
|
|
||||||
/>
|
|
||||||
<label class="usa-checkbox__label" for="filter-status-in-review"
|
|
||||||
>In review</label
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="usa-checkbox">
|
|
||||||
<input
|
|
||||||
class="usa-checkbox__input"
|
|
||||||
id="filter-status-action-needed"
|
|
||||||
type="checkbox"
|
|
||||||
name="filter-status"
|
|
||||||
value="action needed"
|
|
||||||
/>
|
|
||||||
<label class="usa-checkbox__label" for="filter-status-action-needed"
|
|
||||||
>Action needed</label
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="usa-checkbox">
|
|
||||||
<input
|
|
||||||
class="usa-checkbox__input"
|
|
||||||
id="filter-status-rejected"
|
|
||||||
type="checkbox"
|
|
||||||
name="filter-status"
|
|
||||||
value="rejected"
|
|
||||||
/>
|
|
||||||
<label class="usa-checkbox__label" for="filter-status-rejected"
|
|
||||||
>Rejected</label
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="usa-checkbox">
|
|
||||||
<input
|
|
||||||
class="usa-checkbox__input"
|
|
||||||
id="filter-status-withdrawn"
|
|
||||||
type="checkbox"
|
|
||||||
name="filter-status"
|
|
||||||
value="withdrawn"
|
|
||||||
/>
|
|
||||||
<label class="usa-checkbox__label" for="filter-status-withdrawn"
|
|
||||||
>Withdrawn</label
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="usa-checkbox">
|
|
||||||
<input
|
|
||||||
class="usa-checkbox__input"
|
|
||||||
id="filter-status-ineligible"
|
|
||||||
type="checkbox"
|
|
||||||
name="filter-status"
|
|
||||||
value="ineligible"
|
|
||||||
/>
|
|
||||||
<label class="usa-checkbox__label" for="filter-status-ineligible"
|
|
||||||
>Ineligible</label
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="usa-button usa-button--small padding--8-12-9-12 usa-button--outline usa-button--filter domain-requests__reset-filters display-none"
|
|
||||||
>
|
|
||||||
Clear filters
|
|
||||||
<svg class="usa-icon top-1px" aria-hidden="true" focusable="false" role="img" width="24">
|
|
||||||
<use xlink:href="/public/img/sprite.svg#close"></use>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="domain-requests__table-wrapper display-none usa-table-container--scrollable margin-top-0" tabindex="0">
|
<div class="domain-requests__table-wrapper display-none usa-table-container--scrollable margin-top-0" tabindex="0">
|
||||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked domain-requests__table">
|
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked domain-requests__table">
|
||||||
<caption class="sr-only">Your domain requests</caption>
|
<caption class="sr-only">Your domain requests</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th data-sortable="requested_domain__name" scope="col" role="columnheader">Domain name</th>
|
<th data-sortable="requested_domain__name" scope="col" role="columnheader">Domain name</th>
|
||||||
<th data-sortable="last_submitted_date" scope="col" role="columnheader">Submitted</th>
|
<th data-sortable="last_submitted_date" scope="col" role="columnheader">Submitted</th>
|
||||||
{% if portfolio %}
|
{% if portfolio %}
|
||||||
<th data-sortable="creator" scope="col" role="columnheader">Created by</th>
|
<th data-sortable="creator" scope="col" role="columnheader">Created by</th>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<th data-sortable="status" scope="col" role="columnheader">Status</th>
|
<th data-sortable="status" scope="col" role="columnheader">Status</th>
|
||||||
<th scope="col" role="columnheader"><span class="usa-sr-only">Action</span></th>
|
<th scope="col" role="columnheader"><span class="usa-sr-only">Action</span></th>
|
||||||
<!-- AJAX will conditionally add a th for delete actions -->
|
<!-- AJAX will conditionally add a th for delete actions -->
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="domain-requests-tbody">
|
<tbody id="domain-requests-tbody">
|
||||||
<!-- AJAX will populate this tbody -->
|
<!-- AJAX will populate this tbody -->
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div
|
<div class="usa-sr-only usa-table__announcement-region" aria-live="polite"></div>
|
||||||
class="usa-sr-only usa-table__announcement-region"
|
|
||||||
aria-live="polite"
|
|
||||||
></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="domain-requests__no-data display-none">
|
<div class="domain-requests__no-data display-none">
|
||||||
<p>You haven't requested any domains.</p>
|
<p>You haven't requested any domains.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="domain-requests__no-search-results display-none">
|
<div class="domain-requests__no-search-results display-none">
|
||||||
<p>No results found</p>
|
<p>No results found</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domain-requests-pagination">
|
|
||||||
|
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domain-requests-pagination">
|
||||||
<span class="usa-pagination__counter text-base-dark padding-left-2 margin-bottom-1">
|
<span class="usa-pagination__counter text-base-dark padding-left-2 margin-bottom-1">
|
||||||
<!-- Count will be dynamically populated by JS -->
|
<!-- Count will be dynamically populated by JS -->
|
||||||
</span>
|
</span>
|
||||||
|
|
|
@ -34,6 +34,7 @@
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<ul class="usa-nav__primary usa-accordion">
|
<ul class="usa-nav__primary usa-accordion">
|
||||||
|
{% if not hide_domains %}
|
||||||
<li class="usa-nav__primary-item">
|
<li class="usa-nav__primary-item">
|
||||||
{% if has_any_domains_portfolio_permission %}
|
{% if has_any_domains_portfolio_permission %}
|
||||||
{% url 'domains' as url %}
|
{% url 'domains' as url %}
|
||||||
|
@ -44,13 +45,14 @@
|
||||||
Domains
|
Domains
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
{% endif %}
|
||||||
<!-- <li class="usa-nav__primary-item">
|
<!-- <li class="usa-nav__primary-item">
|
||||||
<a href="#" class="usa-nav-link">
|
<a href="#" class="usa-nav-link">
|
||||||
Domain groups
|
Domain groups
|
||||||
</a>
|
</a>
|
||||||
</li> -->
|
</li> -->
|
||||||
|
|
||||||
{% if has_organization_requests_flag %}
|
{% if has_organization_requests_flag and not hide_requests %}
|
||||||
<li class="usa-nav__primary-item">
|
<li class="usa-nav__primary-item">
|
||||||
<!-- user has one of the view permissions plus the edit permission, show the dropdown -->
|
<!-- user has one of the view permissions plus the edit permission, show the dropdown -->
|
||||||
{% if has_edit_request_portfolio_permission %}
|
{% if has_edit_request_portfolio_permission %}
|
||||||
|
@ -91,12 +93,12 @@
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if has_organization_members_flag and has_view_members_portfolio_permission %}
|
{% if has_organization_members_flag %}
|
||||||
<li class="usa-nav__primary-item">
|
<li class="usa-nav__primary-item">
|
||||||
<a href="/members/" class="usa-nav-link {% if path|is_members_subpage %} usa-current{% endif %}">
|
<a href="/members/" class="usa-nav-link {% if path|is_members_subpage %} usa-current{% endif %}">
|
||||||
Members
|
Members
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<li class="usa-nav__primary-item">
|
<li class="usa-nav__primary-item">
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
<h4 class="margin-bottom-0 text-primary">Assigned domains</h4>
|
||||||
|
{% if domain_count > 0 %}
|
||||||
|
<p class="margin-top-0">{{domain_count}}</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="margin-top-0">This member does not manage any domains.{% if manage_button %} To assign this member a domain, click "Manage".{% endif %}</p>
|
||||||
|
{% endif %}
|
26
src/registrar/templates/includes/member_permissions.html
Normal file
26
src/registrar/templates/includes/member_permissions.html
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
<h4 class="margin-bottom-0 text-primary">Member access</h4>
|
||||||
|
{% if permissions.roles and 'organization_admin' in permissions.roles %}
|
||||||
|
<p class="margin-top-0">Admin access</p>
|
||||||
|
{% elif permissions.roles and 'organization_member' in permissions.roles %}
|
||||||
|
<p class="margin-top-0">Basic access</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="margin-top-0">⎯</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h4 class="margin-bottom-0 text-primary">Organization domain requests</h4>
|
||||||
|
{% if member_has_edit_request_portfolio_permission %}
|
||||||
|
<p class="margin-top-0">View all requests plus create requests</p>
|
||||||
|
{% elif member_has_view_all_requests_portfolio_permission %}
|
||||||
|
<p class="margin-top-0">View all requests</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="margin-top-0">No access</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h4 class="margin-bottom-0 text-primary">Organization members</h4>
|
||||||
|
{% if member_has_edit_members_portfolio_permission %}
|
||||||
|
<p class="margin-top-0">View all members plus manage members</p>
|
||||||
|
{% elif member_has_view_members_portfolio_permission %}
|
||||||
|
<p class="margin-top-0">View all members</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="margin-top-0">No access</p>
|
||||||
|
{% endif %}
|
|
@ -4,6 +4,11 @@
|
||||||
<div class="usa-modal__main">
|
<div class="usa-modal__main">
|
||||||
<h2 class="usa-modal__heading" id="modal-1-heading">
|
<h2 class="usa-modal__heading" id="modal-1-heading">
|
||||||
{{ modal_heading }}
|
{{ modal_heading }}
|
||||||
|
{%if domain_name_modal is not None %}
|
||||||
|
<span class="domain-name-wrap">
|
||||||
|
{{ domain_name_modal }}
|
||||||
|
</span>
|
||||||
|
{%endif%}
|
||||||
{% if heading_value is not None %}
|
{% if heading_value is not None %}
|
||||||
{# Add a breakpoint #}
|
{# Add a breakpoint #}
|
||||||
<div aria-hidden="true"></div>
|
<div aria-hidden="true"></div>
|
||||||
|
|
|
@ -8,7 +8,6 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if step == Step.REQUESTING_ENTITY %}
|
{% if step == Step.REQUESTING_ENTITY %}
|
||||||
|
|
||||||
{% if domain_request.organization_name %}
|
{% if domain_request.organization_name %}
|
||||||
{% with title=form_titles|get_item:step value=domain_request %}
|
{% with title=form_titles|get_item:step value=domain_request %}
|
||||||
{% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=is_editable edit_link=domain_request_url address='true' %}
|
{% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=is_editable edit_link=domain_request_url address='true' %}
|
||||||
|
@ -54,32 +53,8 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if step == Step.ADDITIONAL_DETAILS %}
|
{% if step == Step.ADDITIONAL_DETAILS %}
|
||||||
{% with title=form_titles|get_item:step %}
|
{% with title=form_titles|get_item:step value=domain_request.anything_else|default:"<span class='text-bold text-secondary-dark'>Incomplete</span>"|safe %}
|
||||||
{% if domain_request.has_additional_details %}
|
{% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=is_editable edit_link=domain_request_url %}
|
||||||
{% include "includes/summary_item.html" with title="Additional Details" value=" " heading_level=heading_level editable=is_editable edit_link=domain_request_url %}
|
|
||||||
<h3 class="register-form-review-header">CISA Regional Representative</h3>
|
|
||||||
<ul class="usa-list usa-list--unstyled margin-top-0">
|
|
||||||
{% if domain_request.cisa_representative_first_name %}
|
|
||||||
<li>{{domain_request.cisa_representative_first_name}} {{domain_request.cisa_representative_last_name}}</li>
|
|
||||||
{% if domain_request.cisa_representative_email %}
|
|
||||||
<li>{{domain_request.cisa_representative_email}}</li>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
No
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3 class="register-form-review-header">Anything else</h3>
|
|
||||||
<ul class="usa-list usa-list--unstyled margin-top-0">
|
|
||||||
{% if domain_request.anything_else %}
|
|
||||||
{{domain_request.anything_else}}
|
|
||||||
{% else %}
|
|
||||||
No
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
{% else %}
|
|
||||||
{% include "includes/summary_item.html" with title="Additional Details" value="<span class='text-bold text-secondary-dark'>Incomplete</span>"|safe heading_level=heading_level editable=is_editable edit_link=domain_request_url %}
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
|
@ -24,7 +24,11 @@
|
||||||
{% if sub_header_text %}
|
{% if sub_header_text %}
|
||||||
<h4 class="register-form-review-header">{{ sub_header_text }}</h4>
|
<h4 class="register-form-review-header">{{ sub_header_text }}</h4>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if address %}
|
{% if permissions %}
|
||||||
|
{% include "includes/member_permissions.html" with permissions=value %}
|
||||||
|
{% elif domain_mgmt %}
|
||||||
|
{% include "includes/member_domain_management.html" with domain_count=value %}
|
||||||
|
{% elif address %}
|
||||||
{% include "includes/organization_address.html" with organization=value %}
|
{% include "includes/organization_address.html" with organization=value %}
|
||||||
{% elif contact %}
|
{% elif contact %}
|
||||||
{% if list %}
|
{% if list %}
|
||||||
|
@ -122,9 +126,9 @@
|
||||||
class="usa-link usa-link--icon font-sans-sm line-height-sans-5"
|
class="usa-link usa-link--icon font-sans-sm line-height-sans-5"
|
||||||
>
|
>
|
||||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||||
<use xlink:href="{% static 'img/sprite.svg' %}#edit"></use>
|
<use xlink:href="{% static 'img/sprite.svg' %}#{% if manage_button %}settings{% elif view_button %}visibility{% else %}edit{% endif %}"></use>
|
||||||
</svg>
|
</svg>
|
||||||
Edit<span class="sr-only"> {{ title }}</span>
|
{% if manage_button %}Manage{% elif view_button %}View{% else %}Edit{% endif %}<span class="sr-only"> {{ title }}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
{% extends 'domain_request_form.html' %}
|
||||||
|
{% load static field_helpers %}
|
||||||
|
|
||||||
|
{% block form_required_fields_help_text %}
|
||||||
|
{% include "includes/required_fields.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block form_fields %}
|
||||||
|
|
||||||
|
<fieldset class="usa-fieldset margin-top-2">
|
||||||
|
<legend>
|
||||||
|
<h2>Is there anything else you’d like us to know about your domain request?</h2>
|
||||||
|
</legend>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="margin-top-3" id="anything-else">
|
||||||
|
<p>Provide details below. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></p>
|
||||||
|
{% with attr_maxlength=2000 add_label_class="usa-sr-only" %}
|
||||||
|
{% input_with_errors forms.0.anything_else %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
137
src/registrar/templates/portfolio_member.html
Normal file
137
src/registrar/templates/portfolio_member.html
Normal file
|
@ -0,0 +1,137 @@
|
||||||
|
{% extends 'portfolio_base.html' %}
|
||||||
|
{% load static field_helpers%}
|
||||||
|
|
||||||
|
{% block title %}Organization member {% endblock %}
|
||||||
|
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block portfolio_content %}
|
||||||
|
<div id="main-content">
|
||||||
|
|
||||||
|
{% url 'members' as url %}
|
||||||
|
<nav class="usa-breadcrumb padding-top-0 margin-bottom-3" aria-label="Portfolio member breadcrumb">
|
||||||
|
<ol class="usa-breadcrumb__list">
|
||||||
|
<li class="usa-breadcrumb__list-item">
|
||||||
|
<a href="{{ url }}" class="usa-breadcrumb__link"><span>Members</span></a>
|
||||||
|
</li>
|
||||||
|
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
|
||||||
|
<span>Manage member</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
|
||||||
|
<h1 class="margin-bottom-3">Manage member</h1>
|
||||||
|
|
||||||
|
<div class="tablet:display-flex tablet:flex-justify">
|
||||||
|
<h2 class="margin-top-0 margin-bottom-3 break-word">
|
||||||
|
{% if member %}
|
||||||
|
{{ member.email }}
|
||||||
|
{% elif portfolio_invitation %}
|
||||||
|
{{ portfolio_invitation.email }}
|
||||||
|
{% endif %}
|
||||||
|
</h2>
|
||||||
|
{% if has_edit_members_portfolio_permission %}
|
||||||
|
{% if member %}
|
||||||
|
<a
|
||||||
|
role="button"
|
||||||
|
href="#"
|
||||||
|
class="display-block usa-button text-secondary usa-button--unstyled text-no-underline margin-bottom-3 line-height-sans-5 visible-mobile-flex"
|
||||||
|
>
|
||||||
|
Remove member
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a
|
||||||
|
role="button"
|
||||||
|
href="#"
|
||||||
|
class="display-block usa-button text-secondary usa-button--unstyled text-no-underline margin-bottom-3 line-height-sans-5 visible-mobile-flex"
|
||||||
|
>
|
||||||
|
Cancel invitation
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="usa-accordion usa-accordion--more-actions hidden-mobile-flex">
|
||||||
|
<div class="usa-accordion__heading">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="usa-button usa-button--unstyled usa-button--with-icon usa-accordion__button usa-button--more-actions"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="more-actions"
|
||||||
|
>
|
||||||
|
<svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24">
|
||||||
|
<use xlink:href="/public/img/sprite.svg#more_vert"></use>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="more-actions" class="usa-accordion__content usa-prose shadow-1 left-auto right-0" hidden>
|
||||||
|
<h2>More options</h2>
|
||||||
|
{% if member %}
|
||||||
|
<a
|
||||||
|
role="button"
|
||||||
|
href="#"
|
||||||
|
class="usa-button text-secondary usa-button--unstyled text-no-underline margin-top-2 line-height-sans-5"
|
||||||
|
>
|
||||||
|
Remove member
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a
|
||||||
|
role="button"
|
||||||
|
href="#"
|
||||||
|
class="usa-button text-secondary usa-button--unstyled text-no-underline margin-top-2 line-height-sans-5"
|
||||||
|
>
|
||||||
|
Cancel invitation
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<address>
|
||||||
|
<strong class="text-primary-dark">Last active:</strong>
|
||||||
|
{% if member and member.last_login %}
|
||||||
|
{{ member.last_login }}
|
||||||
|
{% elif portfolio_invitation %}
|
||||||
|
Invited
|
||||||
|
{% else %}
|
||||||
|
⎯
|
||||||
|
{% endif %}
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<strong class="text-primary-dark">Full name:</strong>
|
||||||
|
{% if member %}
|
||||||
|
{% if member.first_name or member.last_name %}
|
||||||
|
{{ member.get_formatted_name }}
|
||||||
|
{% else %}
|
||||||
|
⎯
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
⎯
|
||||||
|
{% endif %}
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<strong class="text-primary-dark">Title or organization role:</strong>
|
||||||
|
{% if member and member.title %}
|
||||||
|
{{ member.title }}
|
||||||
|
{% else %}
|
||||||
|
⎯
|
||||||
|
{% endif %}
|
||||||
|
</address>
|
||||||
|
|
||||||
|
{% if portfolio_permission %}
|
||||||
|
{% include "includes/summary_item.html" with title='Member access and permissions' permissions=True value=portfolio_permission edit_link=edit_url editable=has_edit_members_portfolio_permission %}
|
||||||
|
{% elif portfolio_invitation %}
|
||||||
|
{% include "includes/summary_item.html" with title='Member access and permissions' permissions=True value=portfolio_invitation edit_link=edit_url editable=has_edit_members_portfolio_permission %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% comment %}view_button is passed below as true in all cases. This is because manage_button logic will trump view_button logic; ie. if manage_button is true, view_button will never be looked at{% endcomment %}
|
||||||
|
{% if portfolio_permission %}
|
||||||
|
{% include "includes/summary_item.html" with title='Domain management' domain_mgmt=True value=portfolio_permission.get_managed_domains_count edit_link='#' editable=True manage_button=has_edit_members_portfolio_permission view_button=True %}
|
||||||
|
{% elif portfolio_invitation %}
|
||||||
|
{% include "includes/summary_item.html" with title='Domain management' domain_mgmt=True value=portfolio_invitation.get_managed_domains_count edit_link='#' editable=True manage_button=has_edit_members_portfolio_permission view_button=True %}
|
||||||
|
{% else %}
|
||||||
|
{% include "includes/summary_item.html" with title='Domain management' domain_mgmt=True value=0 edit_link='#' editable=True manage_button=has_edit_members_portfolio_permission view_button=True %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
42
src/registrar/templates/portfolio_member_permissions.html
Normal file
42
src/registrar/templates/portfolio_member_permissions.html
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
{% extends 'portfolio_base.html' %}
|
||||||
|
{% load static field_helpers%}
|
||||||
|
|
||||||
|
{% block title %}Organization member {% endblock %}
|
||||||
|
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block portfolio_content %}
|
||||||
|
<div class="grid-row grid-gap">
|
||||||
|
<div class="tablet:grid-col-9" id="main-content">
|
||||||
|
|
||||||
|
{% block messages %}
|
||||||
|
{% include "includes/form_messages.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
<h1>Manage member</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{% if member %}
|
||||||
|
{{ member.email }}
|
||||||
|
{% elif invitation %}
|
||||||
|
{{ invitation.email }}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<form class="usa-form usa-form--large" method="post" novalidate>
|
||||||
|
{% csrf_token %}
|
||||||
|
{% input_with_errors form.roles %}
|
||||||
|
{% input_with_errors form.additional_permissions %}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="usa-button"
|
||||||
|
>Submit</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -18,6 +18,7 @@
|
||||||
<div class="mobile:grid-col-12 tablet:grid-col-6">
|
<div class="mobile:grid-col-12 tablet:grid-col-6">
|
||||||
<h1 id="members-header">Members</h1>
|
<h1 id="members-header">Members</h1>
|
||||||
</div>
|
</div>
|
||||||
|
{% if has_edit_members_portfolio_permission %}
|
||||||
<div class="mobile:grid-col-12 tablet:grid-col-6">
|
<div class="mobile:grid-col-12 tablet:grid-col-6">
|
||||||
<p class="float-right-tablet tablet:margin-y-0">
|
<p class="float-right-tablet tablet:margin-y-0">
|
||||||
<a href="#" class="usa-button"
|
<a href="#" class="usa-button"
|
||||||
|
@ -26,6 +27,7 @@
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% include "includes/members_table.html" with portfolio=portfolio %}
|
{% include "includes/members_table.html" with portfolio=portfolio %}
|
||||||
|
|
|
@ -45,7 +45,7 @@
|
||||||
{% input_with_errors form.address_line2 %}
|
{% input_with_errors form.address_line2 %}
|
||||||
{% input_with_errors form.city %}
|
{% input_with_errors form.city %}
|
||||||
{% input_with_errors form.state_territory %}
|
{% input_with_errors form.state_territory %}
|
||||||
{% with add_class="usa-input--small" sublabel_text="Enter a zip code in the required format, like 12345 or 12345-6789." %}
|
{% with add_class="usa-input--small" sublabel_text="Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789." %}
|
||||||
{% input_with_errors form.zipcode %}
|
{% input_with_errors form.zipcode %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
<button type="submit" class="usa-button">
|
<button type="submit" class="usa-button">
|
||||||
|
|
|
@ -246,9 +246,7 @@ def is_members_subpage(path):
|
||||||
"""Checks if the given page is a subpage of members.
|
"""Checks if the given page is a subpage of members.
|
||||||
Takes a path name, like '/organization/'."""
|
Takes a path name, like '/organization/'."""
|
||||||
# Since our pages aren't unified under a common path, we need this approach for now.
|
# Since our pages aren't unified under a common path, we need this approach for now.
|
||||||
url_names = [
|
url_names = ["members", "member", "member-permissions", "invitedmember", "invitedmember-permissions"]
|
||||||
"members",
|
|
||||||
]
|
|
||||||
return get_url_name(path) in url_names
|
return get_url_name(path) in url_names
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -654,7 +654,7 @@ class TestDomainInformationAdmin(TestCase):
|
||||||
self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields)
|
self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields)
|
||||||
|
|
||||||
# Test for the copy link
|
# Test for the copy link
|
||||||
self.assertContains(response, "button--clipboard", count=3)
|
self.assertContains(response, "copy-to-clipboard", count=3)
|
||||||
|
|
||||||
# cleanup this test
|
# cleanup this test
|
||||||
domain_info.delete()
|
domain_info.delete()
|
||||||
|
|
|
@ -535,7 +535,7 @@ class TestDomainAdminWithClient(TestCase):
|
||||||
self.assertContains(response, "Testy Tester")
|
self.assertContains(response, "Testy Tester")
|
||||||
|
|
||||||
# Test for the copy link
|
# Test for the copy link
|
||||||
self.assertContains(response, "button--clipboard")
|
self.assertContains(response, "copy-to-clipboard")
|
||||||
|
|
||||||
# cleanup from this test
|
# cleanup from this test
|
||||||
domain.delete()
|
domain.delete()
|
||||||
|
|
|
@ -595,7 +595,12 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
def transition_state_and_send_email(
|
def transition_state_and_send_email(
|
||||||
self, domain_request, status, rejection_reason=None, action_needed_reason=None, action_needed_reason_email=None
|
self,
|
||||||
|
domain_request,
|
||||||
|
status,
|
||||||
|
rejection_reason=None,
|
||||||
|
action_needed_reason=None,
|
||||||
|
action_needed_reason_email=None,
|
||||||
):
|
):
|
||||||
"""Helper method for the email test cases."""
|
"""Helper method for the email test cases."""
|
||||||
|
|
||||||
|
@ -687,6 +692,10 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
self.assert_email_is_accurate("ORGANIZATION ALREADY HAS A .GOV DOMAIN", 0, EMAIL, bcc_email_address=BCC_EMAIL)
|
self.assert_email_is_accurate("ORGANIZATION ALREADY HAS A .GOV DOMAIN", 0, EMAIL, bcc_email_address=BCC_EMAIL)
|
||||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
|
||||||
|
|
||||||
|
# We use javascript to reset the content of this. It is only automatically set
|
||||||
|
# if the email itself is somehow None.
|
||||||
|
self._reset_action_needed_email(domain_request)
|
||||||
|
|
||||||
# Test the email sent out for bad_name
|
# Test the email sent out for bad_name
|
||||||
bad_name = DomainRequest.ActionNeededReasons.BAD_NAME
|
bad_name = DomainRequest.ActionNeededReasons.BAD_NAME
|
||||||
self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=bad_name)
|
self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=bad_name)
|
||||||
|
@ -694,6 +703,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
"DOMAIN NAME DOES NOT MEET .GOV REQUIREMENTS", 1, EMAIL, bcc_email_address=BCC_EMAIL
|
"DOMAIN NAME DOES NOT MEET .GOV REQUIREMENTS", 1, EMAIL, bcc_email_address=BCC_EMAIL
|
||||||
)
|
)
|
||||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
|
||||||
|
self._reset_action_needed_email(domain_request)
|
||||||
|
|
||||||
# Test the email sent out for eligibility_unclear
|
# Test the email sent out for eligibility_unclear
|
||||||
eligibility_unclear = DomainRequest.ActionNeededReasons.ELIGIBILITY_UNCLEAR
|
eligibility_unclear = DomainRequest.ActionNeededReasons.ELIGIBILITY_UNCLEAR
|
||||||
|
@ -702,6 +712,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
"ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS", 2, EMAIL, bcc_email_address=BCC_EMAIL
|
"ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS", 2, EMAIL, bcc_email_address=BCC_EMAIL
|
||||||
)
|
)
|
||||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
||||||
|
self._reset_action_needed_email(domain_request)
|
||||||
|
|
||||||
# Test that a custom email is sent out for questionable_so
|
# Test that a custom email is sent out for questionable_so
|
||||||
questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL
|
questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL
|
||||||
|
@ -710,6 +721,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
"SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", 3, _creator.email, bcc_email_address=BCC_EMAIL
|
"SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", 3, _creator.email, bcc_email_address=BCC_EMAIL
|
||||||
)
|
)
|
||||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 4)
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 4)
|
||||||
|
self._reset_action_needed_email(domain_request)
|
||||||
|
|
||||||
# Assert that no other emails are sent on OTHER
|
# Assert that no other emails are sent on OTHER
|
||||||
other = DomainRequest.ActionNeededReasons.OTHER
|
other = DomainRequest.ActionNeededReasons.OTHER
|
||||||
|
@ -717,6 +729,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
|
|
||||||
# Should be unchanged from before
|
# Should be unchanged from before
|
||||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 4)
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 4)
|
||||||
|
self._reset_action_needed_email(domain_request)
|
||||||
|
|
||||||
# Tests if an analyst can override existing email content
|
# Tests if an analyst can override existing email content
|
||||||
questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL
|
questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL
|
||||||
|
@ -730,6 +743,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
domain_request.refresh_from_db()
|
domain_request.refresh_from_db()
|
||||||
self.assert_email_is_accurate("custom email content", 4, _creator.email, bcc_email_address=BCC_EMAIL)
|
self.assert_email_is_accurate("custom email content", 4, _creator.email, bcc_email_address=BCC_EMAIL)
|
||||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 5)
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 5)
|
||||||
|
self._reset_action_needed_email(domain_request)
|
||||||
|
|
||||||
# Tests if a new email gets sent when just the email is changed.
|
# Tests if a new email gets sent when just the email is changed.
|
||||||
# An email should NOT be sent out if we just modify the email content.
|
# An email should NOT be sent out if we just modify the email content.
|
||||||
|
@ -741,6 +755,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 5)
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 5)
|
||||||
|
self._reset_action_needed_email(domain_request)
|
||||||
|
|
||||||
# Set the request back to in review
|
# Set the request back to in review
|
||||||
domain_request.in_review()
|
domain_request.in_review()
|
||||||
|
@ -757,55 +772,53 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
)
|
)
|
||||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 6)
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), 6)
|
||||||
|
|
||||||
# def test_action_needed_sends_reason_email_prod_bcc(self):
|
def _reset_action_needed_email(self, domain_request):
|
||||||
# """When an action needed reason is set, an email is sent out and help@get.gov
|
"""Sets the given action needed email back to none"""
|
||||||
# is BCC'd in production"""
|
domain_request.action_needed_reason_email = None
|
||||||
# # Ensure there is no user with this email
|
domain_request.save()
|
||||||
# EMAIL = "mayor@igorville.gov"
|
domain_request.refresh_from_db()
|
||||||
# BCC_EMAIL = settings.DEFAULT_FROM_EMAIL
|
|
||||||
# User.objects.filter(email=EMAIL).delete()
|
|
||||||
# in_review = DomainRequest.DomainRequestStatus.IN_REVIEW
|
|
||||||
# action_needed = DomainRequest.DomainRequestStatus.ACTION_NEEDED
|
|
||||||
|
|
||||||
# # Create a sample domain request
|
@override_settings(IS_PRODUCTION=True)
|
||||||
# domain_request = completed_domain_request(status=in_review)
|
@less_console_noise_decorator
|
||||||
|
def test_rejected_sends_reason_email_prod_bcc(self):
|
||||||
|
"""When a rejection reason is set, an email is sent out and help@get.gov
|
||||||
|
is BCC'd in production"""
|
||||||
|
# Create fake creator
|
||||||
|
EMAIL = "meoward.jones@igorville.gov"
|
||||||
|
|
||||||
# # Test the email sent out for already_has_domains
|
_creator = User.objects.create(
|
||||||
# already_has_domains = DomainRequest.ActionNeededReasons.ALREADY_HAS_DOMAINS
|
username="MrMeoward",
|
||||||
# self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=already_has_domains)
|
first_name="Meoward",
|
||||||
# self.assert_email_is_accurate("ORGANIZATION ALREADY HAS A .GOV DOMAIN", 0, EMAIL, bcc_email_address=BCC_EMAIL)
|
last_name="Jones",
|
||||||
# self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
|
email=EMAIL,
|
||||||
|
phone="(555) 123 12345",
|
||||||
|
title="Treat inspector",
|
||||||
|
)
|
||||||
|
|
||||||
# # Test the email sent out for bad_name
|
BCC_EMAIL = settings.DEFAULT_FROM_EMAIL
|
||||||
# bad_name = DomainRequest.ActionNeededReasons.BAD_NAME
|
in_review = DomainRequest.DomainRequestStatus.IN_REVIEW
|
||||||
# self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=bad_name)
|
rejected = DomainRequest.DomainRequestStatus.REJECTED
|
||||||
# self.assert_email_is_accurate(
|
|
||||||
# "DOMAIN NAME DOES NOT MEET .GOV REQUIREMENTS", 1, EMAIL, bcc_email_address=BCC_EMAIL
|
|
||||||
# )
|
|
||||||
# self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
|
|
||||||
|
|
||||||
# # Test the email sent out for eligibility_unclear
|
# Create a sample domain request
|
||||||
# eligibility_unclear = DomainRequest.ActionNeededReasons.ELIGIBILITY_UNCLEAR
|
domain_request = completed_domain_request(status=in_review, user=_creator)
|
||||||
# self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=eligibility_unclear)
|
|
||||||
# self.assert_email_is_accurate(
|
|
||||||
# "ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS", 2, EMAIL, bcc_email_address=BCC_EMAIL
|
|
||||||
# )
|
|
||||||
# self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
|
||||||
|
|
||||||
# # Test the email sent out for questionable_so
|
expected_emails = {
|
||||||
# questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL
|
DomainRequest.RejectionReasons.DOMAIN_PURPOSE: "You didn’t provide enough information about how",
|
||||||
# self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=questionable_so)
|
DomainRequest.RejectionReasons.REQUESTOR_NOT_ELIGIBLE: "You must be a government employee, or be",
|
||||||
# self.assert_email_is_accurate(
|
DomainRequest.RejectionReasons.ORG_HAS_DOMAIN: "practice is to approve one domain",
|
||||||
# "SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", 3, EMAIL, bcc_email_address=BCC_EMAIL
|
DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED: "we could not verify the organizational",
|
||||||
# )
|
DomainRequest.RejectionReasons.ORG_NOT_ELIGIBLE: ".Gov domains are only available to official U.S.-based",
|
||||||
# self.assertEqual(len(self.mock_client.EMAILS_SENT), 4)
|
DomainRequest.RejectionReasons.NAMING_REQUIREMENTS: "does not meet our naming requirements",
|
||||||
|
DomainRequest.RejectionReasons.OTHER: "YOU CAN SUBMIT A NEW REQUEST",
|
||||||
# # Assert that no other emails are sent on OTHER
|
}
|
||||||
# other = DomainRequest.ActionNeededReasons.OTHER
|
for i, (reason, email_content) in enumerate(expected_emails.items()):
|
||||||
# self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=other)
|
with self.subTest(reason=reason):
|
||||||
|
self.transition_state_and_send_email(domain_request, status=rejected, rejection_reason=reason)
|
||||||
# # Should be unchanged from before
|
self.assert_email_is_accurate(email_content, i, EMAIL, bcc_email_address=BCC_EMAIL)
|
||||||
# self.assertEqual(len(self.mock_client.EMAILS_SENT), 4)
|
self.assertEqual(len(self.mock_client.EMAILS_SENT), i + 1)
|
||||||
|
domain_request.rejection_reason_email = None
|
||||||
|
domain_request.save()
|
||||||
|
domain_request.refresh_from_db()
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
def test_save_model_sends_submitted_email(self):
|
def test_save_model_sends_submitted_email(self):
|
||||||
|
@ -1034,7 +1047,9 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
|
|
||||||
# Reject for reason REQUESTOR and test email including dynamic organization name
|
# Reject for reason REQUESTOR and test email including dynamic organization name
|
||||||
self.transition_state_and_send_email(
|
self.transition_state_and_send_email(
|
||||||
domain_request, DomainRequest.DomainRequestStatus.REJECTED, DomainRequest.RejectionReasons.REQUESTOR
|
domain_request,
|
||||||
|
DomainRequest.DomainRequestStatus.REJECTED,
|
||||||
|
DomainRequest.RejectionReasons.REQUESTOR_NOT_ELIGIBLE,
|
||||||
)
|
)
|
||||||
self.assert_email_is_accurate(
|
self.assert_email_is_accurate(
|
||||||
"Your domain request was rejected because we don’t believe you’re eligible to request a \n.gov "
|
"Your domain request was rejected because we don’t believe you’re eligible to request a \n.gov "
|
||||||
|
@ -1072,7 +1087,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
self.transition_state_and_send_email(
|
self.transition_state_and_send_email(
|
||||||
domain_request,
|
domain_request,
|
||||||
DomainRequest.DomainRequestStatus.REJECTED,
|
DomainRequest.DomainRequestStatus.REJECTED,
|
||||||
DomainRequest.RejectionReasons.SECOND_DOMAIN_REASONING,
|
DomainRequest.RejectionReasons.ORG_HAS_DOMAIN,
|
||||||
)
|
)
|
||||||
self.assert_email_is_accurate(
|
self.assert_email_is_accurate(
|
||||||
"Your domain request was rejected because Testorg has a .gov domain.", 0, _creator.email
|
"Your domain request was rejected because Testorg has a .gov domain.", 0, _creator.email
|
||||||
|
@ -1108,7 +1123,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
self.transition_state_and_send_email(
|
self.transition_state_and_send_email(
|
||||||
domain_request,
|
domain_request,
|
||||||
DomainRequest.DomainRequestStatus.REJECTED,
|
DomainRequest.DomainRequestStatus.REJECTED,
|
||||||
DomainRequest.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY,
|
DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED,
|
||||||
)
|
)
|
||||||
self.assert_email_is_accurate(
|
self.assert_email_is_accurate(
|
||||||
"Your domain request was rejected because we could not verify the organizational \n"
|
"Your domain request was rejected because we could not verify the organizational \n"
|
||||||
|
@ -1146,7 +1161,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
self.transition_state_and_send_email(
|
self.transition_state_and_send_email(
|
||||||
domain_request,
|
domain_request,
|
||||||
DomainRequest.DomainRequestStatus.REJECTED,
|
DomainRequest.DomainRequestStatus.REJECTED,
|
||||||
DomainRequest.RejectionReasons.ORGANIZATION_ELIGIBILITY,
|
DomainRequest.RejectionReasons.ORG_NOT_ELIGIBLE,
|
||||||
)
|
)
|
||||||
self.assert_email_is_accurate(
|
self.assert_email_is_accurate(
|
||||||
"Your domain request was rejected because we determined that Testorg is not \neligible for "
|
"Your domain request was rejected because we determined that Testorg is not \neligible for "
|
||||||
|
@ -1275,7 +1290,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
stack.enter_context(patch.object(messages, "error"))
|
stack.enter_context(patch.object(messages, "error"))
|
||||||
stack.enter_context(patch.object(messages, "warning"))
|
stack.enter_context(patch.object(messages, "warning"))
|
||||||
domain_request.status = DomainRequest.DomainRequestStatus.REJECTED
|
domain_request.status = DomainRequest.DomainRequestStatus.REJECTED
|
||||||
domain_request.rejection_reason = DomainRequest.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY
|
domain_request.rejection_reason = DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED
|
||||||
|
|
||||||
self.admin.save_model(request, domain_request, None, True)
|
self.admin.save_model(request, domain_request, None, True)
|
||||||
|
|
||||||
|
@ -1511,7 +1526,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields)
|
self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields)
|
||||||
|
|
||||||
# Test for the copy link
|
# Test for the copy link
|
||||||
self.assertContains(response, "button--clipboard", count=4)
|
self.assertContains(response, "copy-to-clipboard", count=4)
|
||||||
|
|
||||||
# Test that Creator counts display properly
|
# Test that Creator counts display properly
|
||||||
self.assertNotContains(response, "Approved domains")
|
self.assertNotContains(response, "Approved domains")
|
||||||
|
@ -1621,6 +1636,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
"updated_at",
|
"updated_at",
|
||||||
"status",
|
"status",
|
||||||
"rejection_reason",
|
"rejection_reason",
|
||||||
|
"rejection_reason_email",
|
||||||
"action_needed_reason",
|
"action_needed_reason",
|
||||||
"action_needed_reason_email",
|
"action_needed_reason_email",
|
||||||
"federal_agency",
|
"federal_agency",
|
||||||
|
@ -1840,12 +1856,64 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
self.trigger_saving_approved_to_another_state(
|
self.trigger_saving_approved_to_another_state(
|
||||||
False,
|
False,
|
||||||
DomainRequest.DomainRequestStatus.REJECTED,
|
DomainRequest.DomainRequestStatus.REJECTED,
|
||||||
DomainRequest.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY,
|
DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_side_effects_when_saving_approved_to_ineligible(self):
|
def test_side_effects_when_saving_approved_to_ineligible(self):
|
||||||
self.trigger_saving_approved_to_another_state(False, DomainRequest.DomainRequestStatus.INELIGIBLE)
|
self.trigger_saving_approved_to_another_state(False, DomainRequest.DomainRequestStatus.INELIGIBLE)
|
||||||
|
|
||||||
|
@less_console_noise
|
||||||
|
def test_error_when_saving_to_approved_and_domain_exists(self):
|
||||||
|
"""Redundant admin check on model transition not allowed."""
|
||||||
|
Domain.objects.create(name="wabbitseason.gov")
|
||||||
|
|
||||||
|
new_request = completed_domain_request(
|
||||||
|
status=DomainRequest.DomainRequestStatus.SUBMITTED, name="wabbitseason.gov"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a request object with a superuser
|
||||||
|
request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(new_request.pk))
|
||||||
|
request.user = self.superuser
|
||||||
|
|
||||||
|
request.session = {}
|
||||||
|
|
||||||
|
# Use ExitStack to combine patch contexts
|
||||||
|
with ExitStack() as stack:
|
||||||
|
# Patch django.contrib.messages.error
|
||||||
|
stack.enter_context(patch.object(messages, "error"))
|
||||||
|
|
||||||
|
new_request.status = DomainRequest.DomainRequestStatus.APPROVED
|
||||||
|
|
||||||
|
self.admin.save_model(request, new_request, None, True)
|
||||||
|
|
||||||
|
messages.error.assert_called_once_with(
|
||||||
|
request,
|
||||||
|
"Cannot approve. Requested domain is already in use.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@less_console_noise
|
||||||
|
def test_no_error_when_saving_to_approved_and_domain_exists(self):
|
||||||
|
"""The negative of the redundant admin check on model transition not allowed."""
|
||||||
|
new_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.SUBMITTED)
|
||||||
|
|
||||||
|
# Create a request object with a superuser
|
||||||
|
request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(new_request.pk))
|
||||||
|
request.user = self.superuser
|
||||||
|
|
||||||
|
request.session = {}
|
||||||
|
|
||||||
|
# Use ExitStack to combine patch contexts
|
||||||
|
with ExitStack() as stack:
|
||||||
|
# Patch Domain.is_active and django.contrib.messages.error simultaneously
|
||||||
|
stack.enter_context(patch.object(messages, "error"))
|
||||||
|
|
||||||
|
new_request.status = DomainRequest.DomainRequestStatus.APPROVED
|
||||||
|
|
||||||
|
self.admin.save_model(request, new_request, None, True)
|
||||||
|
|
||||||
|
# Assert that the error message was never called
|
||||||
|
messages.error.assert_not_called()
|
||||||
|
|
||||||
def test_has_correct_filters(self):
|
def test_has_correct_filters(self):
|
||||||
"""
|
"""
|
||||||
This test verifies that DomainRequestAdmin has the correct filters set up.
|
This test verifies that DomainRequestAdmin has the correct filters set up.
|
||||||
|
|
|
@ -143,8 +143,8 @@ class GetActionNeededEmailForUserJsonTest(TestCase):
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
data = response.json()
|
data = response.json()
|
||||||
self.assertIn("action_needed_email", data)
|
self.assertIn("email", data)
|
||||||
self.assertIn("ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS", data["action_needed_email"])
|
self.assertIn("ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS", data["email"])
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
def test_get_action_needed_email_for_user_json_analyst(self):
|
def test_get_action_needed_email_for_user_json_analyst(self):
|
||||||
|
@ -160,8 +160,8 @@ class GetActionNeededEmailForUserJsonTest(TestCase):
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
data = response.json()
|
data = response.json()
|
||||||
self.assertIn("action_needed_email", data)
|
self.assertIn("email", data)
|
||||||
self.assertIn("SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", data["action_needed_email"])
|
self.assertIn("SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", data["email"])
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
def test_get_action_needed_email_for_user_json_regular(self):
|
def test_get_action_needed_email_for_user_json_regular(self):
|
||||||
|
@ -176,3 +176,71 @@ class GetActionNeededEmailForUserJsonTest(TestCase):
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
|
||||||
|
class GetRejectionEmailForUserJsonTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = Client()
|
||||||
|
self.superuser = create_superuser()
|
||||||
|
self.analyst_user = create_user()
|
||||||
|
self.agency = FederalAgency.objects.create(agency="Test Agency")
|
||||||
|
self.domain_request = completed_domain_request(
|
||||||
|
federal_agency=self.agency,
|
||||||
|
name="test.gov",
|
||||||
|
status=DomainRequest.DomainRequestStatus.REJECTED,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.api_url = reverse("get-rejection-email-for-user-json")
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
DomainRequest.objects.all().delete()
|
||||||
|
User.objects.all().delete()
|
||||||
|
FederalAgency.objects.all().delete()
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_get_rejected_email_for_user_json_superuser(self):
|
||||||
|
"""Test that a superuser can fetch the action needed email."""
|
||||||
|
self.client.force_login(self.superuser)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
self.api_url,
|
||||||
|
{
|
||||||
|
"reason": DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED,
|
||||||
|
"domain_request_id": self.domain_request.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json()
|
||||||
|
self.assertIn("email", data)
|
||||||
|
self.assertIn("we could not verify the organizational", data["email"])
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_get_rejected_email_for_user_json_analyst(self):
|
||||||
|
"""Test that an analyst can fetch the action needed email."""
|
||||||
|
self.client.force_login(self.analyst_user)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
self.api_url,
|
||||||
|
{
|
||||||
|
"reason": DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED,
|
||||||
|
"domain_request_id": self.domain_request.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json()
|
||||||
|
self.assertIn("email", data)
|
||||||
|
self.assertIn("we could not verify the organizational", data["email"])
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_get_rejected_email_for_user_json_regular(self):
|
||||||
|
"""Test that a regular user receives a 403 with an error message."""
|
||||||
|
p = "password"
|
||||||
|
self.client.login(username="testuser", password=p)
|
||||||
|
response = self.client.get(
|
||||||
|
self.api_url,
|
||||||
|
{
|
||||||
|
"reason": DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED,
|
||||||
|
"domain_request_id": self.domain_request.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
|
@ -33,7 +33,7 @@ class TestFormValidation(MockEppLib):
|
||||||
form = OrganizationContactForm(data={"zipcode": "nah"})
|
form = OrganizationContactForm(data={"zipcode": "nah"})
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
form.errors["zipcode"],
|
form.errors["zipcode"],
|
||||||
["Enter a zip code in the form of 12345 or 12345-6789."],
|
["Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789."],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_org_contact_zip_valid(self):
|
def test_org_contact_zip_valid(self):
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
from django.forms import ValidationError
|
from django.forms import ValidationError
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.db.utils import IntegrityError
|
|
||||||
from django.db import transaction
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.test import RequestFactory
|
from django.test import RequestFactory
|
||||||
|
@ -20,28 +18,24 @@ from registrar.models import (
|
||||||
UserPortfolioPermission,
|
UserPortfolioPermission,
|
||||||
AllowedEmail,
|
AllowedEmail,
|
||||||
)
|
)
|
||||||
|
|
||||||
import boto3_mocking
|
import boto3_mocking
|
||||||
from registrar.models.portfolio import Portfolio
|
from registrar.models.portfolio import Portfolio
|
||||||
from registrar.models.portfolio_invitation import PortfolioInvitation
|
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||||
from registrar.models.transition_domain import TransitionDomain
|
from registrar.models.transition_domain import TransitionDomain
|
||||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||||
from registrar.models.verified_by_staff import VerifiedByStaff # type: ignore
|
from registrar.models.verified_by_staff import VerifiedByStaff # type: ignore
|
||||||
from registrar.utility.constants import BranchChoices
|
|
||||||
|
|
||||||
from .common import (
|
from .common import (
|
||||||
MockSESClient,
|
MockSESClient,
|
||||||
less_console_noise,
|
|
||||||
completed_domain_request,
|
completed_domain_request,
|
||||||
set_domain_request_investigators,
|
|
||||||
create_test_user,
|
create_test_user,
|
||||||
)
|
)
|
||||||
from django_fsm import TransitionNotAllowed
|
|
||||||
from waffle.testutils import override_flag
|
from waffle.testutils import override_flag
|
||||||
|
|
||||||
from api.tests.common import less_console_noise_decorator
|
from api.tests.common import less_console_noise_decorator
|
||||||
|
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
@boto3_mocking.patching
|
@boto3_mocking.patching
|
||||||
class TestDomainRequest(TestCase):
|
class TestDomainRequest(TestCase):
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
|
@ -1059,6 +1053,8 @@ class TestPermissions(TestCase):
|
||||||
self.assertTrue(UserDomainRole.objects.get(user=user, domain=domain))
|
self.assertTrue(UserDomainRole.objects.get(user=user, domain=domain))
|
||||||
|
|
||||||
|
|
||||||
|
=======
|
||||||
|
>>>>>>> origin/main
|
||||||
class TestDomainInformation(TestCase):
|
class TestDomainInformation(TestCase):
|
||||||
"""Test the DomainInformation model, when approved or otherwise"""
|
"""Test the DomainInformation model, when approved or otherwise"""
|
||||||
|
|
||||||
|
@ -1176,12 +1172,15 @@ class TestPortfolioInvitations(TestCase):
|
||||||
self.invitation, _ = PortfolioInvitation.objects.get_or_create(
|
self.invitation, _ = PortfolioInvitation.objects.get_or_create(
|
||||||
email=self.email,
|
email=self.email,
|
||||||
portfolio=self.portfolio,
|
portfolio=self.portfolio,
|
||||||
portfolio_roles=[self.portfolio_role_base, self.portfolio_role_admin],
|
roles=[self.portfolio_role_base, self.portfolio_role_admin],
|
||||||
portfolio_additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
|
additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
|
||||||
)
|
)
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
super().tearDown()
|
super().tearDown()
|
||||||
|
DomainInvitation.objects.all().delete()
|
||||||
|
DomainInformation.objects.all().delete()
|
||||||
|
Domain.objects.all().delete()
|
||||||
UserPortfolioPermission.objects.all().delete()
|
UserPortfolioPermission.objects.all().delete()
|
||||||
Portfolio.objects.all().delete()
|
Portfolio.objects.all().delete()
|
||||||
PortfolioInvitation.objects.all().delete()
|
PortfolioInvitation.objects.all().delete()
|
||||||
|
@ -1233,8 +1232,8 @@ class TestPortfolioInvitations(TestCase):
|
||||||
PortfolioInvitation.objects.get_or_create(
|
PortfolioInvitation.objects.get_or_create(
|
||||||
email=self.email,
|
email=self.email,
|
||||||
portfolio=portfolio2,
|
portfolio=portfolio2,
|
||||||
portfolio_roles=[self.portfolio_role_base, self.portfolio_role_admin],
|
roles=[self.portfolio_role_base, self.portfolio_role_admin],
|
||||||
portfolio_additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
|
additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
|
||||||
)
|
)
|
||||||
with override_flag("multiple_portfolios", active=True):
|
with override_flag("multiple_portfolios", active=True):
|
||||||
self.user.check_portfolio_invitations_on_login()
|
self.user.check_portfolio_invitations_on_login()
|
||||||
|
@ -1257,8 +1256,8 @@ class TestPortfolioInvitations(TestCase):
|
||||||
PortfolioInvitation.objects.get_or_create(
|
PortfolioInvitation.objects.get_or_create(
|
||||||
email=self.email,
|
email=self.email,
|
||||||
portfolio=portfolio2,
|
portfolio=portfolio2,
|
||||||
portfolio_roles=[self.portfolio_role_base, self.portfolio_role_admin],
|
roles=[self.portfolio_role_base, self.portfolio_role_admin],
|
||||||
portfolio_additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
|
additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
|
||||||
)
|
)
|
||||||
self.user.check_portfolio_invitations_on_login()
|
self.user.check_portfolio_invitations_on_login()
|
||||||
self.user.refresh_from_db()
|
self.user.refresh_from_db()
|
||||||
|
@ -1269,6 +1268,52 @@ class TestPortfolioInvitations(TestCase):
|
||||||
updated_invitation2, _ = PortfolioInvitation.objects.get_or_create(email=self.email, portfolio=portfolio2)
|
updated_invitation2, _ = PortfolioInvitation.objects.get_or_create(email=self.email, portfolio=portfolio2)
|
||||||
self.assertEqual(updated_invitation2.status, PortfolioInvitation.PortfolioInvitationStatus.INVITED)
|
self.assertEqual(updated_invitation2.status, PortfolioInvitation.PortfolioInvitationStatus.INVITED)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_get_managed_domains_count(self):
|
||||||
|
"""Test that the correct number of domains, which are associated with the portfolio and
|
||||||
|
have invited the email of the portfolio invitation, are returned."""
|
||||||
|
# Add three domains, one which is in the portfolio and email is invited to,
|
||||||
|
# one which is in the portfolio and email is not invited to,
|
||||||
|
# and one which is email is invited to and not in the portfolio.
|
||||||
|
# Arrange
|
||||||
|
# domain_in_portfolio should not be included in the count
|
||||||
|
domain_in_portfolio, _ = Domain.objects.get_or_create(name="domain_in_portfolio.gov", state=Domain.State.READY)
|
||||||
|
DomainInformation.objects.get_or_create(creator=self.user, domain=domain_in_portfolio, portfolio=self.portfolio)
|
||||||
|
# domain_in_portfolio_and_invited should be included in the count
|
||||||
|
domain_in_portfolio_and_invited, _ = Domain.objects.get_or_create(
|
||||||
|
name="domain_in_portfolio_and_invited.gov", state=Domain.State.READY
|
||||||
|
)
|
||||||
|
DomainInformation.objects.get_or_create(
|
||||||
|
creator=self.user, domain=domain_in_portfolio_and_invited, portfolio=self.portfolio
|
||||||
|
)
|
||||||
|
DomainInvitation.objects.get_or_create(email=self.email, domain=domain_in_portfolio_and_invited)
|
||||||
|
# domain_invited should not be included in the count
|
||||||
|
domain_invited, _ = Domain.objects.get_or_create(name="domain_invited.gov", state=Domain.State.READY)
|
||||||
|
DomainInformation.objects.get_or_create(creator=self.user, domain=domain_invited)
|
||||||
|
DomainInvitation.objects.get_or_create(email=self.email, domain=domain_invited)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(self.invitation.get_managed_domains_count(), 1)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_get_portfolio_permissions(self):
|
||||||
|
"""Test that get_portfolio_permissions returns the expected list of permissions,
|
||||||
|
based on the roles and permissions assigned to the invitation."""
|
||||||
|
# Arrange
|
||||||
|
test_permission_list = set()
|
||||||
|
# add the arrays that are defined in UserPortfolioPermission for member and admin
|
||||||
|
test_permission_list.update(
|
||||||
|
UserPortfolioPermission.PORTFOLIO_ROLE_PERMISSIONS.get(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [])
|
||||||
|
)
|
||||||
|
test_permission_list.update(
|
||||||
|
UserPortfolioPermission.PORTFOLIO_ROLE_PERMISSIONS.get(UserPortfolioRoleChoices.ORGANIZATION_ADMIN, [])
|
||||||
|
)
|
||||||
|
# add the permissions that are added to the invitation as additional_permissions
|
||||||
|
test_permission_list.update([self.portfolio_permission_1, self.portfolio_permission_2])
|
||||||
|
perm_list = list(test_permission_list)
|
||||||
|
# Verify
|
||||||
|
self.assertEquals(self.invitation.get_portfolio_permissions(), perm_list)
|
||||||
|
|
||||||
|
|
||||||
class TestUserPortfolioPermission(TestCase):
|
class TestUserPortfolioPermission(TestCase):
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
|
@ -1338,6 +1383,40 @@ class TestUserPortfolioPermission(TestCase):
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_get_managed_domains_count(self):
|
||||||
|
"""Test that the correct number of managed domains associated with the portfolio
|
||||||
|
are returned."""
|
||||||
|
# Add three domains, one which is in the portfolio and managed by the user,
|
||||||
|
# one which is in the portfolio and not managed by the user,
|
||||||
|
# and one which is managed by the user and not in the portfolio.
|
||||||
|
# Arrange
|
||||||
|
portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California")
|
||||||
|
test_user = create_test_user()
|
||||||
|
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
|
||||||
|
portfolio=portfolio, user=test_user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
|
)
|
||||||
|
# domain_in_portfolio should not be included in the count
|
||||||
|
domain_in_portfolio, _ = Domain.objects.get_or_create(name="domain_in_portfolio.gov", state=Domain.State.READY)
|
||||||
|
DomainInformation.objects.get_or_create(creator=self.user, domain=domain_in_portfolio, portfolio=portfolio)
|
||||||
|
# domain_in_portfolio_and_managed should be included in the count
|
||||||
|
domain_in_portfolio_and_managed, _ = Domain.objects.get_or_create(
|
||||||
|
name="domain_in_portfolio_and_managed.gov", state=Domain.State.READY
|
||||||
|
)
|
||||||
|
DomainInformation.objects.get_or_create(
|
||||||
|
creator=self.user, domain=domain_in_portfolio_and_managed, portfolio=portfolio
|
||||||
|
)
|
||||||
|
UserDomainRole.objects.get_or_create(
|
||||||
|
user=test_user, domain=domain_in_portfolio_and_managed, role=UserDomainRole.Roles.MANAGER
|
||||||
|
)
|
||||||
|
# domain_managed should not be included in the count
|
||||||
|
domain_managed, _ = Domain.objects.get_or_create(name="domain_managed.gov", state=Domain.State.READY)
|
||||||
|
DomainInformation.objects.get_or_create(creator=self.user, domain=domain_managed)
|
||||||
|
UserDomainRole.objects.get_or_create(user=test_user, domain=domain_managed, role=UserDomainRole.Roles.MANAGER)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
self.assertEqual(portfolio_permission.get_managed_domains_count(), 1)
|
||||||
|
|
||||||
|
|
||||||
class TestUser(TestCase):
|
class TestUser(TestCase):
|
||||||
"""Test actions that occur on user login,
|
"""Test actions that occur on user login,
|
||||||
|
|
1047
src/registrar/tests/test_models_requests.py
Normal file
1047
src/registrar/tests/test_models_requests.py
Normal file
File diff suppressed because it is too large
Load diff
|
@ -6,7 +6,7 @@ from registrar.models import (
|
||||||
Domain,
|
Domain,
|
||||||
UserDomainRole,
|
UserDomainRole,
|
||||||
)
|
)
|
||||||
from registrar.models import Portfolio
|
from registrar.models import Portfolio, DraftDomain
|
||||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||||
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
||||||
from registrar.utility.csv_export import (
|
from registrar.utility.csv_export import (
|
||||||
|
@ -14,6 +14,7 @@ from registrar.utility.csv_export import (
|
||||||
DomainDataType,
|
DomainDataType,
|
||||||
DomainDataFederal,
|
DomainDataFederal,
|
||||||
DomainDataTypeUser,
|
DomainDataTypeUser,
|
||||||
|
DomainRequestsDataType,
|
||||||
DomainGrowth,
|
DomainGrowth,
|
||||||
DomainManaged,
|
DomainManaged,
|
||||||
DomainUnmanaged,
|
DomainUnmanaged,
|
||||||
|
@ -389,6 +390,77 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
|
||||||
|
|
||||||
return csv_content
|
return csv_content
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@override_flag("organization_feature", active=True)
|
||||||
|
@override_flag("organization_requests", active=True)
|
||||||
|
def test_domain_request_data_type_user_with_portfolio(self):
|
||||||
|
"""Tests DomainRequestsDataType export with portfolio permissions"""
|
||||||
|
|
||||||
|
# Create a portfolio and assign it to the user
|
||||||
|
portfolio = Portfolio.objects.create(creator=self.user, organization_name="Test Portfolio")
|
||||||
|
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(portfolio=portfolio, user=self.user)
|
||||||
|
|
||||||
|
# Create DraftDomain objects
|
||||||
|
dd_1 = DraftDomain.objects.create(name="example1.com")
|
||||||
|
dd_2 = DraftDomain.objects.create(name="example2.com")
|
||||||
|
dd_3 = DraftDomain.objects.create(name="example3.com")
|
||||||
|
|
||||||
|
# Create some domain requests
|
||||||
|
dr_1 = DomainRequest.objects.create(creator=self.user, requested_domain=dd_1, portfolio=portfolio)
|
||||||
|
dr_2 = DomainRequest.objects.create(creator=self.user, requested_domain=dd_2)
|
||||||
|
dr_3 = DomainRequest.objects.create(creator=self.user, requested_domain=dd_3, portfolio=portfolio)
|
||||||
|
|
||||||
|
# Set up user permissions
|
||||||
|
portfolio_permission.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
|
portfolio_permission.save()
|
||||||
|
portfolio_permission.refresh_from_db()
|
||||||
|
|
||||||
|
# Make a GET request using self.client to get a request object
|
||||||
|
request = get_wsgi_request_object(client=self.client, user=self.user)
|
||||||
|
|
||||||
|
# Get the CSV content
|
||||||
|
csv_content = self._run_domain_request_data_type_user_export(request)
|
||||||
|
|
||||||
|
# We expect only domain requests associated with the user's portfolio
|
||||||
|
self.assertIn(dd_1.name, csv_content)
|
||||||
|
self.assertIn(dd_3.name, csv_content)
|
||||||
|
self.assertNotIn(dd_2.name, csv_content)
|
||||||
|
|
||||||
|
# Get the csv content
|
||||||
|
csv_content = self._run_domain_request_data_type_user_export(request)
|
||||||
|
self.assertIn(dd_1.name, csv_content)
|
||||||
|
self.assertIn(dd_3.name, csv_content)
|
||||||
|
self.assertNotIn(dd_2.name, csv_content)
|
||||||
|
|
||||||
|
portfolio_permission.roles = [UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
|
||||||
|
portfolio_permission.save()
|
||||||
|
portfolio_permission.refresh_from_db()
|
||||||
|
|
||||||
|
# Domain Request NOT in Portfolio
|
||||||
|
csv_content = self._run_domain_request_data_type_user_export(request)
|
||||||
|
self.assertNotIn(dd_1.name, csv_content)
|
||||||
|
self.assertNotIn(dd_3.name, csv_content)
|
||||||
|
self.assertNotIn(dd_2.name, csv_content)
|
||||||
|
|
||||||
|
# Clean up the created objects
|
||||||
|
dr_1.delete()
|
||||||
|
dr_2.delete()
|
||||||
|
dr_3.delete()
|
||||||
|
portfolio.delete()
|
||||||
|
|
||||||
|
def _run_domain_request_data_type_user_export(self, request):
|
||||||
|
"""Helper function to run the exporting_dr_data_to_csv function on DomainRequestsDataType"""
|
||||||
|
|
||||||
|
csv_file = StringIO()
|
||||||
|
|
||||||
|
DomainRequestsDataType.exporting_dr_data_to_csv(csv_file, request=request)
|
||||||
|
|
||||||
|
csv_file.seek(0)
|
||||||
|
|
||||||
|
csv_content = csv_file.read()
|
||||||
|
|
||||||
|
return csv_content
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
def test_domain_data_full(self):
|
def test_domain_data_full(self):
|
||||||
"""Shows security contacts, filtered by state"""
|
"""Shows security contacts, filtered by state"""
|
||||||
|
|
|
@ -349,7 +349,10 @@ class TestDomainDetail(TestDomainOverview):
|
||||||
detail_page = self.client.get(f"/domain/{domain.id}")
|
detail_page = self.client.get(f"/domain/{domain.id}")
|
||||||
# Check that alert message displays properly
|
# Check that alert message displays properly
|
||||||
self.assertContains(
|
self.assertContains(
|
||||||
detail_page, "To manage information for this domain, you must add yourself as a domain manager."
|
detail_page,
|
||||||
|
"You don't have access to manage "
|
||||||
|
+ domain.name
|
||||||
|
+ ". If you need to make updates, contact one of the listed domain managers.",
|
||||||
)
|
)
|
||||||
# Check that user does not have option to Edit domain
|
# Check that user does not have option to Edit domain
|
||||||
self.assertNotContains(detail_page, "Edit")
|
self.assertNotContains(detail_page, "Edit")
|
||||||
|
|
|
@ -37,6 +37,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
|
||||||
UserDomainRole.objects.all().delete()
|
UserDomainRole.objects.all().delete()
|
||||||
UserPortfolioPermission.objects.all().delete()
|
UserPortfolioPermission.objects.all().delete()
|
||||||
DomainInformation.objects.all().delete()
|
DomainInformation.objects.all().delete()
|
||||||
|
Domain.objects.all().delete()
|
||||||
Portfolio.objects.all().delete()
|
Portfolio.objects.all().delete()
|
||||||
super().tearDown()
|
super().tearDown()
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from registrar.models.portfolio import Portfolio
|
from registrar.models.portfolio import Portfolio
|
||||||
|
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||||
from registrar.models.user import User
|
from registrar.models.user import User
|
||||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||||
|
@ -38,6 +39,7 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
|
||||||
phone="8003114567",
|
phone="8003114567",
|
||||||
title="Admin",
|
title="Admin",
|
||||||
)
|
)
|
||||||
|
cls.email5 = "fifth@example.com"
|
||||||
|
|
||||||
# Create Portfolio
|
# Create Portfolio
|
||||||
cls.portfolio = Portfolio.objects.create(creator=cls.user, organization_name="Test Portfolio")
|
cls.portfolio = Portfolio.objects.create(creator=cls.user, organization_name="Test Portfolio")
|
||||||
|
@ -67,6 +69,23 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
|
||||||
portfolio=cls.portfolio,
|
portfolio=cls.portfolio,
|
||||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||||
)
|
)
|
||||||
|
PortfolioInvitation.objects.create(
|
||||||
|
email=cls.email5,
|
||||||
|
portfolio=cls.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||||
|
additional_permissions=[
|
||||||
|
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||||
|
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
PortfolioInvitation.objects.all().delete()
|
||||||
|
UserPortfolioPermission.objects.all().delete()
|
||||||
|
Portfolio.objects.all().delete()
|
||||||
|
User.objects.all().delete()
|
||||||
|
super().tearDownClass()
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
@ -83,14 +102,21 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
|
||||||
self.assertFalse(data["has_previous"])
|
self.assertFalse(data["has_previous"])
|
||||||
self.assertFalse(data["has_next"])
|
self.assertFalse(data["has_next"])
|
||||||
self.assertEqual(data["num_pages"], 1)
|
self.assertEqual(data["num_pages"], 1)
|
||||||
self.assertEqual(data["total"], 4)
|
self.assertEqual(data["total"], 5)
|
||||||
self.assertEqual(data["unfiltered_total"], 4)
|
self.assertEqual(data["unfiltered_total"], 5)
|
||||||
|
|
||||||
# Check the number of members
|
# Check the number of members
|
||||||
self.assertEqual(len(data["members"]), 4)
|
self.assertEqual(len(data["members"]), 5)
|
||||||
|
|
||||||
# Check member fields
|
# Check member fields
|
||||||
expected_emails = {self.user.email, self.user2.email, self.user3.email, self.user4.email}
|
expected_emails = {
|
||||||
|
self.user.email,
|
||||||
|
self.user2.email,
|
||||||
|
self.user3.email,
|
||||||
|
self.user4.email,
|
||||||
|
self.user4.email,
|
||||||
|
self.email5,
|
||||||
|
}
|
||||||
actual_emails = {member["email"] for member in data["members"]}
|
actual_emails = {member["email"] for member in data["members"]}
|
||||||
self.assertEqual(expected_emails, actual_emails)
|
self.assertEqual(expected_emails, actual_emails)
|
||||||
|
|
||||||
|
@ -123,8 +149,8 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
|
||||||
self.assertTrue(data["has_next"])
|
self.assertTrue(data["has_next"])
|
||||||
self.assertFalse(data["has_previous"])
|
self.assertFalse(data["has_previous"])
|
||||||
self.assertEqual(data["num_pages"], 2)
|
self.assertEqual(data["num_pages"], 2)
|
||||||
self.assertEqual(data["total"], 14)
|
self.assertEqual(data["total"], 15)
|
||||||
self.assertEqual(data["unfiltered_total"], 14)
|
self.assertEqual(data["unfiltered_total"], 15)
|
||||||
|
|
||||||
# Check the number of members on page 1
|
# Check the number of members on page 1
|
||||||
self.assertEqual(len(data["members"]), 10)
|
self.assertEqual(len(data["members"]), 10)
|
||||||
|
@ -142,7 +168,7 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
|
||||||
self.assertEqual(data["num_pages"], 2)
|
self.assertEqual(data["num_pages"], 2)
|
||||||
|
|
||||||
# Check the number of members on page 2
|
# Check the number of members on page 2
|
||||||
self.assertEqual(len(data["members"]), 4)
|
self.assertEqual(len(data["members"]), 5)
|
||||||
|
|
||||||
def test_search(self):
|
def test_search(self):
|
||||||
"""Test search functionality for portfolio members."""
|
"""Test search functionality for portfolio members."""
|
||||||
|
|
|
@ -10,6 +10,7 @@ from registrar.models import (
|
||||||
UserDomainRole,
|
UserDomainRole,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
|
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||||
from registrar.models.user_group import UserGroup
|
from registrar.models.user_group import UserGroup
|
||||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||||
|
@ -288,9 +289,9 @@ class TestPortfolio(WebTest):
|
||||||
def test_accessible_pages_when_user_does_not_have_role(self):
|
def test_accessible_pages_when_user_does_not_have_role(self):
|
||||||
"""Test that admin / memmber roles are associated with the right access"""
|
"""Test that admin / memmber roles are associated with the right access"""
|
||||||
self.app.set_user(self.user.username)
|
self.app.set_user(self.user.username)
|
||||||
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
|
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
|
||||||
user=self.user, portfolio=self.portfolio, roles=portfolio_roles
|
user=self.user, portfolio=self.portfolio, roles=roles
|
||||||
)
|
)
|
||||||
with override_flag("organization_feature", active=True):
|
with override_flag("organization_feature", active=True):
|
||||||
# This will redirect the user to the portfolio page.
|
# This will redirect the user to the portfolio page.
|
||||||
|
@ -398,8 +399,8 @@ class TestPortfolio(WebTest):
|
||||||
"""When organization_feature flag is true and user has a portfolio,
|
"""When organization_feature flag is true and user has a portfolio,
|
||||||
the portfolio should be set in session."""
|
the portfolio should be set in session."""
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles)
|
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=roles)
|
||||||
with override_flag("organization_feature", active=True):
|
with override_flag("organization_feature", active=True):
|
||||||
response = self.client.get(reverse("home"))
|
response = self.client.get(reverse("home"))
|
||||||
# Ensure that middleware processes the session
|
# Ensure that middleware processes the session
|
||||||
|
@ -420,8 +421,8 @@ class TestPortfolio(WebTest):
|
||||||
This test also satisfies the condition when multiple_portfolios flag
|
This test also satisfies the condition when multiple_portfolios flag
|
||||||
is false and user has a portfolio, so won't add a redundant test for that."""
|
is false and user has a portfolio, so won't add a redundant test for that."""
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles)
|
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=roles)
|
||||||
response = self.client.get(reverse("home"))
|
response = self.client.get(reverse("home"))
|
||||||
# Ensure that middleware processes the session
|
# Ensure that middleware processes the session
|
||||||
session_middleware = SessionMiddleware(lambda request: None)
|
session_middleware = SessionMiddleware(lambda request: None)
|
||||||
|
@ -457,8 +458,8 @@ class TestPortfolio(WebTest):
|
||||||
"""When multiple_portfolios flag is true and user has a portfolio,
|
"""When multiple_portfolios flag is true and user has a portfolio,
|
||||||
the portfolio should be set in session."""
|
the portfolio should be set in session."""
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles)
|
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=roles)
|
||||||
with override_flag("organization_feature", active=True), override_flag("multiple_portfolios", active=True):
|
with override_flag("organization_feature", active=True), override_flag("multiple_portfolios", active=True):
|
||||||
response = self.client.get(reverse("home"))
|
response = self.client.get(reverse("home"))
|
||||||
# Ensure that middleware processes the session
|
# Ensure that middleware processes the session
|
||||||
|
@ -817,7 +818,6 @@ class TestPortfolio(WebTest):
|
||||||
|
|
||||||
# Verify that view-only settings are sent in the dynamic HTML
|
# Verify that view-only settings are sent in the dynamic HTML
|
||||||
response = self.client.get(reverse("get_portfolio_members_json") + f"?portfolio={self.portfolio.pk}")
|
response = self.client.get(reverse("get_portfolio_members_json") + f"?portfolio={self.portfolio.pk}")
|
||||||
print(response.content)
|
|
||||||
self.assertContains(response, '"action_label": "View"')
|
self.assertContains(response, '"action_label": "View"')
|
||||||
self.assertContains(response, '"svg_icon": "visibility"')
|
self.assertContains(response, '"svg_icon": "visibility"')
|
||||||
|
|
||||||
|
@ -856,6 +856,230 @@ class TestPortfolio(WebTest):
|
||||||
# TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"{response.content}")
|
# TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"{response.content}")
|
||||||
self.assertContains(response, '"is_admin": true')
|
self.assertContains(response, '"is_admin": true')
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@override_flag("organization_feature", active=True)
|
||||||
|
def test_cannot_view_member_page_when_flag_is_off(self):
|
||||||
|
"""Test that user cannot access the member page when waffle flag is off"""
|
||||||
|
|
||||||
|
# Verify that the user cannot access the member page
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(reverse("member", kwargs={"pk": 1}), follow=True)
|
||||||
|
# Make sure the page is denied
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@override_flag("organization_feature", active=True)
|
||||||
|
@override_flag("organization_members", active=True)
|
||||||
|
def test_cannot_view_member_page_when_user_has_no_permission(self):
|
||||||
|
"""Test that user cannot access the member page without proper permission"""
|
||||||
|
|
||||||
|
# give user base permissions
|
||||||
|
UserPortfolioPermission.objects.get_or_create(
|
||||||
|
user=self.user,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify that the user cannot access the member page
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(reverse("member", kwargs={"pk": 1}), follow=True)
|
||||||
|
# Make sure the page is denied
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@override_flag("organization_feature", active=True)
|
||||||
|
@override_flag("organization_members", active=True)
|
||||||
|
def test_can_view_member_page_when_user_has_view_members(self):
|
||||||
|
"""Test that user can access the member page with view_members permission"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
# give user permissions to view members
|
||||||
|
permission_obj, _ = UserPortfolioPermission.objects.get_or_create(
|
||||||
|
user=self.user,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||||
|
additional_permissions=[
|
||||||
|
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify the page can be accessed
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(reverse("member", kwargs={"pk": permission_obj.pk}), follow=True)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# Assert text within the page is correct
|
||||||
|
self.assertContains(response, "First Last")
|
||||||
|
self.assertContains(response, self.user.email)
|
||||||
|
self.assertContains(response, "Basic access")
|
||||||
|
self.assertContains(response, "No access")
|
||||||
|
self.assertContains(response, "View all members")
|
||||||
|
self.assertContains(response, "This member does not manage any domains.")
|
||||||
|
|
||||||
|
# Assert buttons and links within the page are correct
|
||||||
|
self.assertNotContains(response, "usa-button--more-actions") # test that 3 dot is not present
|
||||||
|
self.assertNotContains(response, "sprite.svg#edit") # test that Edit link is not present
|
||||||
|
self.assertNotContains(response, "sprite.svg#settings") # test that Manage link is not present
|
||||||
|
self.assertContains(response, "sprite.svg#visibility") # test that View link is present
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@override_flag("organization_feature", active=True)
|
||||||
|
@override_flag("organization_members", active=True)
|
||||||
|
def test_can_view_member_page_when_user_has_edit_members(self):
|
||||||
|
"""Test that user can access the member page with edit_members permission"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
# give user permissions to view AND manage members
|
||||||
|
permission_obj, _ = UserPortfolioPermission.objects.get_or_create(
|
||||||
|
user=self.user,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||||
|
additional_permissions=[
|
||||||
|
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify the page can be accessed
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(reverse("member", kwargs={"pk": permission_obj.pk}), follow=True)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# Assert text within the page is correct
|
||||||
|
self.assertContains(response, "First Last")
|
||||||
|
self.assertContains(response, self.user.email)
|
||||||
|
self.assertContains(response, "Admin access")
|
||||||
|
self.assertContains(response, "View all requests plus create requests")
|
||||||
|
self.assertContains(response, "View all members plus manage members")
|
||||||
|
self.assertContains(
|
||||||
|
response, 'This member does not manage any domains. To assign this member a domain, click "Manage"'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert buttons and links within the page are correct
|
||||||
|
self.assertContains(response, "usa-button--more-actions") # test that 3 dot is present
|
||||||
|
self.assertContains(response, "sprite.svg#edit") # test that Edit link is present
|
||||||
|
self.assertContains(response, "sprite.svg#settings") # test that Manage link is present
|
||||||
|
self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@override_flag("organization_feature", active=True)
|
||||||
|
def test_cannot_view_invitedmember_page_when_flag_is_off(self):
|
||||||
|
"""Test that user cannot access the invitedmember page when waffle flag is off"""
|
||||||
|
|
||||||
|
# Verify that the user cannot access the member page
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(reverse("invitedmember", kwargs={"pk": 1}), follow=True)
|
||||||
|
# Make sure the page is denied
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@override_flag("organization_feature", active=True)
|
||||||
|
@override_flag("organization_members", active=True)
|
||||||
|
def test_cannot_view_invitedmember_page_when_user_has_no_permission(self):
|
||||||
|
"""Test that user cannot access the invitedmember page without proper permission"""
|
||||||
|
|
||||||
|
# give user base permissions
|
||||||
|
UserPortfolioPermission.objects.get_or_create(
|
||||||
|
user=self.user,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify that the user cannot access the member page
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(reverse("invitedmember", kwargs={"pk": 1}), follow=True)
|
||||||
|
# Make sure the page is denied
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@override_flag("organization_feature", active=True)
|
||||||
|
@override_flag("organization_members", active=True)
|
||||||
|
def test_can_view_invitedmember_page_when_user_has_view_members(self):
|
||||||
|
"""Test that user can access the invitedmember page with view_members permission"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
# give user permissions to view members
|
||||||
|
UserPortfolioPermission.objects.get_or_create(
|
||||||
|
user=self.user,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||||
|
additional_permissions=[
|
||||||
|
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(
|
||||||
|
email="info@example.com",
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||||
|
additional_permissions=[
|
||||||
|
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify the page can be accessed
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(reverse("invitedmember", kwargs={"pk": portfolio_invitation.pk}), follow=True)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# Assert text within the page is correct
|
||||||
|
self.assertContains(response, "Invited")
|
||||||
|
self.assertContains(response, portfolio_invitation.email)
|
||||||
|
self.assertContains(response, "Basic access")
|
||||||
|
self.assertContains(response, "No access")
|
||||||
|
self.assertContains(response, "View all members")
|
||||||
|
self.assertContains(response, "This member does not manage any domains.")
|
||||||
|
|
||||||
|
# Assert buttons and links within the page are correct
|
||||||
|
self.assertNotContains(response, "usa-button--more-actions") # test that 3 dot is not present
|
||||||
|
self.assertNotContains(response, "sprite.svg#edit") # test that Edit link is not present
|
||||||
|
self.assertNotContains(response, "sprite.svg#settings") # test that Manage link is not present
|
||||||
|
self.assertContains(response, "sprite.svg#visibility") # test that View link is present
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@override_flag("organization_feature", active=True)
|
||||||
|
@override_flag("organization_members", active=True)
|
||||||
|
def test_can_view_invitedmember_page_when_user_has_edit_members(self):
|
||||||
|
"""Test that user can access the invitedmember page with edit_members permission"""
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
# give user permissions to view AND manage members
|
||||||
|
permission_obj, _ = UserPortfolioPermission.objects.get_or_create(
|
||||||
|
user=self.user,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||||
|
additional_permissions=[
|
||||||
|
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(
|
||||||
|
email="info@example.com",
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||||
|
additional_permissions=[
|
||||||
|
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify the page can be accessed
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(reverse("invitedmember", kwargs={"pk": portfolio_invitation.pk}), follow=True)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# Assert text within the page is correct
|
||||||
|
self.assertContains(response, "Invited")
|
||||||
|
self.assertContains(response, portfolio_invitation.email)
|
||||||
|
self.assertContains(response, "Admin access")
|
||||||
|
self.assertContains(response, "View all requests plus create requests")
|
||||||
|
self.assertContains(response, "View all members plus manage members")
|
||||||
|
self.assertContains(
|
||||||
|
response, 'This member does not manage any domains. To assign this member a domain, click "Manage"'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert buttons and links within the page are correct
|
||||||
|
self.assertContains(response, "usa-button--more-actions") # test that 3 dot is present
|
||||||
|
self.assertContains(response, "sprite.svg#edit") # test that Edit link is present
|
||||||
|
self.assertContains(response, "sprite.svg#settings") # test that Manage link is present
|
||||||
|
self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
@override_flag("organization_feature", active=True)
|
@override_flag("organization_feature", active=True)
|
||||||
def test_portfolio_domain_requests_page_when_user_has_no_permissions(self):
|
def test_portfolio_domain_requests_page_when_user_has_no_permissions(self):
|
||||||
|
@ -1015,8 +1239,8 @@ class TestPortfolio(WebTest):
|
||||||
def test_portfolio_cache_updates_when_modified(self):
|
def test_portfolio_cache_updates_when_modified(self):
|
||||||
"""Test that the portfolio in session updates when the portfolio is modified"""
|
"""Test that the portfolio in session updates when the portfolio is modified"""
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles)
|
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=roles)
|
||||||
|
|
||||||
with override_flag("organization_feature", active=True):
|
with override_flag("organization_feature", active=True):
|
||||||
# Initial request to set the portfolio in session
|
# Initial request to set the portfolio in session
|
||||||
|
@ -1044,8 +1268,8 @@ class TestPortfolio(WebTest):
|
||||||
def test_portfolio_cache_updates_when_flag_disabled_while_logged_in(self):
|
def test_portfolio_cache_updates_when_flag_disabled_while_logged_in(self):
|
||||||
"""Test that the portfolio in session is set to None when the organization_feature flag is disabled"""
|
"""Test that the portfolio in session is set to None when the organization_feature flag is disabled"""
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles)
|
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=roles)
|
||||||
|
|
||||||
with override_flag("organization_feature", active=True):
|
with override_flag("organization_feature", active=True):
|
||||||
# Initial request to set the portfolio in session
|
# Initial request to set the portfolio in session
|
||||||
|
|
|
@ -43,7 +43,7 @@ class DomainRequestTests(TestWithUser, WebTest):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.federal_agency, _ = FederalAgency.objects.get_or_create(agency="General Services Administration")
|
self.federal_agency, _ = FederalAgency.objects.get_or_create(agency="General Services Administration")
|
||||||
self.app.set_user(self.user.username)
|
self.app.set_user(self.user.username)
|
||||||
self.TITLES = DomainRequestWizard.TITLES
|
self.TITLES = DomainRequestWizard.REGULAR_TITLES
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
super().tearDown()
|
super().tearDown()
|
||||||
|
@ -82,7 +82,6 @@ class DomainRequestTests(TestWithUser, WebTest):
|
||||||
response = self.app.get(f"/domain-request/{domain_request.id}")
|
response = self.app.get(f"/domain-request/{domain_request.id}")
|
||||||
# Ensure that the date is still set to None
|
# Ensure that the date is still set to None
|
||||||
self.assertIsNone(domain_request.last_status_update)
|
self.assertIsNone(domain_request.last_status_update)
|
||||||
print(response)
|
|
||||||
# We should still grab a date for this field in this event - but it should come from the audit log instead
|
# We should still grab a date for this field in this event - but it should come from the audit log instead
|
||||||
self.assertContains(response, "Started on:")
|
self.assertContains(response, "Started on:")
|
||||||
self.assertContains(response, fixed_date.strftime("%B %-d, %Y"))
|
self.assertContains(response, fixed_date.strftime("%B %-d, %Y"))
|
||||||
|
@ -522,7 +521,8 @@ class DomainRequestTests(TestWithUser, WebTest):
|
||||||
# And the existence of the modal's data parked and ready for the js init.
|
# And the existence of the modal's data parked and ready for the js init.
|
||||||
# The next assert also tests for the passed requested domain context from
|
# The next assert also tests for the passed requested domain context from
|
||||||
# the view > domain_request_form > modal
|
# the view > domain_request_form > modal
|
||||||
self.assertContains(review_page, "You are about to submit a domain request for city.gov")
|
self.assertContains(review_page, "You are about to submit a domain request for")
|
||||||
|
self.assertContains(review_page, "city.gov")
|
||||||
|
|
||||||
# final submission results in a redirect to the "finished" URL
|
# final submission results in a redirect to the "finished" URL
|
||||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||||
|
@ -2742,6 +2742,66 @@ class DomainRequestTests(TestWithUser, WebTest):
|
||||||
self.assertContains(review_page, "toggle-submit-domain-request")
|
self.assertContains(review_page, "toggle-submit-domain-request")
|
||||||
self.assertContains(review_page, "Your request form is incomplete")
|
self.assertContains(review_page, "Your request form is incomplete")
|
||||||
|
|
||||||
|
@override_flag("organization_feature", active=True)
|
||||||
|
@override_flag("organization_requests", active=True)
|
||||||
|
def test_portfolio_user_missing_edit_permissions(self):
|
||||||
|
"""Tests that a portfolio user without edit request permissions cannot edit or add new requests"""
|
||||||
|
portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Test Portfolio")
|
||||||
|
portfolio_perm, _ = UserPortfolioPermission.objects.get_or_create(
|
||||||
|
user=self.user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
|
||||||
|
)
|
||||||
|
# This user should be forbidden from creating new domain requests
|
||||||
|
intro_page = self.app.get(reverse("domain-request:"), expect_errors=True)
|
||||||
|
self.assertEqual(intro_page.status_code, 403)
|
||||||
|
|
||||||
|
# This user should also be forbidden from editing existing ones
|
||||||
|
domain_request = completed_domain_request(user=self.user)
|
||||||
|
edit_page = self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk}), expect_errors=True)
|
||||||
|
self.assertEqual(edit_page.status_code, 403)
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
portfolio_perm.delete()
|
||||||
|
portfolio.delete()
|
||||||
|
|
||||||
|
@override_flag("organization_feature", active=True)
|
||||||
|
@override_flag("organization_requests", active=True)
|
||||||
|
def test_portfolio_user_with_edit_permissions(self):
|
||||||
|
"""Tests that a portfolio user with edit request permissions can edit and add new requests"""
|
||||||
|
portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Test Portfolio")
|
||||||
|
portfolio_perm, _ = UserPortfolioPermission.objects.get_or_create(
|
||||||
|
user=self.user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
|
)
|
||||||
|
|
||||||
|
# This user should be allowed to create new domain requests
|
||||||
|
intro_page = self.app.get(reverse("domain-request:"))
|
||||||
|
self.assertEqual(intro_page.status_code, 200)
|
||||||
|
|
||||||
|
# This user should also be allowed to edit existing ones
|
||||||
|
domain_request = completed_domain_request(user=self.user)
|
||||||
|
edit_page = self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk})).follow()
|
||||||
|
self.assertEqual(edit_page.status_code, 200)
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
DomainRequest.objects.all().delete()
|
||||||
|
portfolio_perm.delete()
|
||||||
|
portfolio.delete()
|
||||||
|
|
||||||
|
def test_non_creator_access(self):
|
||||||
|
"""Tests that a user cannot edit a domain request they didn't create"""
|
||||||
|
p = "password"
|
||||||
|
other_user = User.objects.create_user(username="other_user", password=p)
|
||||||
|
domain_request = completed_domain_request(user=other_user)
|
||||||
|
|
||||||
|
edit_page = self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk}), expect_errors=True)
|
||||||
|
self.assertEqual(edit_page.status_code, 403)
|
||||||
|
|
||||||
|
def test_creator_access(self):
|
||||||
|
"""Tests that a user can edit a domain request they created"""
|
||||||
|
domain_request = completed_domain_request(user=self.user)
|
||||||
|
|
||||||
|
edit_page = self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk})).follow()
|
||||||
|
self.assertEqual(edit_page.status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
class DomainRequestTestDifferentStatuses(TestWithUser, WebTest):
|
class DomainRequestTestDifferentStatuses(TestWithUser, WebTest):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
@ -2904,7 +2964,7 @@ class DomainRequestTestDifferentStatuses(TestWithUser, WebTest):
|
||||||
self.assertNotContains(home_page, "city.gov")
|
self.assertNotContains(home_page, "city.gov")
|
||||||
|
|
||||||
|
|
||||||
class TestWizardUnlockingSteps(TestWithUser, WebTest):
|
class TestDomainRequestWizard(TestWithUser, WebTest):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.app.set_user(self.user.username)
|
self.app.set_user(self.user.username)
|
||||||
|
@ -3026,6 +3086,94 @@ class TestWizardUnlockingSteps(TestWithUser, WebTest):
|
||||||
else:
|
else:
|
||||||
self.fail(f"Expected a redirect, but got a different response: {response}")
|
self.fail(f"Expected a redirect, but got a different response: {response}")
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@override_flag("organization_feature", active=True)
|
||||||
|
@override_flag("organization_requests", active=True)
|
||||||
|
def test_wizard_steps_portfolio(self):
|
||||||
|
"""
|
||||||
|
Tests the behavior of the domain request wizard for portfolios.
|
||||||
|
Ensures that:
|
||||||
|
- The user can access the organization page.
|
||||||
|
- The expected number of steps are locked/unlocked (implicit test for expected steps).
|
||||||
|
- The user lands on the "Requesting entity" page
|
||||||
|
- The user does not see the Domain and Domain requests buttons
|
||||||
|
"""
|
||||||
|
|
||||||
|
# This should unlock 4 steps by default.
|
||||||
|
# Purpose, .gov domain, current websites, and requirements for operating
|
||||||
|
domain_request = completed_domain_request(
|
||||||
|
status=DomainRequest.DomainRequestStatus.STARTED,
|
||||||
|
user=self.user,
|
||||||
|
)
|
||||||
|
domain_request.anything_else = None
|
||||||
|
domain_request.save()
|
||||||
|
|
||||||
|
federal_agency = FederalAgency.objects.get(agency="Non-Federal Agency")
|
||||||
|
# Add a portfolio
|
||||||
|
portfolio = Portfolio.objects.create(
|
||||||
|
creator=self.user,
|
||||||
|
organization_name="test portfolio",
|
||||||
|
federal_agency=federal_agency,
|
||||||
|
)
|
||||||
|
|
||||||
|
user_portfolio_permission = UserPortfolioPermission.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
portfolio=portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.app.get(f"/domain-request/{domain_request.id}/edit/")
|
||||||
|
# django-webtest does not handle cookie-based sessions well because it keeps
|
||||||
|
# resetting the session key on each new request, thus destroying the concept
|
||||||
|
# of a "session". We are going to do it manually, saving the session ID here
|
||||||
|
# and then setting the cookie on each request.
|
||||||
|
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||||
|
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||||
|
|
||||||
|
# Check if the response is a redirect
|
||||||
|
if response.status_code == 302:
|
||||||
|
# Follow the redirect manually
|
||||||
|
try:
|
||||||
|
detail_page = response.follow()
|
||||||
|
|
||||||
|
self.wizard.get_context_data()
|
||||||
|
except Exception as err:
|
||||||
|
# Handle any potential errors while following the redirect
|
||||||
|
self.fail(f"Error following the redirect {err}")
|
||||||
|
|
||||||
|
# Now 'detail_page' contains the response after following the redirect
|
||||||
|
self.assertEqual(detail_page.status_code, 200)
|
||||||
|
|
||||||
|
# Assert that we're on the organization page
|
||||||
|
self.assertContains(detail_page, portfolio.organization_name)
|
||||||
|
|
||||||
|
# We should only see one unlocked step
|
||||||
|
self.assertContains(detail_page, "#check_circle", count=4)
|
||||||
|
|
||||||
|
# One pages should still be locked (additional details)
|
||||||
|
self.assertContains(detail_page, "#lock", 1)
|
||||||
|
|
||||||
|
# The current option should be selected
|
||||||
|
self.assertContains(detail_page, "usa-current", count=1)
|
||||||
|
|
||||||
|
# We default to the requesting entity page
|
||||||
|
expected_url = reverse("domain-request:portfolio_requesting_entity")
|
||||||
|
# This returns the entire url, thus "in"
|
||||||
|
self.assertIn(expected_url, detail_page.request.url)
|
||||||
|
|
||||||
|
# We shouldn't show the "domains" and "domain requests" buttons
|
||||||
|
# on this page.
|
||||||
|
self.assertNotContains(detail_page, "Domains")
|
||||||
|
self.assertNotContains(detail_page, "Domain requests")
|
||||||
|
else:
|
||||||
|
self.fail(f"Expected a redirect, but got a different response: {response}")
|
||||||
|
|
||||||
|
# Data cleanup
|
||||||
|
user_portfolio_permission.delete()
|
||||||
|
portfolio.delete()
|
||||||
|
federal_agency.delete()
|
||||||
|
domain_request.delete()
|
||||||
|
|
||||||
|
|
||||||
class TestPortfolioDomainRequestViewonly(TestWithUser, WebTest):
|
class TestPortfolioDomainRequestViewonly(TestWithUser, WebTest):
|
||||||
|
|
||||||
|
@ -3037,7 +3185,7 @@ class TestPortfolioDomainRequestViewonly(TestWithUser, WebTest):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.federal_agency, _ = FederalAgency.objects.get_or_create(agency="General Services Administration")
|
self.federal_agency, _ = FederalAgency.objects.get_or_create(agency="General Services Administration")
|
||||||
self.app.set_user(self.user.username)
|
self.app.set_user(self.user.username)
|
||||||
self.TITLES = DomainRequestWizard.TITLES
|
self.TITLES = DomainRequestWizard.REGULAR_TITLES
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
super().tearDown()
|
super().tearDown()
|
||||||
|
|
|
@ -6,36 +6,39 @@ from django.utils.html import escape
|
||||||
from registrar.models.utility.generic_helper import value_of_attribute
|
from registrar.models.utility.generic_helper import value_of_attribute
|
||||||
|
|
||||||
|
|
||||||
def get_all_action_needed_reason_emails(request, domain_request):
|
def get_action_needed_reason_default_email(domain_request, action_needed_reason):
|
||||||
"""Returns a dictionary of every action needed reason and its associated email
|
|
||||||
for this particular domain request."""
|
|
||||||
|
|
||||||
emails = {}
|
|
||||||
for action_needed_reason in domain_request.ActionNeededReasons:
|
|
||||||
# Map the action_needed_reason to its default email
|
|
||||||
emails[action_needed_reason.value] = get_action_needed_reason_default_email(
|
|
||||||
request, domain_request, action_needed_reason.value
|
|
||||||
)
|
|
||||||
|
|
||||||
return emails
|
|
||||||
|
|
||||||
|
|
||||||
def get_action_needed_reason_default_email(request, domain_request, action_needed_reason):
|
|
||||||
"""Returns the default email associated with the given action needed reason"""
|
"""Returns the default email associated with the given action needed reason"""
|
||||||
if not action_needed_reason or action_needed_reason == DomainRequest.ActionNeededReasons.OTHER:
|
return _get_default_email(
|
||||||
|
domain_request,
|
||||||
|
file_path=f"emails/action_needed_reasons/{action_needed_reason}.txt",
|
||||||
|
reason=action_needed_reason,
|
||||||
|
excluded_reasons=[DomainRequest.ActionNeededReasons.OTHER],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_rejection_reason_default_email(domain_request, rejection_reason):
|
||||||
|
"""Returns the default email associated with the given rejection reason"""
|
||||||
|
return _get_default_email(
|
||||||
|
domain_request,
|
||||||
|
file_path="emails/status_change_rejected.txt",
|
||||||
|
reason=rejection_reason,
|
||||||
|
# excluded_reasons=[DomainRequest.RejectionReasons.OTHER]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_default_email(domain_request, file_path, reason, excluded_reasons=None):
|
||||||
|
if not reason:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if excluded_reasons and reason in excluded_reasons:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
recipient = domain_request.creator
|
recipient = domain_request.creator
|
||||||
# Return the context of the rendered views
|
# Return the context of the rendered views
|
||||||
context = {"domain_request": domain_request, "recipient": recipient}
|
context = {"domain_request": domain_request, "recipient": recipient, "reason": reason}
|
||||||
|
|
||||||
# Get the email body
|
email_body_text = get_template(file_path).render(context=context)
|
||||||
template_path = f"emails/action_needed_reasons/{action_needed_reason}.txt"
|
email_body_text_cleaned = email_body_text.strip().lstrip("\n") if email_body_text else None
|
||||||
|
|
||||||
email_body_text = get_template(template_path).render(context=context)
|
|
||||||
email_body_text_cleaned = None
|
|
||||||
if email_body_text:
|
|
||||||
email_body_text_cleaned = email_body_text.strip().lstrip("\n")
|
|
||||||
|
|
||||||
return email_body_text_cleaned
|
return email_body_text_cleaned
|
||||||
|
|
||||||
|
|
|
@ -583,6 +583,105 @@ class DomainDataTypeUser(DomainDataType):
|
||||||
return Q(domain__id__in=request.user.get_user_domain_ids(request))
|
return Q(domain__id__in=request.user.get_user_domain_ids(request))
|
||||||
|
|
||||||
|
|
||||||
|
class DomainRequestsDataType:
|
||||||
|
"""
|
||||||
|
The DomainRequestsDataType report, but filtered based on the current request user
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_filter_conditions(cls, request=None):
|
||||||
|
if request is None or not hasattr(request, "user") or not request.user.is_authenticated:
|
||||||
|
return Q(id__in=[])
|
||||||
|
|
||||||
|
request_ids = request.user.get_user_domain_request_ids(request)
|
||||||
|
return Q(id__in=request_ids)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_queryset(cls, request):
|
||||||
|
return DomainRequest.objects.filter(cls.get_filter_conditions(request))
|
||||||
|
|
||||||
|
def safe_get(attribute, default="N/A"):
|
||||||
|
# Return the attribute value or default if not present
|
||||||
|
return attribute if attribute is not None else default
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def exporting_dr_data_to_csv(cls, response, request=None):
|
||||||
|
import csv
|
||||||
|
|
||||||
|
writer = csv.writer(response)
|
||||||
|
|
||||||
|
# CSV headers
|
||||||
|
writer.writerow(
|
||||||
|
[
|
||||||
|
"Domain request",
|
||||||
|
"Region",
|
||||||
|
"Status",
|
||||||
|
"Election office",
|
||||||
|
"Federal type",
|
||||||
|
"Domain type",
|
||||||
|
"Request additional details",
|
||||||
|
"Creator approved domains count",
|
||||||
|
"Creator active requests count",
|
||||||
|
"Alternative domains",
|
||||||
|
"Other contacts",
|
||||||
|
"Current websites",
|
||||||
|
"Federal agency",
|
||||||
|
"SO first name",
|
||||||
|
"SO last name",
|
||||||
|
"SO email",
|
||||||
|
"SO title/role",
|
||||||
|
"Creator first name",
|
||||||
|
"Creator last name",
|
||||||
|
"Creator email",
|
||||||
|
"Organization name",
|
||||||
|
"City",
|
||||||
|
"State/territory",
|
||||||
|
"Request purpose",
|
||||||
|
"CISA regional representative",
|
||||||
|
"Last submitted date",
|
||||||
|
"First submitted date",
|
||||||
|
"Last status update",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
queryset = cls.get_queryset(request)
|
||||||
|
for request in queryset:
|
||||||
|
writer.writerow(
|
||||||
|
[
|
||||||
|
request.requested_domain,
|
||||||
|
cls.safe_get(getattr(request, "region_field", None)),
|
||||||
|
request.status,
|
||||||
|
cls.safe_get(getattr(request, "election_office", None)),
|
||||||
|
request.federal_type,
|
||||||
|
cls.safe_get(getattr(request, "domain_type", None)),
|
||||||
|
cls.safe_get(getattr(request, "additional_details", None)),
|
||||||
|
cls.safe_get(getattr(request, "creator_approved_domains_count", None)),
|
||||||
|
cls.safe_get(getattr(request, "creator_active_requests_count", None)),
|
||||||
|
cls.safe_get(getattr(request, "all_alternative_domains", None)),
|
||||||
|
cls.safe_get(getattr(request, "all_other_contacts", None)),
|
||||||
|
cls.safe_get(getattr(request, "all_current_websites", None)),
|
||||||
|
cls.safe_get(getattr(request, "federal_agency", None)),
|
||||||
|
cls.safe_get(getattr(request.senior_official, "first_name", None)),
|
||||||
|
cls.safe_get(getattr(request.senior_official, "last_name", None)),
|
||||||
|
cls.safe_get(getattr(request.senior_official, "email", None)),
|
||||||
|
cls.safe_get(getattr(request.senior_official, "title", None)),
|
||||||
|
cls.safe_get(getattr(request.creator, "first_name", None)),
|
||||||
|
cls.safe_get(getattr(request.creator, "last_name", None)),
|
||||||
|
cls.safe_get(getattr(request.creator, "email", None)),
|
||||||
|
cls.safe_get(getattr(request, "organization_name", None)),
|
||||||
|
cls.safe_get(getattr(request, "city", None)),
|
||||||
|
cls.safe_get(getattr(request, "state_territory", None)),
|
||||||
|
cls.safe_get(getattr(request, "purpose", None)),
|
||||||
|
cls.safe_get(getattr(request, "cisa_representative_email", None)),
|
||||||
|
cls.safe_get(getattr(request, "last_submitted_date", None)),
|
||||||
|
cls.safe_get(getattr(request, "first_submitted_date", None)),
|
||||||
|
cls.safe_get(getattr(request, "last_status_update", None)),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
class DomainDataFull(DomainExport):
|
class DomainDataFull(DomainExport):
|
||||||
"""
|
"""
|
||||||
Shows security contacts, filtered by state
|
Shows security contacts, filtered by state
|
||||||
|
|
|
@ -74,10 +74,13 @@ class PortfolioDomainRequestStep(StrEnum):
|
||||||
appear in the order they are defined. (Order matters.)
|
appear in the order they are defined. (Order matters.)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Portfolio
|
# NOTE: Append portfolio_ when customizing a view for portfolio.
|
||||||
REQUESTING_ENTITY = "organization_name"
|
# By default, these will redirect to the normal request flow views.
|
||||||
|
# After creating a new view, you will need to add this to urls.py.
|
||||||
|
REQUESTING_ENTITY = "portfolio_requesting_entity"
|
||||||
CURRENT_SITES = "current_sites"
|
CURRENT_SITES = "current_sites"
|
||||||
DOTGOV_DOMAIN = "dotgov_domain"
|
DOTGOV_DOMAIN = "dotgov_domain"
|
||||||
PURPOSE = "purpose"
|
PURPOSE = "purpose"
|
||||||
ADDITIONAL_DETAILS = "additional_details"
|
ADDITIONAL_DETAILS = "portfolio_additional_details"
|
||||||
REQUIREMENTS = "requirements"
|
REQUIREMENTS = "requirements"
|
||||||
|
REVIEW = "review"
|
||||||
|
|
|
@ -240,7 +240,7 @@ class SecurityEmailError(Exception):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_error_mapping = {
|
_error_mapping = {
|
||||||
SecurityEmailErrorCodes.BAD_DATA: ("Enter an email address in the required format, " "like name@example.com."),
|
SecurityEmailErrorCodes.BAD_DATA: ("Enter an email address in the required format, like name@example.com."),
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, *args, code=None, **kwargs):
|
def __init__(self, *args, code=None, **kwargs):
|
||||||
|
|
|
@ -43,8 +43,8 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
||||||
although not without consulting the base implementation, first.
|
although not without consulting the base implementation, first.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
StepEnum: Step = Step # type: ignore
|
|
||||||
template_name = ""
|
template_name = ""
|
||||||
|
is_portfolio = False
|
||||||
|
|
||||||
# uniquely namespace the wizard in urls.py
|
# uniquely namespace the wizard in urls.py
|
||||||
# (this is not seen _in_ urls, only for Django's internal naming)
|
# (this is not seen _in_ urls, only for Django's internal naming)
|
||||||
|
@ -54,42 +54,140 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
||||||
# name for accessing /domain-request/<id>/edit
|
# name for accessing /domain-request/<id>/edit
|
||||||
EDIT_URL_NAME = "edit-domain-request"
|
EDIT_URL_NAME = "edit-domain-request"
|
||||||
NEW_URL_NAME = "/request/"
|
NEW_URL_NAME = "/request/"
|
||||||
|
|
||||||
|
# region: Titles
|
||||||
# We need to pass our human-readable step titles as context to the templates.
|
# We need to pass our human-readable step titles as context to the templates.
|
||||||
TITLES = {
|
REGULAR_TITLES = {
|
||||||
StepEnum.ORGANIZATION_TYPE: _("Type of organization"),
|
Step.ORGANIZATION_TYPE: _("Type of organization"),
|
||||||
StepEnum.TRIBAL_GOVERNMENT: _("Tribal government"),
|
Step.TRIBAL_GOVERNMENT: _("Tribal government"),
|
||||||
StepEnum.ORGANIZATION_FEDERAL: _("Federal government branch"),
|
Step.ORGANIZATION_FEDERAL: _("Federal government branch"),
|
||||||
StepEnum.ORGANIZATION_ELECTION: _("Election office"),
|
Step.ORGANIZATION_ELECTION: _("Election office"),
|
||||||
StepEnum.ORGANIZATION_CONTACT: _("Organization"),
|
Step.ORGANIZATION_CONTACT: _("Organization"),
|
||||||
StepEnum.ABOUT_YOUR_ORGANIZATION: _("About your organization"),
|
Step.ABOUT_YOUR_ORGANIZATION: _("About your organization"),
|
||||||
StepEnum.SENIOR_OFFICIAL: _("Senior official"),
|
Step.SENIOR_OFFICIAL: _("Senior official"),
|
||||||
StepEnum.CURRENT_SITES: _("Current websites"),
|
Step.CURRENT_SITES: _("Current websites"),
|
||||||
StepEnum.DOTGOV_DOMAIN: _(".gov domain"),
|
Step.DOTGOV_DOMAIN: _(".gov domain"),
|
||||||
StepEnum.PURPOSE: _("Purpose of your domain"),
|
Step.PURPOSE: _("Purpose of your domain"),
|
||||||
StepEnum.OTHER_CONTACTS: _("Other employees from your organization"),
|
Step.OTHER_CONTACTS: _("Other employees from your organization"),
|
||||||
StepEnum.ADDITIONAL_DETAILS: _("Additional details"),
|
Step.ADDITIONAL_DETAILS: _("Additional details"),
|
||||||
StepEnum.REQUIREMENTS: _("Requirements for operating a .gov domain"),
|
Step.REQUIREMENTS: _("Requirements for operating a .gov domain"),
|
||||||
StepEnum.REVIEW: _("Review and submit your domain request"),
|
Step.REVIEW: _("Review and submit your domain request"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Titles for the portfolio context
|
||||||
|
PORTFOLIO_TITLES = {
|
||||||
|
PortfolioDomainRequestStep.REQUESTING_ENTITY: _("Requesting entity"),
|
||||||
|
PortfolioDomainRequestStep.CURRENT_SITES: _("Current websites"),
|
||||||
|
PortfolioDomainRequestStep.DOTGOV_DOMAIN: _(".gov domain"),
|
||||||
|
PortfolioDomainRequestStep.PURPOSE: _("Purpose of your domain"),
|
||||||
|
PortfolioDomainRequestStep.ADDITIONAL_DETAILS: _("Additional details"),
|
||||||
|
PortfolioDomainRequestStep.REQUIREMENTS: _("Requirements for operating a .gov domain"),
|
||||||
|
PortfolioDomainRequestStep.REVIEW: _("Review and submit your domain request"),
|
||||||
|
}
|
||||||
|
# endregion
|
||||||
|
|
||||||
|
# region: Wizard conditions
|
||||||
# We can use a dictionary with step names and callables that return booleans
|
# We can use a dictionary with step names and callables that return booleans
|
||||||
# to show or hide particular steps based on the state of the process.
|
# to show or hide particular steps based on the state of the process.
|
||||||
WIZARD_CONDITIONS = {
|
REGULAR_WIZARD_CONDITIONS = {
|
||||||
StepEnum.ORGANIZATION_FEDERAL: lambda w: w.from_model("show_organization_federal", False),
|
Step.ORGANIZATION_FEDERAL: lambda w: w.from_model("show_organization_federal", False),
|
||||||
StepEnum.TRIBAL_GOVERNMENT: lambda w: w.from_model("show_tribal_government", False),
|
Step.TRIBAL_GOVERNMENT: lambda w: w.from_model("show_tribal_government", False),
|
||||||
StepEnum.ORGANIZATION_ELECTION: lambda w: w.from_model("show_organization_election", False),
|
Step.ORGANIZATION_ELECTION: lambda w: w.from_model("show_organization_election", False),
|
||||||
StepEnum.ABOUT_YOUR_ORGANIZATION: lambda w: w.from_model("show_about_your_organization", False),
|
Step.ABOUT_YOUR_ORGANIZATION: lambda w: w.from_model("show_about_your_organization", False),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PORTFOLIO_WIZARD_CONDITIONS = {} # type: ignore
|
||||||
|
# endregion
|
||||||
|
|
||||||
|
# region: Unlocking steps
|
||||||
|
# The conditions by which each step is "unlocked" or "locked"
|
||||||
|
REGULAR_UNLOCKING_STEPS = {
|
||||||
|
Step.ORGANIZATION_TYPE: lambda self: self.domain_request.generic_org_type is not None,
|
||||||
|
Step.TRIBAL_GOVERNMENT: lambda self: self.domain_request.tribe_name is not None,
|
||||||
|
Step.ORGANIZATION_FEDERAL: lambda self: self.domain_request.federal_type is not None,
|
||||||
|
Step.ORGANIZATION_ELECTION: lambda self: self.domain_request.is_election_board is not None,
|
||||||
|
Step.ORGANIZATION_CONTACT: lambda self: (
|
||||||
|
self.domain_request.federal_agency is not None
|
||||||
|
or self.domain_request.organization_name is not None
|
||||||
|
or self.domain_request.address_line1 is not None
|
||||||
|
or self.domain_request.city is not None
|
||||||
|
or self.domain_request.state_territory is not None
|
||||||
|
or self.domain_request.zipcode is not None
|
||||||
|
or self.domain_request.urbanization is not None
|
||||||
|
),
|
||||||
|
Step.ABOUT_YOUR_ORGANIZATION: lambda self: self.domain_request.about_your_organization is not None,
|
||||||
|
Step.SENIOR_OFFICIAL: lambda self: self.domain_request.senior_official is not None,
|
||||||
|
Step.CURRENT_SITES: lambda self: (
|
||||||
|
self.domain_request.current_websites.exists() or self.domain_request.requested_domain is not None
|
||||||
|
),
|
||||||
|
Step.DOTGOV_DOMAIN: lambda self: self.domain_request.requested_domain is not None,
|
||||||
|
Step.PURPOSE: lambda self: self.domain_request.purpose is not None,
|
||||||
|
Step.OTHER_CONTACTS: lambda self: (
|
||||||
|
self.domain_request.other_contacts.exists() or self.domain_request.no_other_contacts_rationale is not None
|
||||||
|
),
|
||||||
|
Step.ADDITIONAL_DETAILS: lambda self: (
|
||||||
|
# Additional details is complete as long as "has anything else" and "has cisa rep" are not None
|
||||||
|
(
|
||||||
|
self.domain_request.has_anything_else_text is not None
|
||||||
|
and self.domain_request.has_cisa_representative is not None
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Step.REQUIREMENTS: lambda self: self.domain_request.is_policy_acknowledged is not None,
|
||||||
|
Step.REVIEW: lambda self: self.domain_request.is_policy_acknowledged is not None,
|
||||||
|
}
|
||||||
|
|
||||||
|
PORTFOLIO_UNLOCKING_STEPS = {
|
||||||
|
PortfolioDomainRequestStep.REQUESTING_ENTITY: lambda self: self.domain_request.organization_name is not None,
|
||||||
|
PortfolioDomainRequestStep.CURRENT_SITES: lambda self: (
|
||||||
|
self.domain_request.current_websites.exists() or self.domain_request.requested_domain is not None
|
||||||
|
),
|
||||||
|
PortfolioDomainRequestStep.DOTGOV_DOMAIN: lambda self: self.domain_request.requested_domain is not None,
|
||||||
|
PortfolioDomainRequestStep.PURPOSE: lambda self: self.domain_request.purpose is not None,
|
||||||
|
PortfolioDomainRequestStep.ADDITIONAL_DETAILS: lambda self: self.domain_request.anything_else is not None,
|
||||||
|
PortfolioDomainRequestStep.REQUIREMENTS: lambda self: self.domain_request.is_policy_acknowledged is not None,
|
||||||
|
PortfolioDomainRequestStep.REVIEW: lambda self: self.domain_request.is_policy_acknowledged is not None,
|
||||||
|
}
|
||||||
|
# endregion
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.steps = StepsHelper(self)
|
self.titles = {}
|
||||||
|
self.wizard_conditions = {}
|
||||||
|
self.unlocking_steps = {}
|
||||||
|
self.steps = None
|
||||||
|
# Configure titles, wizard_conditions, unlocking_steps, and steps
|
||||||
|
self.configure_step_options()
|
||||||
self._domain_request = None # for caching
|
self._domain_request = None # for caching
|
||||||
|
|
||||||
|
def configure_step_options(self):
|
||||||
|
"""Changes which steps are available to the user based on self.is_portfolio.
|
||||||
|
This may change on the fly, so we need to evaluate it on the fly.
|
||||||
|
|
||||||
|
Using this information, we then set three configuration variables.
|
||||||
|
- self.titles => Returns the page titles for each step
|
||||||
|
- self.wizard_conditions => Conditionally shows / hides certain steps
|
||||||
|
- self.unlocking_steps => Determines what steps are locked/unlocked
|
||||||
|
|
||||||
|
Then, we create self.steps.
|
||||||
|
"""
|
||||||
|
if self.is_portfolio:
|
||||||
|
self.titles = self.PORTFOLIO_TITLES
|
||||||
|
self.wizard_conditions = self.PORTFOLIO_WIZARD_CONDITIONS
|
||||||
|
self.unlocking_steps = self.PORTFOLIO_UNLOCKING_STEPS
|
||||||
|
else:
|
||||||
|
self.titles = self.REGULAR_TITLES
|
||||||
|
self.wizard_conditions = self.REGULAR_WIZARD_CONDITIONS
|
||||||
|
self.unlocking_steps = self.REGULAR_UNLOCKING_STEPS
|
||||||
|
self.steps = StepsHelper(self)
|
||||||
|
|
||||||
def has_pk(self):
|
def has_pk(self):
|
||||||
"""Does this wizard know about a DomainRequest database record?"""
|
"""Does this wizard know about a DomainRequest database record?"""
|
||||||
return "domain_request_id" in self.storage
|
return "domain_request_id" in self.storage
|
||||||
|
|
||||||
|
def get_step_enum(self):
|
||||||
|
"""Determines which step enum we should use for the wizard"""
|
||||||
|
return PortfolioDomainRequestStep if self.is_portfolio else Step
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def prefix(self):
|
def prefix(self):
|
||||||
"""Namespace the wizard to avoid clashes in session variable names."""
|
"""Namespace the wizard to avoid clashes in session variable names."""
|
||||||
|
@ -128,10 +226,16 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
||||||
|
|
||||||
# If a user is creating a request, we assume that perms are handled upstream
|
# If a user is creating a request, we assume that perms are handled upstream
|
||||||
if self.request.user.is_org_user(self.request):
|
if self.request.user.is_org_user(self.request):
|
||||||
|
portfolio = self.request.session.get("portfolio")
|
||||||
self._domain_request = DomainRequest.objects.create(
|
self._domain_request = DomainRequest.objects.create(
|
||||||
creator=self.request.user,
|
creator=self.request.user,
|
||||||
portfolio=self.request.session.get("portfolio"),
|
portfolio=portfolio,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Question for reviewers: we should probably be doing this right?
|
||||||
|
if portfolio and not self._domain_request.generic_org_type:
|
||||||
|
self._domain_request.generic_org_type = portfolio.organization_type
|
||||||
|
self._domain_request.save()
|
||||||
else:
|
else:
|
||||||
self._domain_request = DomainRequest.objects.create(creator=self.request.user)
|
self._domain_request = DomainRequest.objects.create(creator=self.request.user)
|
||||||
|
|
||||||
|
@ -190,6 +294,12 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
"""This method handles GET requests."""
|
"""This method handles GET requests."""
|
||||||
|
|
||||||
|
if not self.is_portfolio and self.request.user.is_org_user(request):
|
||||||
|
self.is_portfolio = True
|
||||||
|
# Configure titles, wizard_conditions, unlocking_steps, and steps
|
||||||
|
self.configure_step_options()
|
||||||
|
|
||||||
current_url = resolve(request.path_info).url_name
|
current_url = resolve(request.path_info).url_name
|
||||||
|
|
||||||
# if user visited via an "edit" url, associate the id of the
|
# if user visited via an "edit" url, associate the id of the
|
||||||
|
@ -209,7 +319,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
||||||
# Clear context so the prop getter won't create a request here.
|
# Clear context so the prop getter won't create a request here.
|
||||||
# Creating a request will be handled in the post method for the
|
# Creating a request will be handled in the post method for the
|
||||||
# intro page.
|
# intro page.
|
||||||
return render(request, "domain_request_intro.html", {})
|
return render(request, "domain_request_intro.html", {"hide_requests": True, "hide_domains": True})
|
||||||
else:
|
else:
|
||||||
return self.goto(self.steps.first)
|
return self.goto(self.steps.first)
|
||||||
|
|
||||||
|
@ -321,56 +431,9 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
||||||
return DomainRequest.objects.filter(creator=self.request.user, status__in=check_statuses)
|
return DomainRequest.objects.filter(creator=self.request.user, status__in=check_statuses)
|
||||||
|
|
||||||
def db_check_for_unlocking_steps(self):
|
def db_check_for_unlocking_steps(self):
|
||||||
"""Helper for get_context_data
|
"""Helper for get_context_data.
|
||||||
|
|
||||||
Queries the DB for a domain request and returns a list of unlocked steps."""
|
Queries the DB for a domain request and returns a list of unlocked steps."""
|
||||||
|
return [key for key, is_unlocked_checker in self.unlocking_steps.items() if is_unlocked_checker(self)]
|
||||||
# The way this works is as follows:
|
|
||||||
# Each step is assigned a true/false value to determine if it is
|
|
||||||
# "unlocked" or not. This dictionary of values is looped through
|
|
||||||
# at the end of this function and any step with a "true" value is
|
|
||||||
# added to a simple array that is returned at the end of this function.
|
|
||||||
# This array is eventually passed to the frontend context (eg. domain_request_sidebar.html),
|
|
||||||
# and is used to determine how steps appear in the side nav.
|
|
||||||
# It is worth noting that any step assigned "false" here will be EXCLUDED
|
|
||||||
# from the list of "unlocked" steps.
|
|
||||||
|
|
||||||
history_dict = {
|
|
||||||
"generic_org_type": self.domain_request.generic_org_type is not None,
|
|
||||||
"tribal_government": self.domain_request.tribe_name is not None,
|
|
||||||
"organization_federal": self.domain_request.federal_type is not None,
|
|
||||||
"organization_election": self.domain_request.is_election_board is not None,
|
|
||||||
"organization_contact": (
|
|
||||||
self.domain_request.federal_agency is not None
|
|
||||||
or self.domain_request.organization_name is not None
|
|
||||||
or self.domain_request.address_line1 is not None
|
|
||||||
or self.domain_request.city is not None
|
|
||||||
or self.domain_request.state_territory is not None
|
|
||||||
or self.domain_request.zipcode is not None
|
|
||||||
or self.domain_request.urbanization is not None
|
|
||||||
),
|
|
||||||
"about_your_organization": self.domain_request.about_your_organization is not None,
|
|
||||||
"senior_official": self.domain_request.senior_official is not None,
|
|
||||||
"current_sites": (
|
|
||||||
self.domain_request.current_websites.exists() or self.domain_request.requested_domain is not None
|
|
||||||
),
|
|
||||||
"dotgov_domain": self.domain_request.requested_domain is not None,
|
|
||||||
"purpose": self.domain_request.purpose is not None,
|
|
||||||
"other_contacts": (
|
|
||||||
self.domain_request.other_contacts.exists()
|
|
||||||
or self.domain_request.no_other_contacts_rationale is not None
|
|
||||||
),
|
|
||||||
"additional_details": (
|
|
||||||
# Additional details is complete as long as "has anything else" and "has cisa rep" are not None
|
|
||||||
(
|
|
||||||
self.domain_request.has_anything_else_text is not None
|
|
||||||
and self.domain_request.has_cisa_representative is not None
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"requirements": self.domain_request.is_policy_acknowledged is not None,
|
|
||||||
"review": self.domain_request.is_policy_acknowledged is not None,
|
|
||||||
}
|
|
||||||
return [key for key, value in history_dict.items() if value]
|
|
||||||
|
|
||||||
def get_context_data(self):
|
def get_context_data(self):
|
||||||
"""Define context for access on all wizard pages."""
|
"""Define context for access on all wizard pages."""
|
||||||
|
@ -380,17 +443,22 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
||||||
requested_domain_name = self.domain_request.requested_domain.name
|
requested_domain_name = self.domain_request.requested_domain.name
|
||||||
|
|
||||||
context_stuff = {}
|
context_stuff = {}
|
||||||
if DomainRequest._form_complete(self.domain_request, self.request):
|
|
||||||
|
# Note: we will want to consolidate the non_org_steps_complete check into the same check that
|
||||||
|
# org_steps_complete is using at some point.
|
||||||
|
non_org_steps_complete = DomainRequest._form_complete(self.domain_request, self.request)
|
||||||
|
org_steps_complete = len(self.db_check_for_unlocking_steps()) == len(self.steps)
|
||||||
|
if (not self.is_portfolio and non_org_steps_complete) or (self.is_portfolio and org_steps_complete):
|
||||||
modal_button = '<button type="submit" ' 'class="usa-button" ' ">Submit request</button>"
|
modal_button = '<button type="submit" ' 'class="usa-button" ' ">Submit request</button>"
|
||||||
context_stuff = {
|
context_stuff = {
|
||||||
"not_form": False,
|
"not_form": False,
|
||||||
"form_titles": self.TITLES,
|
"form_titles": self.titles,
|
||||||
"steps": self.steps,
|
"steps": self.steps,
|
||||||
"visited": self.storage.get("step_history", []),
|
"visited": self.storage.get("step_history", []),
|
||||||
"is_federal": self.domain_request.is_federal(),
|
"is_federal": self.domain_request.is_federal(),
|
||||||
"modal_button": modal_button,
|
"modal_button": modal_button,
|
||||||
"modal_heading": "You are about to submit a domain request for "
|
"modal_heading": "You are about to submit a domain request for ",
|
||||||
+ str(self.domain_request.requested_domain),
|
"domain_name_modal": str(self.domain_request.requested_domain),
|
||||||
"modal_description": "Once you submit this request, you won’t be able to edit it until we review it.\
|
"modal_description": "Once you submit this request, you won’t be able to edit it until we review it.\
|
||||||
You’ll only be able to withdraw your request.",
|
You’ll only be able to withdraw your request.",
|
||||||
"review_form_is_complete": True,
|
"review_form_is_complete": True,
|
||||||
|
@ -401,7 +469,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
||||||
modal_button = '<button type="button" class="usa-button" data-close-modal>Return to request</button>'
|
modal_button = '<button type="button" class="usa-button" data-close-modal>Return to request</button>'
|
||||||
context_stuff = {
|
context_stuff = {
|
||||||
"not_form": True,
|
"not_form": True,
|
||||||
"form_titles": self.TITLES,
|
"form_titles": self.titles,
|
||||||
"steps": self.steps,
|
"steps": self.steps,
|
||||||
"visited": self.storage.get("step_history", []),
|
"visited": self.storage.get("step_history", []),
|
||||||
"is_federal": self.domain_request.is_federal(),
|
"is_federal": self.domain_request.is_federal(),
|
||||||
|
@ -413,14 +481,19 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
||||||
"user": self.request.user,
|
"user": self.request.user,
|
||||||
"requested_domain__name": requested_domain_name,
|
"requested_domain__name": requested_domain_name,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Hides the requests and domains buttons in the navbar
|
||||||
|
context_stuff["hide_requests"] = self.is_portfolio
|
||||||
|
context_stuff["hide_domains"] = self.is_portfolio
|
||||||
|
|
||||||
return context_stuff
|
return context_stuff
|
||||||
|
|
||||||
def get_step_list(self) -> list:
|
def get_step_list(self) -> list:
|
||||||
"""Dynamically generated list of steps in the form wizard."""
|
"""Dynamically generated list of steps in the form wizard."""
|
||||||
return request_step_list(self)
|
return request_step_list(self, self.get_step_enum())
|
||||||
|
|
||||||
def goto(self, step):
|
def goto(self, step):
|
||||||
if step == "generic_org_type":
|
if step == "generic_org_type" or step == "portfolio_requesting_entity":
|
||||||
# We need to avoid creating a new domain request if the user
|
# We need to avoid creating a new domain request if the user
|
||||||
# clicks the back button
|
# clicks the back button
|
||||||
self.request.session["new_request"] = False
|
self.request.session["new_request"] = False
|
||||||
|
@ -443,21 +516,21 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs) -> HttpResponse:
|
def post(self, request, *args, **kwargs) -> HttpResponse:
|
||||||
"""This method handles POST requests."""
|
"""This method handles POST requests."""
|
||||||
|
if not self.is_portfolio and self.request.user.is_org_user(request): # type: ignore
|
||||||
|
self.is_portfolio = True
|
||||||
|
# Configure titles, wizard_conditions, unlocking_steps, and steps
|
||||||
|
self.configure_step_options()
|
||||||
|
|
||||||
# which button did the user press?
|
# which button did the user press?
|
||||||
button: str = request.POST.get("submit_button", "")
|
button: str = request.POST.get("submit_button", "")
|
||||||
# If a user hits the new request url directly
|
|
||||||
if "new_request" not in request.session:
|
if "new_request" not in request.session:
|
||||||
request.session["new_request"] = True
|
request.session["new_request"] = True
|
||||||
|
|
||||||
# if user has acknowledged the intro message
|
# if user has acknowledged the intro message
|
||||||
if button == "intro_acknowledge":
|
if button == "intro_acknowledge":
|
||||||
if request.path_info == self.NEW_URL_NAME:
|
# Split into a function: C901 'DomainRequestWizard.post' is too complex (11)
|
||||||
|
self.handle_intro_acknowledge(request)
|
||||||
if self.request.session["new_request"] is True:
|
|
||||||
# This will trigger the domain_request getter into creating a new DomainRequest
|
|
||||||
del self.storage
|
|
||||||
|
|
||||||
return self.goto(self.steps.first)
|
|
||||||
|
|
||||||
# if accessing this class directly, redirect to the first step
|
# if accessing this class directly, redirect to the first step
|
||||||
if self.__class__ == DomainRequestWizard:
|
if self.__class__ == DomainRequestWizard:
|
||||||
|
@ -488,6 +561,14 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
||||||
# otherwise, proceed as normal
|
# otherwise, proceed as normal
|
||||||
return self.goto_next_step()
|
return self.goto_next_step()
|
||||||
|
|
||||||
|
def handle_intro_acknowledge(self, request):
|
||||||
|
"""If we are starting a new request, clear storage
|
||||||
|
and redirect to the first step"""
|
||||||
|
if request.path_info == self.NEW_URL_NAME:
|
||||||
|
if self.request.session["new_request"] is True:
|
||||||
|
del self.storage
|
||||||
|
return self.goto(self.steps.first)
|
||||||
|
|
||||||
def save(self, forms: list):
|
def save(self, forms: list):
|
||||||
"""
|
"""
|
||||||
Unpack the form responses onto the model object properties.
|
Unpack the form responses onto the model object properties.
|
||||||
|
@ -501,24 +582,22 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
||||||
|
|
||||||
# TODO - this is a WIP until the domain request experience for portfolios is complete
|
# TODO - this is a WIP until the domain request experience for portfolios is complete
|
||||||
class PortfolioDomainRequestWizard(DomainRequestWizard):
|
class PortfolioDomainRequestWizard(DomainRequestWizard):
|
||||||
StepEnum: PortfolioDomainRequestStep = PortfolioDomainRequestStep # type: ignore
|
is_portfolio = True
|
||||||
|
|
||||||
TITLES = {
|
|
||||||
StepEnum.REQUESTING_ENTITY: _("Requesting entity"),
|
|
||||||
StepEnum.CURRENT_SITES: _("Current websites"),
|
|
||||||
StepEnum.DOTGOV_DOMAIN: _(".gov domain"),
|
|
||||||
StepEnum.PURPOSE: _("Purpose of your domain"),
|
|
||||||
StepEnum.ADDITIONAL_DETAILS: _("Additional details"),
|
|
||||||
StepEnum.REQUIREMENTS: _("Requirements for operating a .gov domain"),
|
|
||||||
# Step.REVIEW: _("Review and submit your domain request"),
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self.steps = StepsHelper(self)
|
|
||||||
self._domain_request = None # for caching
|
|
||||||
|
|
||||||
|
|
||||||
|
# Portfolio pages
|
||||||
|
class RequestingEntity(DomainRequestWizard):
|
||||||
|
template_name = "domain_request_requesting_entity.html"
|
||||||
|
forms = [forms.RequestingEntityForm]
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioAdditionalDetails(DomainRequestWizard):
|
||||||
|
template_name = "portfolio_domain_request_additional_details.html"
|
||||||
|
|
||||||
|
forms = [forms.AnythingElseForm]
|
||||||
|
|
||||||
|
|
||||||
|
# Non-portfolio pages
|
||||||
class OrganizationType(DomainRequestWizard):
|
class OrganizationType(DomainRequestWizard):
|
||||||
template_name = "domain_request_org_type.html"
|
template_name = "domain_request_org_type.html"
|
||||||
forms = [forms.OrganizationTypeForm]
|
forms = [forms.OrganizationTypeForm]
|
||||||
|
@ -698,7 +777,7 @@ class Review(DomainRequestWizard):
|
||||||
if DomainRequest._form_complete(self.domain_request, self.request) is False:
|
if DomainRequest._form_complete(self.domain_request, self.request) is False:
|
||||||
logger.warning("User arrived at review page with an incomplete form.")
|
logger.warning("User arrived at review page with an incomplete form.")
|
||||||
context = super().get_context_data()
|
context = super().get_context_data()
|
||||||
context["Step"] = Step.__members__
|
context["Step"] = self.get_step_enum().__members__
|
||||||
context["domain_request"] = self.domain_request
|
context["domain_request"] = self.domain_request
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
@ -899,9 +978,9 @@ class PortfolioDomainRequestStatusViewOnly(DomainRequestPortfolioViewonlyView):
|
||||||
# Create a temp wizard object to grab the step list
|
# Create a temp wizard object to grab the step list
|
||||||
wizard = PortfolioDomainRequestWizard()
|
wizard = PortfolioDomainRequestWizard()
|
||||||
wizard.request = self.request
|
wizard.request = self.request
|
||||||
context["Step"] = wizard.StepEnum.__members__
|
context["Step"] = PortfolioDomainRequestStep.__members__
|
||||||
context["steps"] = request_step_list(wizard)
|
context["steps"] = request_step_list(wizard, PortfolioDomainRequestStep)
|
||||||
context["form_titles"] = wizard.TITLES
|
context["form_titles"] = wizard.titles
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,45 +1,41 @@
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.db.models import Q
|
from django.db.models import Value, F, CharField, TextField, Q, Case, When
|
||||||
|
from django.db.models.functions import Concat, Coalesce
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.db.models.functions import Cast
|
||||||
|
|
||||||
from registrar.models.portfolio_invitation import PortfolioInvitation
|
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||||
from registrar.models.user import User
|
|
||||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||||
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def get_portfolio_members_json(request):
|
def get_portfolio_members_json(request):
|
||||||
"""Given the current request,
|
"""Fetch members (permissions and invitations) for the given portfolio."""
|
||||||
get all members that are associated with the given portfolio"""
|
|
||||||
portfolio = request.GET.get("portfolio")
|
portfolio = request.GET.get("portfolio")
|
||||||
member_ids = get_member_ids_from_request(request, portfolio)
|
|
||||||
objects = User.objects.filter(id__in=member_ids)
|
|
||||||
|
|
||||||
admin_ids = UserPortfolioPermission.objects.filter(
|
# Two initial querysets which will be combined
|
||||||
portfolio=portfolio,
|
permissions = initial_permissions_search(portfolio)
|
||||||
roles__overlap=[
|
invitations = initial_invitations_search(portfolio)
|
||||||
UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
|
|
||||||
],
|
|
||||||
).values_list("user__id", flat=True)
|
|
||||||
portfolio_invitation_emails = PortfolioInvitation.objects.filter(portfolio=portfolio).values_list(
|
|
||||||
"email", flat=True
|
|
||||||
)
|
|
||||||
|
|
||||||
unfiltered_total = objects.count()
|
# Get total across both querysets before applying filters
|
||||||
|
unfiltered_total = permissions.count() + invitations.count()
|
||||||
|
|
||||||
objects = apply_search(objects, request)
|
permissions = apply_search_term(permissions, request)
|
||||||
# objects = apply_status_filter(objects, request)
|
invitations = apply_search_term(invitations, request)
|
||||||
|
|
||||||
|
# Union the two querysets
|
||||||
|
objects = permissions.union(invitations)
|
||||||
objects = apply_sorting(objects, request)
|
objects = apply_sorting(objects, request)
|
||||||
|
|
||||||
paginator = Paginator(objects, 10)
|
paginator = Paginator(objects, 10)
|
||||||
page_number = request.GET.get("page", 1)
|
page_number = request.GET.get("page", 1)
|
||||||
page_obj = paginator.get_page(page_number)
|
page_obj = paginator.get_page(page_number)
|
||||||
members = [
|
|
||||||
serialize_members(request, portfolio, member, request.user, admin_ids, portfolio_invitation_emails)
|
members = [serialize_members(request, portfolio, item, request.user) for item in page_obj.object_list]
|
||||||
for member in page_obj.object_list
|
|
||||||
]
|
|
||||||
|
|
||||||
return JsonResponse(
|
return JsonResponse(
|
||||||
{
|
{
|
||||||
|
@ -54,71 +50,121 @@ def get_portfolio_members_json(request):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_member_ids_from_request(request, portfolio):
|
def initial_permissions_search(portfolio):
|
||||||
"""Given the current request,
|
"""Perform initial search for permissions before applying any filters."""
|
||||||
get all members that are associated with the given portfolio"""
|
permissions = UserPortfolioPermission.objects.filter(portfolio=portfolio)
|
||||||
member_ids = []
|
permissions = (
|
||||||
if portfolio:
|
permissions.select_related("user")
|
||||||
member_ids = UserPortfolioPermission.objects.filter(portfolio=portfolio).values_list("user__id", flat=True)
|
.annotate(
|
||||||
return member_ids
|
first_name=F("user__first_name"),
|
||||||
|
last_name=F("user__last_name"),
|
||||||
|
email_display=F("user__email"),
|
||||||
|
last_active=Cast(F("user__last_login"), output_field=TextField()), # Cast last_login to text
|
||||||
|
additional_permissions_display=F("additional_permissions"),
|
||||||
|
member_display=Case(
|
||||||
|
# If email is present and not blank, use email
|
||||||
|
When(Q(user__email__isnull=False) & ~Q(user__email=""), then=F("user__email")),
|
||||||
|
# If first name or last name is present, use concatenation of first_name + " " + last_name
|
||||||
|
When(
|
||||||
|
Q(user__first_name__isnull=False) | Q(user__last_name__isnull=False),
|
||||||
|
then=Concat(
|
||||||
|
Coalesce(F("user__first_name"), Value("")),
|
||||||
|
Value(" "),
|
||||||
|
Coalesce(F("user__last_name"), Value("")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# If neither, use an empty string
|
||||||
|
default=Value(""),
|
||||||
|
output_field=CharField(),
|
||||||
|
),
|
||||||
|
source=Value("permission", output_field=CharField()),
|
||||||
|
)
|
||||||
|
.values(
|
||||||
|
"id",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"email_display",
|
||||||
|
"last_active",
|
||||||
|
"roles",
|
||||||
|
"additional_permissions_display",
|
||||||
|
"member_display",
|
||||||
|
"source",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return permissions
|
||||||
|
|
||||||
|
|
||||||
def apply_search(queryset, request):
|
def initial_invitations_search(portfolio):
|
||||||
search_term = request.GET.get("search_term")
|
"""Perform initial invitations search before applying any filters."""
|
||||||
|
invitations = PortfolioInvitation.objects.filter(portfolio=portfolio)
|
||||||
|
invitations = invitations.annotate(
|
||||||
|
first_name=Value(None, output_field=CharField()),
|
||||||
|
last_name=Value(None, output_field=CharField()),
|
||||||
|
email_display=F("email"),
|
||||||
|
last_active=Value("Invited", output_field=TextField()),
|
||||||
|
additional_permissions_display=F("additional_permissions"),
|
||||||
|
member_display=F("email"),
|
||||||
|
source=Value("invitation", output_field=CharField()),
|
||||||
|
).values(
|
||||||
|
"id",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"email_display",
|
||||||
|
"last_active",
|
||||||
|
"roles",
|
||||||
|
"additional_permissions_display",
|
||||||
|
"member_display",
|
||||||
|
"source",
|
||||||
|
)
|
||||||
|
return invitations
|
||||||
|
|
||||||
|
|
||||||
|
def apply_search_term(queryset, request):
|
||||||
|
"""Apply search term to the queryset."""
|
||||||
|
search_term = request.GET.get("search_term", "").lower()
|
||||||
if search_term:
|
if search_term:
|
||||||
queryset = queryset.filter(
|
queryset = queryset.filter(
|
||||||
Q(username__icontains=search_term)
|
Q(first_name__icontains=search_term)
|
||||||
| Q(first_name__icontains=search_term)
|
|
||||||
| Q(last_name__icontains=search_term)
|
| Q(last_name__icontains=search_term)
|
||||||
| Q(email__icontains=search_term)
|
| Q(email_display__icontains=search_term)
|
||||||
)
|
)
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
def apply_sorting(queryset, request):
|
def apply_sorting(queryset, request):
|
||||||
|
"""Apply sorting to the queryset."""
|
||||||
sort_by = request.GET.get("sort_by", "id") # Default to 'id'
|
sort_by = request.GET.get("sort_by", "id") # Default to 'id'
|
||||||
order = request.GET.get("order", "asc") # Default to 'asc'
|
order = request.GET.get("order", "asc") # Default to 'asc'
|
||||||
|
# Adjust sort_by to match the annotated fields in the unioned queryset
|
||||||
if sort_by == "member":
|
if sort_by == "member":
|
||||||
sort_by = ["email", "first_name", "middle_name", "last_name"]
|
sort_by = "member_display"
|
||||||
else:
|
|
||||||
sort_by = [sort_by]
|
|
||||||
|
|
||||||
if order == "desc":
|
if order == "desc":
|
||||||
sort_by = [f"-{field}" for field in sort_by]
|
queryset = queryset.order_by(F(sort_by).desc())
|
||||||
|
else:
|
||||||
return queryset.order_by(*sort_by)
|
queryset = queryset.order_by(sort_by)
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
def serialize_members(request, portfolio, member, user, admin_ids, portfolio_invitation_emails):
|
def serialize_members(request, portfolio, item, user):
|
||||||
# ------- VIEW ONLY
|
# Check if the user can edit other users
|
||||||
# If not view_only (the user has permissions to edit/manage users), show the gear icon with "Manage" link.
|
user_can_edit_other_users = any(
|
||||||
# If view_only (the user only has view user permissions), show the "View" link (no gear icon).
|
user.has_perm(perm) for perm in ["registrar.full_access_permission", "registrar.change_user"]
|
||||||
# We check on user_group_permision to account for the upcoming "Manage portfolio" button on admin.
|
)
|
||||||
user_can_edit_other_users = False
|
|
||||||
for user_group_permission in ["registrar.full_access_permission", "registrar.change_user"]:
|
|
||||||
if user.has_perm(user_group_permission):
|
|
||||||
user_can_edit_other_users = True
|
|
||||||
break
|
|
||||||
|
|
||||||
view_only = not user.has_edit_members_portfolio_permission(portfolio) or not user_can_edit_other_users
|
view_only = not user.has_edit_members_portfolio_permission(portfolio) or not user_can_edit_other_users
|
||||||
|
|
||||||
# ------- USER STATUSES
|
is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in (item.get("roles") or [])
|
||||||
is_invited = member.email in portfolio_invitation_emails
|
action_url = reverse("member" if item["source"] == "permission" else "invitedmember", kwargs={"pk": item["id"]})
|
||||||
last_active = "Invited" if is_invited else "Unknown"
|
|
||||||
if member.last_login:
|
|
||||||
last_active = member.last_login.strftime("%b. %d, %Y")
|
|
||||||
is_admin = member.id in admin_ids
|
|
||||||
|
|
||||||
# ------- SERIALIZE
|
# Serialize member data
|
||||||
member_json = {
|
member_json = {
|
||||||
"id": member.id,
|
"id": item.get("id", ""),
|
||||||
"name": member.get_formatted_name(),
|
"name": " ".join(filter(None, [item.get("first_name", ""), item.get("last_name", "")])),
|
||||||
"email": member.email,
|
"email": item.get("email_display", ""),
|
||||||
|
"member_display": item.get("member_display", ""),
|
||||||
"is_admin": is_admin,
|
"is_admin": is_admin,
|
||||||
"last_active": last_active,
|
"last_active": item.get("last_active", ""),
|
||||||
"action_url": "#", # reverse("members", kwargs={"pk": member.id}), # TODO: Future ticket?
|
"action_url": action_url,
|
||||||
"action_label": ("View" if view_only else "Manage"),
|
"action_label": ("View" if view_only else "Manage"),
|
||||||
"svg_icon": ("visibility" if view_only else "settings"),
|
"svg_icon": ("visibility" if view_only else "settings"),
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,20 +3,30 @@ from django.http import Http404
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from registrar.forms.portfolio import PortfolioOrgAddressForm, PortfolioSeniorOfficialForm
|
from registrar.forms.portfolio import (
|
||||||
|
PortfolioInvitedMemberForm,
|
||||||
|
PortfolioMemberForm,
|
||||||
|
PortfolioOrgAddressForm,
|
||||||
|
PortfolioSeniorOfficialForm,
|
||||||
|
)
|
||||||
from registrar.models import Portfolio, User
|
from registrar.models import Portfolio, User
|
||||||
|
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||||
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||||
from registrar.views.utility.permission_views import (
|
from registrar.views.utility.permission_views import (
|
||||||
PortfolioDomainRequestsPermissionView,
|
PortfolioDomainRequestsPermissionView,
|
||||||
PortfolioDomainsPermissionView,
|
PortfolioDomainsPermissionView,
|
||||||
PortfolioBasePermissionView,
|
PortfolioBasePermissionView,
|
||||||
NoPortfolioDomainsPermissionView,
|
NoPortfolioDomainsPermissionView,
|
||||||
|
PortfolioInvitedMemberEditPermissionView,
|
||||||
|
PortfolioInvitedMemberPermissionView,
|
||||||
|
PortfolioMemberEditPermissionView,
|
||||||
|
PortfolioMemberPermissionView,
|
||||||
PortfolioMembersPermissionView,
|
PortfolioMembersPermissionView,
|
||||||
)
|
)
|
||||||
from django.views.generic import View
|
from django.views.generic import View
|
||||||
from django.views.generic.edit import FormMixin
|
from django.views.generic.edit import FormMixin
|
||||||
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -51,6 +61,155 @@ class PortfolioMembersView(PortfolioMembersPermissionView, View):
|
||||||
return render(request, "portfolio_members.html")
|
return render(request, "portfolio_members.html")
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioMemberView(PortfolioMemberPermissionView, View):
|
||||||
|
|
||||||
|
template_name = "portfolio_member.html"
|
||||||
|
|
||||||
|
def get(self, request, pk):
|
||||||
|
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
|
||||||
|
member = portfolio_permission.user
|
||||||
|
|
||||||
|
# We have to explicitely name these with member_ otherwise we'll have conflicts with context preprocessors
|
||||||
|
member_has_view_all_requests_portfolio_permission = member.has_view_all_requests_portfolio_permission(
|
||||||
|
portfolio_permission.portfolio
|
||||||
|
)
|
||||||
|
member_has_edit_request_portfolio_permission = member.has_edit_request_portfolio_permission(
|
||||||
|
portfolio_permission.portfolio
|
||||||
|
)
|
||||||
|
member_has_view_members_portfolio_permission = member.has_view_members_portfolio_permission(
|
||||||
|
portfolio_permission.portfolio
|
||||||
|
)
|
||||||
|
member_has_edit_members_portfolio_permission = member.has_edit_members_portfolio_permission(
|
||||||
|
portfolio_permission.portfolio
|
||||||
|
)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
self.template_name,
|
||||||
|
{
|
||||||
|
"edit_url": reverse("member-permissions", args=[pk]),
|
||||||
|
"portfolio_permission": portfolio_permission,
|
||||||
|
"member": member,
|
||||||
|
"member_has_view_all_requests_portfolio_permission": member_has_view_all_requests_portfolio_permission,
|
||||||
|
"member_has_edit_request_portfolio_permission": member_has_edit_request_portfolio_permission,
|
||||||
|
"member_has_view_members_portfolio_permission": member_has_view_members_portfolio_permission,
|
||||||
|
"member_has_edit_members_portfolio_permission": member_has_edit_members_portfolio_permission,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View):
|
||||||
|
|
||||||
|
template_name = "portfolio_member_permissions.html"
|
||||||
|
form_class = PortfolioMemberForm
|
||||||
|
|
||||||
|
def get(self, request, pk):
|
||||||
|
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
|
||||||
|
user = portfolio_permission.user
|
||||||
|
|
||||||
|
form = self.form_class(instance=portfolio_permission)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
self.template_name,
|
||||||
|
{
|
||||||
|
"form": form,
|
||||||
|
"member": user,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def post(self, request, pk):
|
||||||
|
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
|
||||||
|
user = portfolio_permission.user
|
||||||
|
|
||||||
|
form = self.form_class(request.POST, instance=portfolio_permission)
|
||||||
|
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
return redirect("member", pk=pk)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
self.template_name,
|
||||||
|
{
|
||||||
|
"form": form,
|
||||||
|
"member": user, # Pass the user object again to the template
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioInvitedMemberView(PortfolioInvitedMemberPermissionView, View):
|
||||||
|
|
||||||
|
template_name = "portfolio_member.html"
|
||||||
|
# form_class = PortfolioInvitedMemberForm
|
||||||
|
|
||||||
|
def get(self, request, pk):
|
||||||
|
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
|
||||||
|
# form = self.form_class(instance=portfolio_invitation)
|
||||||
|
|
||||||
|
# We have to explicitely name these with member_ otherwise we'll have conflicts with context preprocessors
|
||||||
|
member_has_view_all_requests_portfolio_permission = (
|
||||||
|
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS in portfolio_invitation.get_portfolio_permissions()
|
||||||
|
)
|
||||||
|
member_has_edit_request_portfolio_permission = (
|
||||||
|
UserPortfolioPermissionChoices.EDIT_REQUESTS in portfolio_invitation.get_portfolio_permissions()
|
||||||
|
)
|
||||||
|
member_has_view_members_portfolio_permission = (
|
||||||
|
UserPortfolioPermissionChoices.VIEW_MEMBERS in portfolio_invitation.get_portfolio_permissions()
|
||||||
|
)
|
||||||
|
member_has_edit_members_portfolio_permission = (
|
||||||
|
UserPortfolioPermissionChoices.EDIT_MEMBERS in portfolio_invitation.get_portfolio_permissions()
|
||||||
|
)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
self.template_name,
|
||||||
|
{
|
||||||
|
"edit_url": reverse("invitedmember-permissions", args=[pk]),
|
||||||
|
"portfolio_invitation": portfolio_invitation,
|
||||||
|
"member_has_view_all_requests_portfolio_permission": member_has_view_all_requests_portfolio_permission,
|
||||||
|
"member_has_edit_request_portfolio_permission": member_has_edit_request_portfolio_permission,
|
||||||
|
"member_has_view_members_portfolio_permission": member_has_view_members_portfolio_permission,
|
||||||
|
"member_has_edit_members_portfolio_permission": member_has_edit_members_portfolio_permission,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioInvitedMemberEditView(PortfolioInvitedMemberEditPermissionView, View):
|
||||||
|
|
||||||
|
template_name = "portfolio_member_permissions.html"
|
||||||
|
form_class = PortfolioInvitedMemberForm
|
||||||
|
|
||||||
|
def get(self, request, pk):
|
||||||
|
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
|
||||||
|
form = self.form_class(instance=portfolio_invitation)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
self.template_name,
|
||||||
|
{
|
||||||
|
"form": form,
|
||||||
|
"invitation": portfolio_invitation,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def post(self, request, pk):
|
||||||
|
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
|
||||||
|
form = self.form_class(request.POST, instance=portfolio_invitation)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
return redirect("invitedmember", pk=pk)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
self.template_name,
|
||||||
|
{
|
||||||
|
"form": form,
|
||||||
|
"invitation": portfolio_invitation, # Pass the user object again to the template
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PortfolioNoDomainsView(NoPortfolioDomainsPermissionView, View):
|
class PortfolioNoDomainsView(NoPortfolioDomainsPermissionView, View):
|
||||||
"""Some users have access to the underlying portfolio, but not any domains.
|
"""Some users have access to the underlying portfolio, but not any domains.
|
||||||
This is a custom view which explains that to the user - and denotes who to contact.
|
This is a custom view which explains that to the user - and denotes who to contact.
|
||||||
|
|
|
@ -169,6 +169,17 @@ class ExportDataTypeUser(View):
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class ExportDataTypeRequests(View):
|
||||||
|
"""Returns a domain requests report for a given user on the request"""
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
response = HttpResponse(content_type="text/csv")
|
||||||
|
response["Content-Disposition"] = 'attachment; filename="domain-requests.csv"'
|
||||||
|
csv_export.DomainRequestsDataType.exporting_dr_data_to_csv(response, request=request)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
class ExportDataFull(View):
|
class ExportDataFull(View):
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
# Smaller export based on 1
|
# Smaller export based on 1
|
||||||
|
|
|
@ -4,7 +4,7 @@ from django.forms.models import model_to_dict
|
||||||
from registrar.models import FederalAgency, SeniorOfficial, DomainRequest
|
from registrar.models import FederalAgency, SeniorOfficial, DomainRequest
|
||||||
from django.contrib.admin.views.decorators import staff_member_required
|
from django.contrib.admin.views.decorators import staff_member_required
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from registrar.utility.admin_helpers import get_all_action_needed_reason_emails
|
from registrar.utility.admin_helpers import get_action_needed_reason_default_email, get_rejection_reason_default_email
|
||||||
from registrar.models.portfolio import Portfolio
|
from registrar.models.portfolio import Portfolio
|
||||||
from registrar.utility.constants import BranchChoices
|
from registrar.utility.constants import BranchChoices
|
||||||
|
|
||||||
|
@ -88,5 +88,30 @@ def get_action_needed_email_for_user_json(request):
|
||||||
return JsonResponse({"error": "No domain_request_id specified"}, status=404)
|
return JsonResponse({"error": "No domain_request_id specified"}, status=404)
|
||||||
|
|
||||||
domain_request = DomainRequest.objects.filter(id=domain_request_id).first()
|
domain_request = DomainRequest.objects.filter(id=domain_request_id).first()
|
||||||
emails = get_all_action_needed_reason_emails(request, domain_request)
|
|
||||||
return JsonResponse({"action_needed_email": emails.get(reason)}, status=200)
|
email = get_action_needed_reason_default_email(domain_request, reason)
|
||||||
|
return JsonResponse({"email": email}, status=200)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@staff_member_required
|
||||||
|
def get_rejection_email_for_user_json(request):
|
||||||
|
"""Returns a default rejection email for a given user"""
|
||||||
|
|
||||||
|
# This API is only accessible to admins and analysts
|
||||||
|
superuser_perm = request.user.has_perm("registrar.full_access_permission")
|
||||||
|
analyst_perm = request.user.has_perm("registrar.analyst_access_permission")
|
||||||
|
if not request.user.is_authenticated or not any([analyst_perm, superuser_perm]):
|
||||||
|
return JsonResponse({"error": "You do not have access to this resource"}, status=403)
|
||||||
|
|
||||||
|
reason = request.GET.get("reason")
|
||||||
|
domain_request_id = request.GET.get("domain_request_id")
|
||||||
|
if not reason:
|
||||||
|
return JsonResponse({"error": "No reason specified"}, status=404)
|
||||||
|
|
||||||
|
if not domain_request_id:
|
||||||
|
return JsonResponse({"error": "No domain_request_id specified"}, status=404)
|
||||||
|
|
||||||
|
domain_request = DomainRequest.objects.filter(id=domain_request_id).first()
|
||||||
|
email = get_rejection_reason_default_email(domain_request, reason)
|
||||||
|
return JsonResponse({"email": email}, status=200)
|
||||||
|
|
|
@ -384,10 +384,32 @@ class DomainRequestWizardPermission(PermissionsLoginMixin):
|
||||||
The user is in self.request.user
|
The user is in self.request.user
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
if not self.request.user.is_authenticated:
|
||||||
|
return False
|
||||||
|
|
||||||
# The user has an ineligible flag
|
# The user has an ineligible flag
|
||||||
if self.request.user.is_restricted():
|
if self.request.user.is_restricted():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# If the user is an org user and doesn't have add/edit perms, forbid this
|
||||||
|
if self.request.user.is_org_user(self.request):
|
||||||
|
portfolio = self.request.session.get("portfolio")
|
||||||
|
if not self.request.user.has_edit_request_portfolio_permission(portfolio):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# user needs to be the creator of the domain request to edit it.
|
||||||
|
id = self.kwargs.get("id") if hasattr(self, "kwargs") else None
|
||||||
|
if not id:
|
||||||
|
domain_request_wizard = self.request.session.get("wizard_domain_request")
|
||||||
|
if domain_request_wizard:
|
||||||
|
id = domain_request_wizard.get("domain_request_id")
|
||||||
|
|
||||||
|
# If no id is provided, we can assume that the user is starting a new request.
|
||||||
|
# If one IS provided, check that they are the original creator of it.
|
||||||
|
if id:
|
||||||
|
if not DomainRequest.objects.filter(creator=self.request.user, id=id).exists():
|
||||||
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@ -490,7 +512,81 @@ class PortfolioMembersPermission(PortfolioBasePermission):
|
||||||
up from the portfolio's primary key in self.kwargs["pk"]"""
|
up from the portfolio's primary key in self.kwargs["pk"]"""
|
||||||
|
|
||||||
portfolio = self.request.session.get("portfolio")
|
portfolio = self.request.session.get("portfolio")
|
||||||
if not self.request.user.has_view_members_portfolio_permission(portfolio):
|
if not self.request.user.has_view_members_portfolio_permission(
|
||||||
|
portfolio
|
||||||
|
) and not self.request.user.has_edit_members_portfolio_permission(portfolio):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return super().has_permission()
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioMemberPermission(PortfolioBasePermission):
|
||||||
|
"""Permission mixin that allows access to portfolio member pages if user
|
||||||
|
has access, otherwise 403"""
|
||||||
|
|
||||||
|
def has_permission(self):
|
||||||
|
"""Check if this user has access to members for this portfolio.
|
||||||
|
|
||||||
|
The user is in self.request.user and the portfolio can be looked
|
||||||
|
up from the portfolio's primary key in self.kwargs["pk"]"""
|
||||||
|
|
||||||
|
portfolio = self.request.session.get("portfolio")
|
||||||
|
if not self.request.user.has_view_members_portfolio_permission(
|
||||||
|
portfolio
|
||||||
|
) and not self.request.user.has_edit_members_portfolio_permission(portfolio):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return super().has_permission()
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioMemberEditPermission(PortfolioBasePermission):
|
||||||
|
"""Permission mixin that allows access to portfolio member pages if user
|
||||||
|
has access to edit, otherwise 403"""
|
||||||
|
|
||||||
|
def has_permission(self):
|
||||||
|
"""Check if this user has access to members for this portfolio.
|
||||||
|
|
||||||
|
The user is in self.request.user and the portfolio can be looked
|
||||||
|
up from the portfolio's primary key in self.kwargs["pk"]"""
|
||||||
|
|
||||||
|
portfolio = self.request.session.get("portfolio")
|
||||||
|
if not self.request.user.has_edit_members_portfolio_permission(portfolio):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return super().has_permission()
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioInvitedMemberPermission(PortfolioBasePermission):
|
||||||
|
"""Permission mixin that allows access to portfolio invited member pages if user
|
||||||
|
has access, otherwise 403"""
|
||||||
|
|
||||||
|
def has_permission(self):
|
||||||
|
"""Check if this user has access to members for this portfolio.
|
||||||
|
|
||||||
|
The user is in self.request.user and the portfolio can be looked
|
||||||
|
up from the portfolio's primary key in self.kwargs["pk"]"""
|
||||||
|
|
||||||
|
portfolio = self.request.session.get("portfolio")
|
||||||
|
if not self.request.user.has_view_members_portfolio_permission(
|
||||||
|
portfolio
|
||||||
|
) and not self.request.user.has_edit_members_portfolio_permission(portfolio):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return super().has_permission()
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioInvitedMemberEditPermission(PortfolioBasePermission):
|
||||||
|
"""Permission mixin that allows access to portfolio invited member pages if user
|
||||||
|
has access to edit, otherwise 403"""
|
||||||
|
|
||||||
|
def has_permission(self):
|
||||||
|
"""Check if this user has access to members for this portfolio.
|
||||||
|
|
||||||
|
The user is in self.request.user and the portfolio can be looked
|
||||||
|
up from the portfolio's primary key in self.kwargs["pk"]"""
|
||||||
|
|
||||||
|
portfolio = self.request.session.get("portfolio")
|
||||||
|
if not self.request.user.has_edit_members_portfolio_permission(portfolio):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return super().has_permission()
|
return super().has_permission()
|
||||||
|
|
|
@ -15,10 +15,14 @@ from .mixins import (
|
||||||
DomainRequestWizardPermission,
|
DomainRequestWizardPermission,
|
||||||
PortfolioDomainRequestsPermission,
|
PortfolioDomainRequestsPermission,
|
||||||
PortfolioDomainsPermission,
|
PortfolioDomainsPermission,
|
||||||
|
PortfolioInvitedMemberEditPermission,
|
||||||
|
PortfolioInvitedMemberPermission,
|
||||||
|
PortfolioMemberEditPermission,
|
||||||
UserDeleteDomainRolePermission,
|
UserDeleteDomainRolePermission,
|
||||||
UserProfilePermission,
|
UserProfilePermission,
|
||||||
PortfolioBasePermission,
|
PortfolioBasePermission,
|
||||||
PortfolioMembersPermission,
|
PortfolioMembersPermission,
|
||||||
|
PortfolioMemberPermission,
|
||||||
DomainRequestPortfolioViewonlyPermission,
|
DomainRequestPortfolioViewonlyPermission,
|
||||||
)
|
)
|
||||||
import logging
|
import logging
|
||||||
|
@ -253,7 +257,41 @@ class PortfolioDomainRequestsPermissionView(PortfolioDomainRequestsPermission, P
|
||||||
|
|
||||||
|
|
||||||
class PortfolioMembersPermissionView(PortfolioMembersPermission, PortfolioBasePermissionView, abc.ABC):
|
class PortfolioMembersPermissionView(PortfolioMembersPermission, PortfolioBasePermissionView, abc.ABC):
|
||||||
"""Abstract base view for portfolio domain request views that enforces permissions.
|
"""Abstract base view for portfolio members views that enforces permissions.
|
||||||
|
|
||||||
|
This abstract view cannot be instantiated. Actual views must specify
|
||||||
|
`template_name`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioMemberPermissionView(PortfolioMemberPermission, PortfolioBasePermissionView, abc.ABC):
|
||||||
|
"""Abstract base view for portfolio member views that enforces permissions.
|
||||||
|
|
||||||
|
This abstract view cannot be instantiated. Actual views must specify
|
||||||
|
`template_name`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioMemberEditPermissionView(PortfolioMemberEditPermission, PortfolioBasePermissionView, abc.ABC):
|
||||||
|
"""Abstract base view for portfolio member edit views that enforces permissions.
|
||||||
|
|
||||||
|
This abstract view cannot be instantiated. Actual views must specify
|
||||||
|
`template_name`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioInvitedMemberPermissionView(PortfolioInvitedMemberPermission, PortfolioBasePermissionView, abc.ABC):
|
||||||
|
"""Abstract base view for portfolio member views that enforces permissions.
|
||||||
|
|
||||||
|
This abstract view cannot be instantiated. Actual views must specify
|
||||||
|
`template_name`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioInvitedMemberEditPermissionView(
|
||||||
|
PortfolioInvitedMemberEditPermission, PortfolioBasePermissionView, abc.ABC
|
||||||
|
):
|
||||||
|
"""Abstract base view for portfolio member edit views that enforces permissions.
|
||||||
|
|
||||||
This abstract view cannot be instantiated. Actual views must specify
|
This abstract view cannot be instantiated. Actual views must specify
|
||||||
`template_name`.
|
`template_name`.
|
||||||
|
|
|
@ -71,6 +71,7 @@
|
||||||
10038 OUTOFSCOPE http://app:8080/domain_requests/
|
10038 OUTOFSCOPE http://app:8080/domain_requests/
|
||||||
10038 OUTOFSCOPE http://app:8080/domains/
|
10038 OUTOFSCOPE http://app:8080/domains/
|
||||||
10038 OUTOFSCOPE http://app:8080/organization/
|
10038 OUTOFSCOPE http://app:8080/organization/
|
||||||
|
10038 OUTOFSCOPE http://app:8080/permissions
|
||||||
10038 OUTOFSCOPE http://app:8080/suborganization/
|
10038 OUTOFSCOPE http://app:8080/suborganization/
|
||||||
10038 OUTOFSCOPE http://app:8080/transfer/
|
10038 OUTOFSCOPE http://app:8080/transfer/
|
||||||
# This URL always returns 404, so include it as well.
|
# This URL always returns 404, so include it as well.
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue