mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-05-19 19:09:22 +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.functions import Concat, Coalesce
|
||||
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.shortcuts import redirect
|
||||
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 waffle.admin import FlagAdmin
|
||||
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.utility.constants import BranchChoices
|
||||
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
|
||||
|
@ -190,11 +190,11 @@ class PortfolioInvitationAdminForm(UserChangeForm):
|
|||
model = models.PortfolioInvitation
|
||||
fields = "__all__"
|
||||
widgets = {
|
||||
"portfolio_roles": FilteredSelectMultipleArrayWidget(
|
||||
"portfolio_roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices
|
||||
"roles": FilteredSelectMultipleArrayWidget(
|
||||
"roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices
|
||||
),
|
||||
"portfolio_additional_permissions": FilteredSelectMultipleArrayWidget(
|
||||
"portfolio_additional_permissions",
|
||||
"additional_permissions": FilteredSelectMultipleArrayWidget(
|
||||
"additional_permissions",
|
||||
is_stacked=False,
|
||||
choices=UserPortfolioPermissionChoices.choices,
|
||||
),
|
||||
|
@ -237,6 +237,7 @@ class DomainRequestAdminForm(forms.ModelForm):
|
|||
}
|
||||
labels = {
|
||||
"action_needed_reason_email": "Email",
|
||||
"rejection_reason_email": "Email",
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
@ -1408,8 +1409,8 @@ class PortfolioInvitationAdmin(ListHeaderAdmin):
|
|||
list_display = [
|
||||
"email",
|
||||
"portfolio",
|
||||
"portfolio_roles",
|
||||
"portfolio_additional_permissions",
|
||||
"roles",
|
||||
"additional_permissions",
|
||||
"status",
|
||||
]
|
||||
|
||||
|
@ -1750,6 +1751,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
"status_history",
|
||||
"status",
|
||||
"rejection_reason",
|
||||
"rejection_reason_email",
|
||||
"action_needed_reason",
|
||||
"action_needed_reason_email",
|
||||
"investigator",
|
||||
|
@ -1905,25 +1907,11 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
# Get the original domain request from the database.
|
||||
original_obj = models.DomainRequest.objects.get(pk=obj.pk)
|
||||
|
||||
# == Handle action_needed_reason == #
|
||||
|
||||
reason_changed = obj.action_needed_reason != original_obj.action_needed_reason
|
||||
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 action needed and rejected emails == #
|
||||
# Edge case: this logic is handled by javascript, so contexts outside that must be handled
|
||||
obj = self._handle_custom_emails(obj)
|
||||
|
||||
# == Handle allowed emails == #
|
||||
if obj.status in DomainRequest.get_statuses_that_send_emails() and not settings.IS_PRODUCTION:
|
||||
self._check_for_valid_email(request, obj)
|
||||
|
||||
|
@ -1939,6 +1927,15 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
if should_save:
|
||||
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):
|
||||
"""Certain emails are whitelisted in non-production environments,
|
||||
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
|
||||
# weird issues down the line. Instead, we should block this.
|
||||
# NEEDS A UNIT TEST
|
||||
should_proceed = False
|
||||
return should_proceed
|
||||
return (obj, should_proceed)
|
||||
|
||||
request_is_not_approved = obj.status != models.DomainRequest.DomainRequestStatus.APPROVED
|
||||
if request_is_not_approved and not obj.domain_is_not_active():
|
||||
# If an admin tried to set an approved domain request to
|
||||
# another status and the related domain is already
|
||||
# active, shortcut the action and throw a friendly
|
||||
# error message. This action would still not go through
|
||||
# shortcut or not as the rules are duplicated on the model,
|
||||
# but the error would be an ugly Django error screen.
|
||||
obj_is_not_approved = obj.status != models.DomainRequest.DomainRequestStatus.APPROVED
|
||||
if obj_is_not_approved and not obj.domain_is_not_active():
|
||||
# REDUNDANT CHECK / ERROR SCREEN AVOIDANCE:
|
||||
# This action (moving a request from approved to
|
||||
# another status) when the domain is already active (READY),
|
||||
# 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.
|
||||
# This avoids an ugly Django error screen.
|
||||
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:
|
||||
# This condition should never be triggered.
|
||||
# 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
|
||||
|
||||
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
|
||||
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
@ -501,82 +438,110 @@ function initializeWidgetOnList(list, parentId) {
|
|||
})();
|
||||
|
||||
|
||||
/** An IIFE that hooks to the show/hide button underneath action needed reason.
|
||||
* This shows the auto generated email on action needed reason.
|
||||
class CustomizableEmailBase {
|
||||
|
||||
/**
|
||||
* @param {Object} config - must contain the following:
|
||||
* @property {HTMLElement} dropdown - The dropdown element.
|
||||
* @property {HTMLElement} textarea - The textarea element.
|
||||
* @property {HTMLElement} lastSentEmailContent - The last sent email content element.
|
||||
* @property {HTMLElement} textAreaFormGroup - The form group for the textarea.
|
||||
* @property {HTMLElement} dropdownFormGroup - The form group for the dropdown.
|
||||
* @property {HTMLElement} modalConfirm - The confirm button in the modal.
|
||||
* @property {string} apiUrl - The API URL for fetching email content.
|
||||
* @property {string} statusToCheck - The status to check against. Used for show/hide on textAreaFormGroup/dropdownFormGroup.
|
||||
* @property {string} sessionVariableName - The session variable name. Used for show/hide on textAreaFormGroup/dropdownFormGroup.
|
||||
* @property {string} apiErrorMessage - The error message that the ajax call returns.
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const dropdown = document.getElementById("id_action_needed_reason");
|
||||
const textarea = document.getElementById("id_action_needed_reason_email")
|
||||
const domainRequestId = dropdown ? document.getElementById("domain_request_id").value : null
|
||||
const textareaPlaceholder = document.querySelector(".field-action_needed_reason_email__placeholder");
|
||||
const directEditButton = document.querySelector('.field-action_needed_reason_email__edit');
|
||||
const modalTrigger = document.querySelector('.field-action_needed_reason_email__modal-trigger');
|
||||
const modalConfirm = document.getElementById('confirm-edit-email');
|
||||
const formLabel = document.querySelector('label[for="id_action_needed_reason_email"]');
|
||||
let lastSentEmailContent = document.getElementById("last-sent-email-content");
|
||||
const initialDropdownValue = dropdown ? dropdown.value : null;
|
||||
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;
|
||||
|
||||
// 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, '');
|
||||
// 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, '');
|
||||
}
|
||||
|
||||
if (!dropdown || !textarea || !domainRequestId || !formLabel || !modalConfirm) return;
|
||||
const apiUrl = document.getElementById("get-action-needed-email-for-user-json").value;
|
||||
}
|
||||
|
||||
function updateUserInterface(reason) {
|
||||
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
|
||||
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);
|
||||
// Handle showing/hiding the related fields on page load.
|
||||
initializeFormGroups() {
|
||||
let isStatus = this.statusSelect.value == this.statusToCheck;
|
||||
|
||||
// Initial handling of these groups.
|
||||
this.updateFormGroupVisibility(isStatus);
|
||||
|
||||
// Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage
|
||||
this.statusSelect.addEventListener('change', () => {
|
||||
// Show the action needed field if the status is what we expect.
|
||||
// Then track if its shown or hidden in our session cache.
|
||||
isStatus = this.statusSelect.value == this.statusToCheck;
|
||||
this.updateFormGroupVisibility(isStatus);
|
||||
addOrRemoveSessionBoolean(this.sessionVariableName, isStatus);
|
||||
});
|
||||
|
||||
// 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 showTextAreaFormGroup = sessionStorage.getItem(this.sessionVariableName) !== null;
|
||||
this.updateFormGroupVisibility(showTextAreaFormGroup);
|
||||
}
|
||||
});
|
||||
});
|
||||
observer.observe({ type: "navigation" });
|
||||
}
|
||||
|
||||
updateFormGroupVisibility(showFormGroups) {
|
||||
if (showFormGroups) {
|
||||
showElement(this.textAreaFormGroup);
|
||||
showElement(this.dropdownFormGroup);
|
||||
}else {
|
||||
// A triggering selection is selected, all hands on board:
|
||||
textarea.setAttribute('readonly', true);
|
||||
showElement(textarea);
|
||||
hideElement(textareaPlaceholder);
|
||||
|
||||
if (isEmailAlreadySentConst) {
|
||||
hideElement(directEditButton);
|
||||
showElement(modalTrigger);
|
||||
} else {
|
||||
showElement(directEditButton);
|
||||
hideElement(modalTrigger);
|
||||
}
|
||||
if (isEmailAlreadySent()) {
|
||||
formLabel.innerHTML = "Email sent to creator:";
|
||||
} else {
|
||||
formLabel.innerHTML = "Email:";
|
||||
}
|
||||
hideElement(this.textAreaFormGroup);
|
||||
hideElement(this.dropdownFormGroup);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize UI
|
||||
updateUserInterface(dropdown.value);
|
||||
|
||||
dropdown.addEventListener("change", function() {
|
||||
const reason = dropdown.value;
|
||||
// Update the UI
|
||||
updateUserInterface(reason);
|
||||
if (reason && reason !== "other") {
|
||||
// If it's not the initial value
|
||||
if (initialDropdownValue !== dropdown.value || initialEmailValue !== textarea.value) {
|
||||
initializeDropdown() {
|
||||
this.dropdown.addEventListener("change", () => {
|
||||
let reason = this.dropdown.value;
|
||||
if (this.initialDropdownValue !== this.dropdown.value || this.initialEmailValue !== this.textarea.value) {
|
||||
let searchParams = new URLSearchParams(
|
||||
{
|
||||
"reason": reason,
|
||||
"domain_request_id": this.domainRequestId,
|
||||
}
|
||||
);
|
||||
// Replace the email content
|
||||
fetch(`${apiUrl}?reason=${reason}&domain_request_id=${domainRequestId}`)
|
||||
fetch(`${this.apiUrl}?${searchParams.toString()}`)
|
||||
.then(response => {
|
||||
return response.json().then(data => data);
|
||||
})
|
||||
|
@ -584,30 +549,213 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
if (data.error) {
|
||||
console.error("Error in AJAX call: " + data.error);
|
||||
}else {
|
||||
textarea.value = data.action_needed_email;
|
||||
this.textarea.value = data.email;
|
||||
}
|
||||
updateUserInterface(reason);
|
||||
this.updateUserInterface(reason);
|
||||
})
|
||||
.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:";
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function that makes overriding the placeholder reason easy
|
||||
showPlaceholderNoReason() {
|
||||
this.showPlaceholder("Email:", "Select a reason to see email");
|
||||
}
|
||||
|
||||
// Helper function that makes overriding the placeholder reason easy
|
||||
showPlaceholderOtherReason() {
|
||||
this.showPlaceholder("Email:", "No email will be sent");
|
||||
}
|
||||
|
||||
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()
|
||||
});
|
||||
|
||||
modalConfirm.addEventListener("click", () => {
|
||||
textarea.removeAttribute('readonly');
|
||||
textarea.focus();
|
||||
hideElement(directEditButton);
|
||||
hideElement(modalTrigger);
|
||||
});
|
||||
directEditButton.addEventListener("click", () => {
|
||||
textarea.removeAttribute('readonly');
|
||||
textarea.focus();
|
||||
hideElement(directEditButton);
|
||||
hideElement(modalTrigger);
|
||||
});
|
||||
|
||||
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 '';
|
||||
}
|
||||
// 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
|
||||
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>Rationale:</strong></br>` +
|
||||
`<strong>Alternative Domains:</strong> ${alternativeDomains.join(', ')}</br>` +
|
||||
`<strong>Submitter:</strong> ${submitterInfo}</br>` +
|
||||
`<strong>Senior Official:</strong> ${seniorOfficialInfo}</br>` +
|
||||
`<strong>Other Employees:</strong> ${otherContactsSummary}</br>`;
|
||||
|
||||
|
|
|
@ -1498,12 +1498,23 @@ class DomainsTable extends LoadTableBase {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
class DomainRequestsTable extends LoadTableBase {
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
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
|
||||
* 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) {
|
||||
let baseUrl = document.getElementById("get_domain_requests_json_url");
|
||||
|
||||
if (!baseUrl) {
|
||||
return;
|
||||
}
|
||||
|
@ -1548,6 +1560,9 @@ class DomainRequestsTable extends LoadTableBase {
|
|||
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
|
||||
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 {*} order - the sort order {asc, desc}
|
||||
* @param {*} scroll - control for the scrollToElement functionality
|
||||
* @param {*} status - control for the status filter
|
||||
* @param {*} searchTerm - the search term
|
||||
* @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
|
||||
let searchParams = new URLSearchParams(
|
||||
|
@ -1877,7 +1891,6 @@ class MembersTable extends LoadTableBase {
|
|||
"page": page,
|
||||
"sort_by": sortBy,
|
||||
"order": order,
|
||||
"status": status,
|
||||
"search_term": searchTerm
|
||||
}
|
||||
);
|
||||
|
@ -1913,11 +1926,40 @@ class MembersTable extends LoadTableBase {
|
|||
const memberList = document.querySelector('.members__table tbody');
|
||||
memberList.innerHTML = '';
|
||||
|
||||
const invited = 'Invited';
|
||||
|
||||
data.members.forEach(member => {
|
||||
// const actionUrl = domain.action_url;
|
||||
const member_name = member.name;
|
||||
const member_email = member.email;
|
||||
const last_active = member.last_active;
|
||||
const member_display = member.member_display;
|
||||
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_label = member.action_label;
|
||||
const svg_icon = member.svg_icon;
|
||||
|
@ -1930,10 +1972,10 @@ class MembersTable extends LoadTableBase {
|
|||
|
||||
row.innerHTML = `
|
||||
<th scope="row" role="rowheader" data-label="member email">
|
||||
${member_email ? member_email : member_name} ${admin_tagHTML}
|
||||
${member_display} ${admin_tagHTML}
|
||||
</th>
|
||||
<td data-sort-value="${last_active}" data-label="last_active">
|
||||
${last_active}
|
||||
<td data-sort-value="${last_active_sort_value}" data-label="last_active">
|
||||
${last_active_formatted}
|
||||
</td>
|
||||
<td>
|
||||
<a href="${action_url}">
|
||||
|
|
|
@ -25,6 +25,8 @@
|
|||
/**
|
||||
* Edits made for dotgov project:
|
||||
* - 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
|
||||
* - fixed bug in createHeaderButton which added newlines to header button tooltips
|
||||
*/
|
||||
|
@ -5938,6 +5940,22 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
|
|||
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
|
||||
* @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 leftMargin = calculateMarginOffset("left", e.offsetWidth, tooltipTrigger);
|
||||
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
|
||||
e.style.margin = `-${topMargin}px 0 0 -${leftMargin / 2}px`;
|
||||
};
|
||||
|
@ -5963,7 +5989,17 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
|
|||
resetPositionStyles(e);
|
||||
const leftMargin = calculateMarginOffset("left", e.offsetWidth, tooltipTrigger);
|
||||
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`;
|
||||
};
|
||||
|
||||
|
@ -5975,8 +6011,16 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
|
|||
resetPositionStyles(e);
|
||||
const topMargin = calculateMarginOffset("top", e.offsetHeight, tooltipTrigger);
|
||||
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`;
|
||||
};
|
||||
|
||||
|
@ -5991,8 +6035,16 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
|
|||
// we have to check for some utility margins
|
||||
const leftMargin = calculateMarginOffset("left", tooltipTrigger.offsetLeft > e.offsetWidth ? tooltipTrigger.offsetLeft - e.offsetWidth : e.offsetWidth, tooltipTrigger);
|
||||
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
|
||||
};
|
||||
|
||||
|
@ -6017,6 +6069,7 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
|
|||
if (i < positions.length) {
|
||||
const pos = positions[i];
|
||||
pos(element);
|
||||
|
||||
if (!isElementInViewport(element)) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
tryPositions(i += 1);
|
||||
|
@ -6128,7 +6181,17 @@ const setUpAttributes = tooltipTrigger => {
|
|||
tooltipBody.setAttribute("aria-hidden", "true");
|
||||
|
||||
// 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 {
|
||||
tooltipBody,
|
||||
position,
|
||||
|
|
|
@ -385,6 +385,7 @@ a.button,
|
|||
font-kerning: auto;
|
||||
font-family: inherit;
|
||||
font-weight: normal;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
.button svg,
|
||||
.button span,
|
||||
|
@ -392,6 +393,9 @@ a.button,
|
|||
.usa-button--dja span {
|
||||
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) {
|
||||
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 {
|
||||
border-color: var(--body-quiet-color);
|
||||
}
|
||||
// Targets the DJA buttom with a nested icon
|
||||
button .usa-icon,
|
||||
.button .usa-icon,
|
||||
.button--clipboard .usa-icon {
|
||||
vertical-align: middle;
|
||||
.admin-icon-group {
|
||||
position: relative;
|
||||
display: inline;
|
||||
align-items: center;
|
||||
|
||||
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 {
|
||||
|
@ -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 {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.usa-button__small-text {
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
// Get rid of padding on all help texts
|
||||
form .aligned p.help, form .aligned div.help {
|
||||
padding-left: 0px !important;
|
||||
|
@ -887,6 +853,9 @@ div.dja__model-description{
|
|||
padding-top: 0 !important;
|
||||
}
|
||||
|
||||
.padding-bottom-0 {
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.flex-container {
|
||||
@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
|
||||
button.text-secondary,
|
||||
button.text-secondary:hover,
|
||||
.dotgov-table a.text-secondary {
|
||||
a.text-secondary,
|
||||
a.text-secondary:hover {
|
||||
color: $theme-color-error;
|
||||
}
|
||||
|
|
|
@ -28,3 +28,47 @@
|
|||
#extended-logo .usa-tooltip__body {
|
||||
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,
|
||||
ExportDomainRequestDataFull,
|
||||
ExportDataTypeUser,
|
||||
ExportDataTypeRequests,
|
||||
)
|
||||
|
||||
# --jsons
|
||||
|
@ -30,9 +31,10 @@ from registrar.views.utility.api_views import (
|
|||
get_senior_official_from_federal_agency_json,
|
||||
get_federal_and_portfolio_types_from_federal_agency_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.utility import always_404
|
||||
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.REQUIREMENTS, views.Requirements),
|
||||
(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))
|
||||
|
||||
|
@ -81,6 +86,26 @@ urlpatterns = [
|
|||
views.PortfolioMembersView.as_view(),
|
||||
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(
|
||||
# "no-organization-members/",
|
||||
# views.PortfolioNoMembersView.as_view(),
|
||||
|
@ -171,6 +196,11 @@ urlpatterns = [
|
|||
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(
|
||||
"reports/export_data_type_user/",
|
||||
|
@ -178,7 +208,17 @@ urlpatterns = [
|
|||
name="export_data_type_user",
|
||||
),
|
||||
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(),
|
||||
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.portfolio import Portfolio
|
||||
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()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -58,6 +58,7 @@ class UserPortfolioPermissionFixture:
|
|||
user=user,
|
||||
portfolio=portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[UserPortfolioPermissionChoices.EDIT_MEMBERS],
|
||||
)
|
||||
user_portfolio_permissions_to_create.append(user_portfolio_permission)
|
||||
else:
|
||||
|
|
|
@ -13,4 +13,5 @@ from .domain import (
|
|||
)
|
||||
from .portfolio import (
|
||||
PortfolioOrgAddressForm,
|
||||
PortfolioMemberForm,
|
||||
)
|
||||
|
|
|
@ -4,7 +4,7 @@ import logging
|
|||
from django import forms
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator, RegexValidator, MaxLengthValidator
|
||||
from django.forms import formset_factory
|
||||
from registrar.models import DomainRequest
|
||||
from registrar.models import DomainRequest, FederalAgency
|
||||
from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
||||
from registrar.models.suborganization import Suborganization
|
||||
from registrar.models.utility.domain_helper import DomainHelper
|
||||
|
@ -35,7 +35,10 @@ class DomainAddUserForm(forms.Form):
|
|||
email = forms.EmailField(
|
||||
label="Email",
|
||||
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=[
|
||||
MaxLengthValidator(
|
||||
320,
|
||||
|
@ -285,7 +288,7 @@ class UserForm(forms.ModelForm):
|
|||
"required": "Enter your title or role in your organization (e.g., Chief Information Officer)"
|
||||
}
|
||||
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.domainInfo = None
|
||||
|
@ -342,7 +345,7 @@ class ContactForm(forms.ModelForm):
|
|||
"required": "Enter your title or role in your organization (e.g., Chief Information Officer)"
|
||||
}
|
||||
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.domainInfo = None
|
||||
|
@ -458,9 +461,12 @@ class DomainOrgNameAddressForm(forms.ModelForm):
|
|||
validators=[
|
||||
RegexValidator(
|
||||
"^[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:
|
||||
|
@ -529,17 +535,25 @@ class DomainOrgNameAddressForm(forms.ModelForm):
|
|||
|
||||
def save(self, commit=True):
|
||||
"""Override the save() method of the BaseModelForm."""
|
||||
|
||||
if self.has_changed():
|
||||
|
||||
# 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.
|
||||
# This could be malicious, so lets reserve information for the backend only.
|
||||
if self.is_federal and not self._field_unchanged("federal_agency"):
|
||||
|
||||
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"):
|
||||
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:
|
||||
"""
|
||||
|
|
|
@ -21,6 +21,13 @@ from registrar.utility.constants import BranchChoices
|
|||
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):
|
||||
generic_org_type = forms.ChoiceField(
|
||||
# use the long names in the domain request form
|
||||
|
@ -137,10 +144,10 @@ class OrganizationContactForm(RegistrarForm):
|
|||
validators=[
|
||||
RegexValidator(
|
||||
"^[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(
|
||||
required=False,
|
||||
|
@ -226,7 +233,10 @@ class SeniorOfficialForm(RegistrarForm):
|
|||
email = forms.EmailField(
|
||||
label="Email",
|
||||
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=[
|
||||
MaxLengthValidator(
|
||||
320,
|
||||
|
@ -603,7 +613,8 @@ class CisaRepresentativeForm(BaseDeletableRegistrarForm):
|
|||
max_length=None,
|
||||
required=False,
|
||||
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=[
|
||||
MaxLengthValidator(
|
||||
|
|
|
@ -4,7 +4,14 @@ import logging
|
|||
from django import forms
|
||||
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__)
|
||||
|
||||
|
@ -17,9 +24,12 @@ class PortfolioOrgAddressForm(forms.ModelForm):
|
|||
validators=[
|
||||
RegexValidator(
|
||||
"^[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:
|
||||
|
@ -38,6 +48,7 @@ class PortfolioOrgAddressForm(forms.ModelForm):
|
|||
"state_territory": {
|
||||
"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 = {
|
||||
# We need to set the required attributed for State/territory
|
||||
|
@ -95,3 +106,57 @@ class PortfolioSeniorOfficialForm(forms.ModelForm):
|
|||
cleaned_data = super().clean()
|
||||
cleaned_data.pop("full_name", None)
|
||||
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)"
|
||||
}
|
||||
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."
|
||||
|
||||
|
|
|
@ -279,11 +279,11 @@ class BaseYesNoForm(RegistrarForm):
|
|||
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."""
|
||||
step_list = []
|
||||
for step in request_wizard.StepEnum:
|
||||
condition = request_wizard.WIZARD_CONDITIONS.get(step, True)
|
||||
for step in step_enum:
|
||||
condition = request_wizard.wizard_conditions.get(step, True)
|
||||
if callable(condition):
|
||||
condition = condition(request_wizard)
|
||||
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):
|
||||
DOMAIN_PURPOSE = "purpose_not_met", "Purpose requirements not met"
|
||||
REQUESTOR = "requestor_not_eligible", "Requestor not eligible to make request"
|
||||
SECOND_DOMAIN_REASONING = (
|
||||
DOMAIN_PURPOSE = "domain_purpose", "Purpose requirements not met"
|
||||
REQUESTOR_NOT_ELIGIBLE = "requestor_not_eligible", "Requestor not eligible to make request"
|
||||
ORG_HAS_DOMAIN = (
|
||||
"org_has_domain",
|
||||
"Org already has a .gov domain",
|
||||
)
|
||||
CONTACTS_OR_ORGANIZATION_LEGITIMACY = (
|
||||
CONTACTS_NOT_VERIFIED = (
|
||||
"contacts_not_verified",
|
||||
"Org contacts couldn't be verified",
|
||||
)
|
||||
ORGANIZATION_ELIGIBILITY = "org_not_eligible", "Org not eligible for a .gov domain"
|
||||
NAMING_REQUIREMENTS = "naming_not_met", "Naming requirements not met"
|
||||
ORG_NOT_ELIGIBLE = "org_not_eligible", "Org not eligible for a .gov domain"
|
||||
NAMING_REQUIREMENTS = "naming_requirements", "Naming requirements not met"
|
||||
OTHER = "other", "Other/Unspecified"
|
||||
|
||||
@classmethod
|
||||
|
@ -300,6 +300,11 @@ class DomainRequest(TimeStampedModel):
|
|||
blank=True,
|
||||
)
|
||||
|
||||
rejection_reason_email = models.TextField(
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
action_needed_reason = models.TextField(
|
||||
choices=ActionNeededReasons.choices,
|
||||
null=True,
|
||||
|
@ -635,15 +640,16 @@ class DomainRequest(TimeStampedModel):
|
|||
# Actually updates the organization_type field
|
||||
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"""
|
||||
self._cached_action_needed_reason = self.action_needed_reason
|
||||
self._cached_rejection_reason = self.rejection_reason
|
||||
self._cached_status = self.status
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# 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):
|
||||
"""Save override for custom properties"""
|
||||
|
@ -655,23 +661,63 @@ class DomainRequest(TimeStampedModel):
|
|||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Handle the action needed email.
|
||||
# An email is sent out when action_needed_reason is changed or added.
|
||||
if self.action_needed_reason and self.status == self.DomainRequestStatus.ACTION_NEEDED:
|
||||
self.sync_action_needed_reason()
|
||||
# Handle custom status emails.
|
||||
# An email is sent out when a, for example, action_needed_reason is changed or added.
|
||||
statuses_that_send_custom_emails = [self.DomainRequestStatus.ACTION_NEEDED, self.DomainRequestStatus.REJECTED]
|
||||
if self.status in statuses_that_send_custom_emails:
|
||||
self.send_custom_status_update_email(self.status)
|
||||
|
||||
# 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):
|
||||
"""Checks if we need to send another action needed email"""
|
||||
was_already_action_needed = self._cached_status == self.DomainRequestStatus.ACTION_NEEDED
|
||||
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
|
||||
if was_already_action_needed and reason_exists and reason_changed:
|
||||
# We don't send emails out in state "other"
|
||||
if self.action_needed_reason != self.ActionNeededReasons.OTHER:
|
||||
self._send_action_needed_reason_email(email_content=self.action_needed_reason_email)
|
||||
def send_custom_status_update_email(self, status):
|
||||
"""Helper function to send out a second status email when the status remains the same,
|
||||
but the reason has changed."""
|
||||
|
||||
# Currently, we store all this information in three variables.
|
||||
# When adding new reasons, this can be a lot to manage so we store it here
|
||||
# in a centralized location. However, this may need to change if this scales.
|
||||
status_information = {
|
||||
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):
|
||||
"""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,
|
||||
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.
|
||||
|
||||
This action is logged.
|
||||
|
@ -907,43 +953,23 @@ class DomainRequest(TimeStampedModel):
|
|||
This action cleans up the rejection status if moving away from rejected.
|
||||
|
||||
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:
|
||||
self.delete_and_clean_up_domain("reject_with_prejudice")
|
||||
self.delete_and_clean_up_domain("action_needed")
|
||||
elif self.status == self.DomainRequestStatus.REJECTED:
|
||||
self.rejection_reason = None
|
||||
|
||||
# Check if the tuple is setup correctly, then grab its value.
|
||||
|
||||
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"
|
||||
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(
|
||||
field="status",
|
||||
source=[
|
||||
|
@ -1037,18 +1063,20 @@ class DomainRequest(TimeStampedModel):
|
|||
def reject(self):
|
||||
"""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
|
||||
(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:
|
||||
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(
|
||||
field="status",
|
||||
source=[
|
||||
|
|
|
@ -4,6 +4,7 @@ import logging
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from django_fsm import FSMField, transition
|
||||
from registrar.models.domain_invitation import DomainInvitation
|
||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
from .utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices # type: ignore
|
||||
from .utility.time_stamped_model import TimeStampedModel
|
||||
|
@ -38,7 +39,7 @@ class PortfolioInvitation(TimeStampedModel):
|
|||
related_name="portfolios",
|
||||
)
|
||||
|
||||
portfolio_roles = ArrayField(
|
||||
roles = ArrayField(
|
||||
models.CharField(
|
||||
max_length=50,
|
||||
choices=UserPortfolioRoleChoices.choices,
|
||||
|
@ -48,7 +49,7 @@ class PortfolioInvitation(TimeStampedModel):
|
|||
help_text="Select one or more roles.",
|
||||
)
|
||||
|
||||
portfolio_additional_permissions = ArrayField(
|
||||
additional_permissions = ArrayField(
|
||||
models.CharField(
|
||||
max_length=50,
|
||||
choices=UserPortfolioPermissionChoices.choices,
|
||||
|
@ -67,6 +68,31 @@ class PortfolioInvitation(TimeStampedModel):
|
|||
def __str__(self):
|
||||
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)
|
||||
def retrieve(self):
|
||||
"""When an invitation is retrieved, create the corresponding permission.
|
||||
|
@ -88,8 +114,8 @@ class PortfolioInvitation(TimeStampedModel):
|
|||
user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
portfolio=self.portfolio, user=user
|
||||
)
|
||||
if self.portfolio_roles and len(self.portfolio_roles) > 0:
|
||||
user_portfolio_permission.roles = self.portfolio_roles
|
||||
if self.portfolio_additional_permissions and len(self.portfolio_additional_permissions) > 0:
|
||||
user_portfolio_permission.additional_permissions = self.portfolio_additional_permissions
|
||||
if self.roles and len(self.roles) > 0:
|
||||
user_portfolio_permission.roles = self.roles
|
||||
if self.additional_permissions and len(self.additional_permissions) > 0:
|
||||
user_portfolio_permission.additional_permissions = self.additional_permissions
|
||||
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"""
|
||||
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):
|
||||
# BEGIN
|
||||
# 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)
|
||||
else:
|
||||
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.forms import ValidationError
|
||||
from registrar.models.user_domain_role import UserDomainRole
|
||||
from registrar.utility.waffle import flag_is_active_for_user
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
from .utility.time_stamped_model import TimeStampedModel
|
||||
|
@ -79,6 +80,14 @@ class UserPortfolioPermission(TimeStampedModel):
|
|||
)
|
||||
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):
|
||||
"""
|
||||
Retrieve the permissions for the user's portfolio roles.
|
||||
|
|
|
@ -20,10 +20,11 @@
|
|||
</li>
|
||||
{% if opts.model_name == 'domainrequest' %}
|
||||
<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" >
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
|
||||
</svg>
|
||||
<!-- the span is targeted in JS, do not remove -->
|
||||
<span>{% translate "Copy request summary" %}</span>
|
||||
</a>
|
||||
</li>
|
||||
|
|
|
@ -8,7 +8,7 @@ Template for an input field with a clipboard
|
|||
<div class="admin-icon-group">
|
||||
{{ field }}
|
||||
<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"
|
||||
>
|
||||
<div class="no-outline-on-click">
|
||||
|
@ -17,15 +17,17 @@ Template for an input field with a clipboard
|
|||
>
|
||||
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
|
||||
</svg>
|
||||
Copy
|
||||
<!-- the span is targeted in JS, do not remove -->
|
||||
<span>Copy</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{% 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 }}" />
|
||||
{% if field.email is not None %}
|
||||
<button
|
||||
class="usa-button usa-button--unstyled padding-right-1 usa-button--icon button--clipboard copy-to-clipboard text-no-underline padding-left-05"
|
||||
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
|
||||
|
@ -33,7 +35,9 @@ Template for an input field with a clipboard
|
|||
>
|
||||
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
|
||||
</svg>
|
||||
Copy
|
||||
<!-- the span is targeted in JS, do not remove -->
|
||||
<span>Copy</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
|
@ -10,6 +10,8 @@
|
|||
<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 %}
|
||||
<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 %}
|
||||
{% comment %}
|
||||
TODO: this will eventually need to be changed to something like this
|
||||
|
|
|
@ -26,7 +26,7 @@
|
|||
{% if user.email %}
|
||||
<span id="contact_info_email">{{ user.email }}</span>
|
||||
{% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %}
|
||||
<br class="admin-icon-group__br">
|
||||
<br>
|
||||
{% else %}
|
||||
None<br>
|
||||
{% endif %}
|
||||
|
|
|
@ -137,29 +137,28 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
|
||||
{% block field_other %}
|
||||
{% 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>
|
||||
|
||||
{{ field.field }}
|
||||
|
||||
<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 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
|
||||
>
|
||||
<a
|
||||
href="#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"
|
||||
aria-controls="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 edit-button-modal-trigger flex-align-self-start"
|
||||
aria-controls="action-needed-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="email-already-sent-modal"
|
||||
id="action-needed-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"
|
||||
>
|
||||
|
@ -187,8 +186,8 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
<li class="usa-button-group__item">
|
||||
<button
|
||||
type="submit"
|
||||
id="action-needed-reason__confirm-edit-email"
|
||||
class="usa-button"
|
||||
id="confirm-edit-email"
|
||||
data-close-modal
|
||||
>
|
||||
Yes, continue editing
|
||||
|
@ -221,11 +220,99 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
</div>
|
||||
|
||||
{% 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 %}
|
||||
<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 %}
|
||||
|
||||
{% 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 %}
|
||||
{{ field.field }}
|
||||
{% endif %}
|
||||
|
@ -254,7 +341,7 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="4">Other contact information</th>
|
||||
<th colspan="5">Other contact information</th>
|
||||
<tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
@ -267,9 +354,20 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
</td>
|
||||
<td class="padding-left-1">{{ contact.phone }}</td>
|
||||
<td class="padding-left-1 text-size-small">
|
||||
{% if contact.email %}
|
||||
<input aria-hidden="true" class="display-none" value="{{ contact.email }}" />
|
||||
<button
|
||||
class="usa-button usa-button--unstyled padding-right-1 usa-button--icon button--clipboard copy-to-clipboard usa-button__small-text text-no-underline"
|
||||
class="
|
||||
usa-button--dja
|
||||
usa-button
|
||||
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"
|
||||
>
|
||||
<svg
|
||||
|
@ -277,8 +375,10 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
>
|
||||
<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>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
|
|
@ -45,7 +45,7 @@
|
|||
<div class="usa-alert usa-alert--info usa-alert--slim">
|
||||
<div class="usa-alert__body">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -42,7 +42,7 @@
|
|||
|
||||
{% 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 %}
|
||||
{% endwith %}
|
||||
|
||||
|
|
|
@ -13,7 +13,9 @@
|
|||
<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 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>
|
||||
{% endif %}
|
||||
|
||||
<p>Note that <strong>only federal agencies can request generic terms</strong> like
|
||||
vote.gov.</p>
|
||||
|
|
|
@ -12,7 +12,11 @@
|
|||
|
||||
<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>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
<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>,
|
||||
completing your domain request might take around 15 minutes.</p>
|
||||
|
|
|
@ -33,7 +33,7 @@
|
|||
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
|
||||
{% block form_fields %}
|
||||
{% 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 %}
|
||||
|
|
|
@ -11,8 +11,13 @@
|
|||
|
||||
<p>
|
||||
The name of your suborganization will be publicly listed as the domain registrant.
|
||||
This list of suborganizations has been populated the .gov program.
|
||||
If you believe there is an error please contact <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
|
||||
</p>
|
||||
<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>
|
||||
|
||||
{% 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
|
||||
|
||||
----------------------------------------------------------------
|
||||
{% if domain_request.rejection_reason != 'other' %}
|
||||
REJECTION REASON{% endif %}{% if domain_request.rejection_reason == 'purpose_not_met' %}
|
||||
{% if reason != domain_request.RejectionReasons.DOMAIN_PURPOSE.OTHER %}
|
||||
REJECTION REASON{% endif %}{% if reason == domain_request.RejectionReasons.DOMAIN_PURPOSE %}
|
||||
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
|
||||
domain.
|
||||
|
@ -18,7 +18,7 @@ Learn more about:
|
|||
- 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/>
|
||||
|
||||
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
|
||||
.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.
|
||||
|
@ -26,7 +26,7 @@ working on behalf of a government organization, to request a .gov domain.
|
|||
|
||||
DEMONSTRATE ELIGIBILITY
|
||||
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
|
||||
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
|
||||
|
@ -35,9 +35,9 @@ justification for an additional domain.
|
|||
Read more about our practice of approving one domain per online 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
|
||||
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
|
||||
eligible for a .gov domain. .Gov domains are only available to official U.S.-based
|
||||
government organizations.
|
||||
|
@ -46,7 +46,7 @@ Learn more about eligibility for .gov domains
|
|||
<https://get.gov/domains/eligibility/>.
|
||||
|
||||
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.
|
||||
Domains should uniquely identify a government organization and be clear to the
|
||||
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
|
||||
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
|
||||
If your organization is eligible for a .gov domain and you meet our other requirements, you can submit a new request.
|
||||
|
||||
|
|
|
@ -3,21 +3,21 @@
|
|||
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
|
||||
{% url 'get_domain_requests_json' as url %}
|
||||
<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">
|
||||
<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 %}
|
||||
<div class="mobile:grid-col-12 desktop:grid-col-6">
|
||||
<h2 id="domain-requests-header" class="flex-6">Domain requests</h2>
|
||||
</div>
|
||||
<h2 id="domain-requests-header" class="display-inline-block">Domain requests</h2>
|
||||
{% else %}
|
||||
<!-- Embedding the portfolio value in a data attribute -->
|
||||
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>
|
||||
{% 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 %}">
|
||||
<section aria-label="Domain requests search component" class="margin-top-2">
|
||||
<form class="usa-search usa-search--small" method="POST" role="search">
|
||||
{% csrf_token %}
|
||||
<button class="usa-button usa-button--unstyled margin-right-2 domain-requests__reset-search display-none" type="button">
|
||||
<button class="usa-button usa-button--unstyled margin-right-3 domain-requests__reset-search display-none" type="button">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
|
||||
</svg>
|
||||
|
@ -49,7 +49,19 @@
|
|||
</form>
|
||||
</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>
|
||||
|
||||
{% if portfolio %}
|
||||
<div class="display-flex flex-align-center">
|
||||
<span class="margin-right-2 margin-top-neg-1 usa-prose text-base-darker">Filter by</span>
|
||||
|
@ -79,9 +91,7 @@
|
|||
name="filter-status"
|
||||
value="started"
|
||||
/>
|
||||
<label class="usa-checkbox__label" for="filter-status-started"
|
||||
>Started</label
|
||||
>
|
||||
<label class="usa-checkbox__label" for="filter-status-started">Started</label>
|
||||
</div>
|
||||
<div class="usa-checkbox">
|
||||
<input
|
||||
|
@ -91,9 +101,7 @@
|
|||
name="filter-status"
|
||||
value="submitted"
|
||||
/>
|
||||
<label class="usa-checkbox__label" for="filter-status-submitted"
|
||||
>Submitted</label
|
||||
>
|
||||
<label class="usa-checkbox__label" for="filter-status-submitted">Submitted</label>
|
||||
</div>
|
||||
<div class="usa-checkbox">
|
||||
<input
|
||||
|
@ -103,9 +111,7 @@
|
|||
name="filter-status"
|
||||
value="in review"
|
||||
/>
|
||||
<label class="usa-checkbox__label" for="filter-status-in-review"
|
||||
>In review</label
|
||||
>
|
||||
<label class="usa-checkbox__label" for="filter-status-in-review">In review</label>
|
||||
</div>
|
||||
<div class="usa-checkbox">
|
||||
<input
|
||||
|
@ -115,9 +121,7 @@
|
|||
name="filter-status"
|
||||
value="action needed"
|
||||
/>
|
||||
<label class="usa-checkbox__label" for="filter-status-action-needed"
|
||||
>Action needed</label
|
||||
>
|
||||
<label class="usa-checkbox__label" for="filter-status-action-needed">Action needed</label>
|
||||
</div>
|
||||
<div class="usa-checkbox">
|
||||
<input
|
||||
|
@ -127,9 +131,7 @@
|
|||
name="filter-status"
|
||||
value="rejected"
|
||||
/>
|
||||
<label class="usa-checkbox__label" for="filter-status-rejected"
|
||||
>Rejected</label
|
||||
>
|
||||
<label class="usa-checkbox__label" for="filter-status-rejected">Rejected</label>
|
||||
</div>
|
||||
<div class="usa-checkbox">
|
||||
<input
|
||||
|
@ -139,9 +141,7 @@
|
|||
name="filter-status"
|
||||
value="withdrawn"
|
||||
/>
|
||||
<label class="usa-checkbox__label" for="filter-status-withdrawn"
|
||||
>Withdrawn</label
|
||||
>
|
||||
<label class="usa-checkbox__label" for="filter-status-withdrawn">Withdrawn</label>
|
||||
</div>
|
||||
<div class="usa-checkbox">
|
||||
<input
|
||||
|
@ -151,9 +151,7 @@
|
|||
name="filter-status"
|
||||
value="ineligible"
|
||||
/>
|
||||
<label class="usa-checkbox__label" for="filter-status-ineligible"
|
||||
>Ineligible</label
|
||||
>
|
||||
<label class="usa-checkbox__label" for="filter-status-ineligible">Ineligible</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
@ -169,6 +167,7 @@
|
|||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<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">
|
||||
<caption class="sr-only">Your domain requests</caption>
|
||||
|
@ -188,18 +187,18 @@
|
|||
<!-- AJAX will populate this tbody -->
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class="usa-sr-only usa-table__announcement-region"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
<div class="usa-sr-only usa-table__announcement-region" aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
<div class="domain-requests__no-data display-none">
|
||||
<p>You haven't requested any domains.</p>
|
||||
</div>
|
||||
|
||||
<div class="domain-requests__no-search-results display-none">
|
||||
<p>No results found</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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">
|
||||
<!-- Count will be dynamically populated by JS -->
|
||||
|
|
|
@ -34,6 +34,7 @@
|
|||
</ul>
|
||||
</div>
|
||||
<ul class="usa-nav__primary usa-accordion">
|
||||
{% if not hide_domains %}
|
||||
<li class="usa-nav__primary-item">
|
||||
{% if has_any_domains_portfolio_permission %}
|
||||
{% url 'domains' as url %}
|
||||
|
@ -44,13 +45,14 @@
|
|||
Domains
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<!-- <li class="usa-nav__primary-item">
|
||||
<a href="#" class="usa-nav-link">
|
||||
Domain groups
|
||||
</a>
|
||||
</li> -->
|
||||
|
||||
{% if has_organization_requests_flag %}
|
||||
{% if has_organization_requests_flag and not hide_requests %}
|
||||
<li class="usa-nav__primary-item">
|
||||
<!-- user has one of the view permissions plus the edit permission, show the dropdown -->
|
||||
{% if has_edit_request_portfolio_permission %}
|
||||
|
@ -91,7 +93,7 @@
|
|||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if has_organization_members_flag and has_view_members_portfolio_permission %}
|
||||
{% if has_organization_members_flag %}
|
||||
<li class="usa-nav__primary-item">
|
||||
<a href="/members/" class="usa-nav-link {% if path|is_members_subpage %} usa-current{% endif %}">
|
||||
Members
|
||||
|
|
|
@ -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">
|
||||
<h2 class="usa-modal__heading" id="modal-1-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 %}
|
||||
{# Add a breakpoint #}
|
||||
<div aria-hidden="true"></div>
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
{% endif %}
|
||||
|
||||
{% if step == Step.REQUESTING_ENTITY %}
|
||||
|
||||
{% if domain_request.organization_name %}
|
||||
{% 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' %}
|
||||
|
@ -54,32 +53,8 @@
|
|||
{% endif %}
|
||||
|
||||
{% if step == Step.ADDITIONAL_DETAILS %}
|
||||
{% with title=form_titles|get_item:step %}
|
||||
{% if domain_request.has_additional_details %}
|
||||
{% 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 %}
|
||||
{% with title=form_titles|get_item:step value=domain_request.anything_else|default:"<span class='text-bold text-secondary-dark'>Incomplete</span>"|safe %}
|
||||
{% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=is_editable edit_link=domain_request_url %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
|
||||
|
|
|
@ -24,7 +24,11 @@
|
|||
{% if sub_header_text %}
|
||||
<h4 class="register-form-review-header">{{ sub_header_text }}</h4>
|
||||
{% 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 %}
|
||||
{% elif contact %}
|
||||
{% if list %}
|
||||
|
@ -122,9 +126,9 @@
|
|||
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">
|
||||
<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>
|
||||
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>
|
||||
</div>
|
||||
{% 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">
|
||||
<h1 id="members-header">Members</h1>
|
||||
</div>
|
||||
{% if has_edit_members_portfolio_permission %}
|
||||
<div class="mobile:grid-col-12 tablet:grid-col-6">
|
||||
<p class="float-right-tablet tablet:margin-y-0">
|
||||
<a href="#" class="usa-button"
|
||||
|
@ -26,6 +27,7 @@
|
|||
</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% include "includes/members_table.html" with portfolio=portfolio %}
|
||||
|
|
|
@ -45,7 +45,7 @@
|
|||
{% input_with_errors form.address_line2 %}
|
||||
{% input_with_errors form.city %}
|
||||
{% 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 %}
|
||||
{% endwith %}
|
||||
<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.
|
||||
Takes a path name, like '/organization/'."""
|
||||
# Since our pages aren't unified under a common path, we need this approach for now.
|
||||
url_names = [
|
||||
"members",
|
||||
]
|
||||
url_names = ["members", "member", "member-permissions", "invitedmember", "invitedmember-permissions"]
|
||||
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)
|
||||
|
||||
# Test for the copy link
|
||||
self.assertContains(response, "button--clipboard", count=3)
|
||||
self.assertContains(response, "copy-to-clipboard", count=3)
|
||||
|
||||
# cleanup this test
|
||||
domain_info.delete()
|
||||
|
|
|
@ -535,7 +535,7 @@ class TestDomainAdminWithClient(TestCase):
|
|||
self.assertContains(response, "Testy Tester")
|
||||
|
||||
# Test for the copy link
|
||||
self.assertContains(response, "button--clipboard")
|
||||
self.assertContains(response, "copy-to-clipboard")
|
||||
|
||||
# cleanup from this test
|
||||
domain.delete()
|
||||
|
|
|
@ -595,7 +595,12 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
|
||||
@less_console_noise_decorator
|
||||
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."""
|
||||
|
||||
|
@ -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.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
|
||||
bad_name = DomainRequest.ActionNeededReasons.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
|
||||
)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
|
||||
self._reset_action_needed_email(domain_request)
|
||||
|
||||
# Test the email sent out for 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
|
||||
)
|
||||
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
|
||||
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
|
||||
)
|
||||
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
|
||||
other = DomainRequest.ActionNeededReasons.OTHER
|
||||
|
@ -717,6 +729,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
|
||||
# Should be unchanged from before
|
||||
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
|
||||
questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL
|
||||
|
@ -730,6 +743,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
domain_request.refresh_from_db()
|
||||
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._reset_action_needed_email(domain_request)
|
||||
|
||||
# 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.
|
||||
|
@ -741,6 +755,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
)
|
||||
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 5)
|
||||
self._reset_action_needed_email(domain_request)
|
||||
|
||||
# Set the request back to in review
|
||||
domain_request.in_review()
|
||||
|
@ -757,55 +772,53 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 6)
|
||||
|
||||
# def test_action_needed_sends_reason_email_prod_bcc(self):
|
||||
# """When an action needed reason is set, an email is sent out and help@get.gov
|
||||
# is BCC'd in production"""
|
||||
# # Ensure there is no user with this email
|
||||
# EMAIL = "mayor@igorville.gov"
|
||||
# BCC_EMAIL = settings.DEFAULT_FROM_EMAIL
|
||||
# User.objects.filter(email=EMAIL).delete()
|
||||
# in_review = DomainRequest.DomainRequestStatus.IN_REVIEW
|
||||
# action_needed = DomainRequest.DomainRequestStatus.ACTION_NEEDED
|
||||
def _reset_action_needed_email(self, domain_request):
|
||||
"""Sets the given action needed email back to none"""
|
||||
domain_request.action_needed_reason_email = None
|
||||
domain_request.save()
|
||||
domain_request.refresh_from_db()
|
||||
|
||||
# # Create a sample domain request
|
||||
# domain_request = completed_domain_request(status=in_review)
|
||||
@override_settings(IS_PRODUCTION=True)
|
||||
@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
|
||||
# already_has_domains = DomainRequest.ActionNeededReasons.ALREADY_HAS_DOMAINS
|
||||
# self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=already_has_domains)
|
||||
# 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)
|
||||
_creator = User.objects.create(
|
||||
username="MrMeoward",
|
||||
first_name="Meoward",
|
||||
last_name="Jones",
|
||||
email=EMAIL,
|
||||
phone="(555) 123 12345",
|
||||
title="Treat inspector",
|
||||
)
|
||||
|
||||
# # Test the email sent out for bad_name
|
||||
# bad_name = DomainRequest.ActionNeededReasons.BAD_NAME
|
||||
# self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=bad_name)
|
||||
# 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)
|
||||
BCC_EMAIL = settings.DEFAULT_FROM_EMAIL
|
||||
in_review = DomainRequest.DomainRequestStatus.IN_REVIEW
|
||||
rejected = DomainRequest.DomainRequestStatus.REJECTED
|
||||
|
||||
# # Test the email sent out for eligibility_unclear
|
||||
# eligibility_unclear = DomainRequest.ActionNeededReasons.ELIGIBILITY_UNCLEAR
|
||||
# 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)
|
||||
# Create a sample domain request
|
||||
domain_request = completed_domain_request(status=in_review, user=_creator)
|
||||
|
||||
# # Test the email sent out for questionable_so
|
||||
# questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL
|
||||
# self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=questionable_so)
|
||||
# self.assert_email_is_accurate(
|
||||
# "SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", 3, EMAIL, bcc_email_address=BCC_EMAIL
|
||||
# )
|
||||
# self.assertEqual(len(self.mock_client.EMAILS_SENT), 4)
|
||||
|
||||
# # Assert that no other emails are sent on OTHER
|
||||
# other = DomainRequest.ActionNeededReasons.OTHER
|
||||
# self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=other)
|
||||
|
||||
# # Should be unchanged from before
|
||||
# self.assertEqual(len(self.mock_client.EMAILS_SENT), 4)
|
||||
expected_emails = {
|
||||
DomainRequest.RejectionReasons.DOMAIN_PURPOSE: "You didn’t provide enough information about how",
|
||||
DomainRequest.RejectionReasons.REQUESTOR_NOT_ELIGIBLE: "You must be a government employee, or be",
|
||||
DomainRequest.RejectionReasons.ORG_HAS_DOMAIN: "practice is to approve one domain",
|
||||
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",
|
||||
DomainRequest.RejectionReasons.NAMING_REQUIREMENTS: "does not meet our naming requirements",
|
||||
DomainRequest.RejectionReasons.OTHER: "YOU CAN SUBMIT A NEW REQUEST",
|
||||
}
|
||||
for i, (reason, email_content) in enumerate(expected_emails.items()):
|
||||
with self.subTest(reason=reason):
|
||||
self.transition_state_and_send_email(domain_request, status=rejected, rejection_reason=reason)
|
||||
self.assert_email_is_accurate(email_content, i, EMAIL, bcc_email_address=BCC_EMAIL)
|
||||
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
|
||||
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
|
||||
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(
|
||||
"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(
|
||||
domain_request,
|
||||
DomainRequest.DomainRequestStatus.REJECTED,
|
||||
DomainRequest.RejectionReasons.SECOND_DOMAIN_REASONING,
|
||||
DomainRequest.RejectionReasons.ORG_HAS_DOMAIN,
|
||||
)
|
||||
self.assert_email_is_accurate(
|
||||
"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(
|
||||
domain_request,
|
||||
DomainRequest.DomainRequestStatus.REJECTED,
|
||||
DomainRequest.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY,
|
||||
DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED,
|
||||
)
|
||||
self.assert_email_is_accurate(
|
||||
"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(
|
||||
domain_request,
|
||||
DomainRequest.DomainRequestStatus.REJECTED,
|
||||
DomainRequest.RejectionReasons.ORGANIZATION_ELIGIBILITY,
|
||||
DomainRequest.RejectionReasons.ORG_NOT_ELIGIBLE,
|
||||
)
|
||||
self.assert_email_is_accurate(
|
||||
"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, "warning"))
|
||||
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)
|
||||
|
||||
|
@ -1511,7 +1526,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields)
|
||||
|
||||
# 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
|
||||
self.assertNotContains(response, "Approved domains")
|
||||
|
@ -1621,6 +1636,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
"updated_at",
|
||||
"status",
|
||||
"rejection_reason",
|
||||
"rejection_reason_email",
|
||||
"action_needed_reason",
|
||||
"action_needed_reason_email",
|
||||
"federal_agency",
|
||||
|
@ -1840,12 +1856,64 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
self.trigger_saving_approved_to_another_state(
|
||||
False,
|
||||
DomainRequest.DomainRequestStatus.REJECTED,
|
||||
DomainRequest.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY,
|
||||
DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED,
|
||||
)
|
||||
|
||||
def test_side_effects_when_saving_approved_to_ineligible(self):
|
||||
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):
|
||||
"""
|
||||
This test verifies that DomainRequestAdmin has the correct filters set up.
|
||||
|
|
|
@ -143,8 +143,8 @@ class GetActionNeededEmailForUserJsonTest(TestCase):
|
|||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertIn("action_needed_email", data)
|
||||
self.assertIn("ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS", data["action_needed_email"])
|
||||
self.assertIn("email", data)
|
||||
self.assertIn("ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS", data["email"])
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_get_action_needed_email_for_user_json_analyst(self):
|
||||
|
@ -160,8 +160,8 @@ class GetActionNeededEmailForUserJsonTest(TestCase):
|
|||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertIn("action_needed_email", data)
|
||||
self.assertIn("SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", data["action_needed_email"])
|
||||
self.assertIn("email", data)
|
||||
self.assertIn("SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", data["email"])
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_get_action_needed_email_for_user_json_regular(self):
|
||||
|
@ -176,3 +176,71 @@ class GetActionNeededEmailForUserJsonTest(TestCase):
|
|||
},
|
||||
)
|
||||
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"})
|
||||
self.assertEqual(
|
||||
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):
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
from django.forms import ValidationError
|
||||
from django.test import TestCase
|
||||
from django.db.utils import IntegrityError
|
||||
from django.db import transaction
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import RequestFactory
|
||||
|
@ -20,28 +18,24 @@ from registrar.models import (
|
|||
UserPortfolioPermission,
|
||||
AllowedEmail,
|
||||
)
|
||||
|
||||
import boto3_mocking
|
||||
from registrar.models.portfolio import Portfolio
|
||||
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||
from registrar.models.transition_domain import TransitionDomain
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
from registrar.models.verified_by_staff import VerifiedByStaff # type: ignore
|
||||
from registrar.utility.constants import BranchChoices
|
||||
|
||||
from .common import (
|
||||
MockSESClient,
|
||||
less_console_noise,
|
||||
completed_domain_request,
|
||||
set_domain_request_investigators,
|
||||
create_test_user,
|
||||
)
|
||||
from django_fsm import TransitionNotAllowed
|
||||
from waffle.testutils import override_flag
|
||||
|
||||
from api.tests.common import less_console_noise_decorator
|
||||
|
||||
|
||||
<<<<<<< HEAD
|
||||
@boto3_mocking.patching
|
||||
class TestDomainRequest(TestCase):
|
||||
@less_console_noise_decorator
|
||||
|
@ -1059,6 +1053,8 @@ class TestPermissions(TestCase):
|
|||
self.assertTrue(UserDomainRole.objects.get(user=user, domain=domain))
|
||||
|
||||
|
||||
=======
|
||||
>>>>>>> origin/main
|
||||
class TestDomainInformation(TestCase):
|
||||
"""Test the DomainInformation model, when approved or otherwise"""
|
||||
|
||||
|
@ -1176,12 +1172,15 @@ class TestPortfolioInvitations(TestCase):
|
|||
self.invitation, _ = PortfolioInvitation.objects.get_or_create(
|
||||
email=self.email,
|
||||
portfolio=self.portfolio,
|
||||
portfolio_roles=[self.portfolio_role_base, self.portfolio_role_admin],
|
||||
portfolio_additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
|
||||
roles=[self.portfolio_role_base, self.portfolio_role_admin],
|
||||
additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
super().tearDown()
|
||||
DomainInvitation.objects.all().delete()
|
||||
DomainInformation.objects.all().delete()
|
||||
Domain.objects.all().delete()
|
||||
UserPortfolioPermission.objects.all().delete()
|
||||
Portfolio.objects.all().delete()
|
||||
PortfolioInvitation.objects.all().delete()
|
||||
|
@ -1233,8 +1232,8 @@ class TestPortfolioInvitations(TestCase):
|
|||
PortfolioInvitation.objects.get_or_create(
|
||||
email=self.email,
|
||||
portfolio=portfolio2,
|
||||
portfolio_roles=[self.portfolio_role_base, self.portfolio_role_admin],
|
||||
portfolio_additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
|
||||
roles=[self.portfolio_role_base, self.portfolio_role_admin],
|
||||
additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
|
||||
)
|
||||
with override_flag("multiple_portfolios", active=True):
|
||||
self.user.check_portfolio_invitations_on_login()
|
||||
|
@ -1257,8 +1256,8 @@ class TestPortfolioInvitations(TestCase):
|
|||
PortfolioInvitation.objects.get_or_create(
|
||||
email=self.email,
|
||||
portfolio=portfolio2,
|
||||
portfolio_roles=[self.portfolio_role_base, self.portfolio_role_admin],
|
||||
portfolio_additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
|
||||
roles=[self.portfolio_role_base, self.portfolio_role_admin],
|
||||
additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
|
||||
)
|
||||
self.user.check_portfolio_invitations_on_login()
|
||||
self.user.refresh_from_db()
|
||||
|
@ -1269,6 +1268,52 @@ class TestPortfolioInvitations(TestCase):
|
|||
updated_invitation2, _ = PortfolioInvitation.objects.get_or_create(email=self.email, portfolio=portfolio2)
|
||||
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):
|
||||
@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):
|
||||
"""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,
|
||||
UserDomainRole,
|
||||
)
|
||||
from registrar.models import Portfolio
|
||||
from registrar.models import Portfolio, DraftDomain
|
||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
||||
from registrar.utility.csv_export import (
|
||||
|
@ -14,6 +14,7 @@ from registrar.utility.csv_export import (
|
|||
DomainDataType,
|
||||
DomainDataFederal,
|
||||
DomainDataTypeUser,
|
||||
DomainRequestsDataType,
|
||||
DomainGrowth,
|
||||
DomainManaged,
|
||||
DomainUnmanaged,
|
||||
|
@ -389,6 +390,77 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
|
|||
|
||||
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
|
||||
def test_domain_data_full(self):
|
||||
"""Shows security contacts, filtered by state"""
|
||||
|
|
|
@ -349,7 +349,10 @@ class TestDomainDetail(TestDomainOverview):
|
|||
detail_page = self.client.get(f"/domain/{domain.id}")
|
||||
# Check that alert message displays properly
|
||||
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
|
||||
self.assertNotContains(detail_page, "Edit")
|
||||
|
|
|
@ -37,6 +37,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
|
|||
UserDomainRole.objects.all().delete()
|
||||
UserPortfolioPermission.objects.all().delete()
|
||||
DomainInformation.objects.all().delete()
|
||||
Domain.objects.all().delete()
|
||||
Portfolio.objects.all().delete()
|
||||
super().tearDown()
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
from django.urls import reverse
|
||||
|
||||
from registrar.models.portfolio import Portfolio
|
||||
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||
from registrar.models.user import User
|
||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
|
@ -38,6 +39,7 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
|
|||
phone="8003114567",
|
||||
title="Admin",
|
||||
)
|
||||
cls.email5 = "fifth@example.com"
|
||||
|
||||
# Create Portfolio
|
||||
cls.portfolio = Portfolio.objects.create(creator=cls.user, organization_name="Test Portfolio")
|
||||
|
@ -67,6 +69,23 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
|
|||
portfolio=cls.portfolio,
|
||||
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):
|
||||
super().setUp()
|
||||
|
@ -83,14 +102,21 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
|
|||
self.assertFalse(data["has_previous"])
|
||||
self.assertFalse(data["has_next"])
|
||||
self.assertEqual(data["num_pages"], 1)
|
||||
self.assertEqual(data["total"], 4)
|
||||
self.assertEqual(data["unfiltered_total"], 4)
|
||||
self.assertEqual(data["total"], 5)
|
||||
self.assertEqual(data["unfiltered_total"], 5)
|
||||
|
||||
# Check the number of members
|
||||
self.assertEqual(len(data["members"]), 4)
|
||||
self.assertEqual(len(data["members"]), 5)
|
||||
|
||||
# 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"]}
|
||||
self.assertEqual(expected_emails, actual_emails)
|
||||
|
||||
|
@ -123,8 +149,8 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
|
|||
self.assertTrue(data["has_next"])
|
||||
self.assertFalse(data["has_previous"])
|
||||
self.assertEqual(data["num_pages"], 2)
|
||||
self.assertEqual(data["total"], 14)
|
||||
self.assertEqual(data["unfiltered_total"], 14)
|
||||
self.assertEqual(data["total"], 15)
|
||||
self.assertEqual(data["unfiltered_total"], 15)
|
||||
|
||||
# Check the number of members on page 1
|
||||
self.assertEqual(len(data["members"]), 10)
|
||||
|
@ -142,7 +168,7 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
|
|||
self.assertEqual(data["num_pages"], 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):
|
||||
"""Test search functionality for portfolio members."""
|
||||
|
|
|
@ -10,6 +10,7 @@ from registrar.models import (
|
|||
UserDomainRole,
|
||||
User,
|
||||
)
|
||||
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||
from registrar.models.user_group import UserGroup
|
||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
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):
|
||||
"""Test that admin / memmber roles are associated with the right access"""
|
||||
self.app.set_user(self.user.username)
|
||||
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
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):
|
||||
# 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,
|
||||
the portfolio should be set in session."""
|
||||
self.client.force_login(self.user)
|
||||
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles)
|
||||
roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=roles)
|
||||
with override_flag("organization_feature", active=True):
|
||||
response = self.client.get(reverse("home"))
|
||||
# Ensure that middleware processes the session
|
||||
|
@ -420,8 +421,8 @@ class TestPortfolio(WebTest):
|
|||
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."""
|
||||
self.client.force_login(self.user)
|
||||
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles)
|
||||
roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=roles)
|
||||
response = self.client.get(reverse("home"))
|
||||
# Ensure that middleware processes the session
|
||||
session_middleware = SessionMiddleware(lambda request: None)
|
||||
|
@ -457,8 +458,8 @@ class TestPortfolio(WebTest):
|
|||
"""When multiple_portfolios flag is true and user has a portfolio,
|
||||
the portfolio should be set in session."""
|
||||
self.client.force_login(self.user)
|
||||
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles)
|
||||
roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
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):
|
||||
response = self.client.get(reverse("home"))
|
||||
# Ensure that middleware processes the session
|
||||
|
@ -817,7 +818,6 @@ class TestPortfolio(WebTest):
|
|||
|
||||
# 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}")
|
||||
print(response.content)
|
||||
self.assertContains(response, '"action_label": "View"')
|
||||
self.assertContains(response, '"svg_icon": "visibility"')
|
||||
|
||||
|
@ -856,6 +856,230 @@ class TestPortfolio(WebTest):
|
|||
# TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"{response.content}")
|
||||
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
|
||||
@override_flag("organization_feature", active=True)
|
||||
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):
|
||||
"""Test that the portfolio in session updates when the portfolio is modified"""
|
||||
self.client.force_login(self.user)
|
||||
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles)
|
||||
roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=roles)
|
||||
|
||||
with override_flag("organization_feature", active=True):
|
||||
# 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):
|
||||
"""Test that the portfolio in session is set to None when the organization_feature flag is disabled"""
|
||||
self.client.force_login(self.user)
|
||||
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles)
|
||||
roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=roles)
|
||||
|
||||
with override_flag("organization_feature", active=True):
|
||||
# Initial request to set the portfolio in session
|
||||
|
|
|
@ -43,7 +43,7 @@ class DomainRequestTests(TestWithUser, WebTest):
|
|||
super().setUp()
|
||||
self.federal_agency, _ = FederalAgency.objects.get_or_create(agency="General Services Administration")
|
||||
self.app.set_user(self.user.username)
|
||||
self.TITLES = DomainRequestWizard.TITLES
|
||||
self.TITLES = DomainRequestWizard.REGULAR_TITLES
|
||||
|
||||
def tearDown(self):
|
||||
super().tearDown()
|
||||
|
@ -82,7 +82,6 @@ class DomainRequestTests(TestWithUser, WebTest):
|
|||
response = self.app.get(f"/domain-request/{domain_request.id}")
|
||||
# Ensure that the date is still set to None
|
||||
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
|
||||
self.assertContains(response, "Started on:")
|
||||
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.
|
||||
# The next assert also tests for the passed requested domain context from
|
||||
# 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
|
||||
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, "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):
|
||||
def setUp(self):
|
||||
|
@ -2904,7 +2964,7 @@ class DomainRequestTestDifferentStatuses(TestWithUser, WebTest):
|
|||
self.assertNotContains(home_page, "city.gov")
|
||||
|
||||
|
||||
class TestWizardUnlockingSteps(TestWithUser, WebTest):
|
||||
class TestDomainRequestWizard(TestWithUser, WebTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.app.set_user(self.user.username)
|
||||
|
@ -3026,6 +3086,94 @@ class TestWizardUnlockingSteps(TestWithUser, WebTest):
|
|||
else:
|
||||
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):
|
||||
|
||||
|
@ -3037,7 +3185,7 @@ class TestPortfolioDomainRequestViewonly(TestWithUser, WebTest):
|
|||
super().setUp()
|
||||
self.federal_agency, _ = FederalAgency.objects.get_or_create(agency="General Services Administration")
|
||||
self.app.set_user(self.user.username)
|
||||
self.TITLES = DomainRequestWizard.TITLES
|
||||
self.TITLES = DomainRequestWizard.REGULAR_TITLES
|
||||
|
||||
def tearDown(self):
|
||||
super().tearDown()
|
||||
|
|
|
@ -6,36 +6,39 @@ from django.utils.html import escape
|
|||
from registrar.models.utility.generic_helper import value_of_attribute
|
||||
|
||||
|
||||
def get_all_action_needed_reason_emails(request, domain_request):
|
||||
"""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
|
||||
def get_action_needed_reason_default_email(domain_request, action_needed_reason):
|
||||
"""Returns the default email associated with the given action needed reason"""
|
||||
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],
|
||||
)
|
||||
|
||||
return emails
|
||||
|
||||
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_action_needed_reason_default_email(request, domain_request, 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:
|
||||
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
|
||||
|
||||
recipient = domain_request.creator
|
||||
# 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
|
||||
template_path = f"emails/action_needed_reasons/{action_needed_reason}.txt"
|
||||
|
||||
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")
|
||||
email_body_text = get_template(file_path).render(context=context)
|
||||
email_body_text_cleaned = email_body_text.strip().lstrip("\n") if email_body_text else None
|
||||
|
||||
return email_body_text_cleaned
|
||||
|
||||
|
|
|
@ -583,6 +583,105 @@ class DomainDataTypeUser(DomainDataType):
|
|||
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):
|
||||
"""
|
||||
Shows security contacts, filtered by state
|
||||
|
|
|
@ -74,10 +74,13 @@ class PortfolioDomainRequestStep(StrEnum):
|
|||
appear in the order they are defined. (Order matters.)
|
||||
"""
|
||||
|
||||
# Portfolio
|
||||
REQUESTING_ENTITY = "organization_name"
|
||||
# NOTE: Append portfolio_ when customizing a view for portfolio.
|
||||
# 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"
|
||||
DOTGOV_DOMAIN = "dotgov_domain"
|
||||
PURPOSE = "purpose"
|
||||
ADDITIONAL_DETAILS = "additional_details"
|
||||
ADDITIONAL_DETAILS = "portfolio_additional_details"
|
||||
REQUIREMENTS = "requirements"
|
||||
REVIEW = "review"
|
||||
|
|
|
@ -240,7 +240,7 @@ class SecurityEmailError(Exception):
|
|||
"""
|
||||
|
||||
_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):
|
||||
|
|
|
@ -43,8 +43,8 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
|||
although not without consulting the base implementation, first.
|
||||
"""
|
||||
|
||||
StepEnum: Step = Step # type: ignore
|
||||
template_name = ""
|
||||
is_portfolio = False
|
||||
|
||||
# uniquely namespace the wizard in urls.py
|
||||
# (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
|
||||
EDIT_URL_NAME = "edit-domain-request"
|
||||
NEW_URL_NAME = "/request/"
|
||||
|
||||
# region: Titles
|
||||
# We need to pass our human-readable step titles as context to the templates.
|
||||
TITLES = {
|
||||
StepEnum.ORGANIZATION_TYPE: _("Type of organization"),
|
||||
StepEnum.TRIBAL_GOVERNMENT: _("Tribal government"),
|
||||
StepEnum.ORGANIZATION_FEDERAL: _("Federal government branch"),
|
||||
StepEnum.ORGANIZATION_ELECTION: _("Election office"),
|
||||
StepEnum.ORGANIZATION_CONTACT: _("Organization"),
|
||||
StepEnum.ABOUT_YOUR_ORGANIZATION: _("About your organization"),
|
||||
StepEnum.SENIOR_OFFICIAL: _("Senior official"),
|
||||
StepEnum.CURRENT_SITES: _("Current websites"),
|
||||
StepEnum.DOTGOV_DOMAIN: _(".gov domain"),
|
||||
StepEnum.PURPOSE: _("Purpose of your domain"),
|
||||
StepEnum.OTHER_CONTACTS: _("Other employees from your organization"),
|
||||
StepEnum.ADDITIONAL_DETAILS: _("Additional details"),
|
||||
StepEnum.REQUIREMENTS: _("Requirements for operating a .gov domain"),
|
||||
StepEnum.REVIEW: _("Review and submit your domain request"),
|
||||
REGULAR_TITLES = {
|
||||
Step.ORGANIZATION_TYPE: _("Type of organization"),
|
||||
Step.TRIBAL_GOVERNMENT: _("Tribal government"),
|
||||
Step.ORGANIZATION_FEDERAL: _("Federal government branch"),
|
||||
Step.ORGANIZATION_ELECTION: _("Election office"),
|
||||
Step.ORGANIZATION_CONTACT: _("Organization"),
|
||||
Step.ABOUT_YOUR_ORGANIZATION: _("About your organization"),
|
||||
Step.SENIOR_OFFICIAL: _("Senior official"),
|
||||
Step.CURRENT_SITES: _("Current websites"),
|
||||
Step.DOTGOV_DOMAIN: _(".gov domain"),
|
||||
Step.PURPOSE: _("Purpose of your domain"),
|
||||
Step.OTHER_CONTACTS: _("Other employees from your organization"),
|
||||
Step.ADDITIONAL_DETAILS: _("Additional details"),
|
||||
Step.REQUIREMENTS: _("Requirements for operating a .gov domain"),
|
||||
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
|
||||
# to show or hide particular steps based on the state of the process.
|
||||
WIZARD_CONDITIONS = {
|
||||
StepEnum.ORGANIZATION_FEDERAL: lambda w: w.from_model("show_organization_federal", False),
|
||||
StepEnum.TRIBAL_GOVERNMENT: lambda w: w.from_model("show_tribal_government", False),
|
||||
StepEnum.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),
|
||||
REGULAR_WIZARD_CONDITIONS = {
|
||||
Step.ORGANIZATION_FEDERAL: lambda w: w.from_model("show_organization_federal", False),
|
||||
Step.TRIBAL_GOVERNMENT: lambda w: w.from_model("show_tribal_government", False),
|
||||
Step.ORGANIZATION_ELECTION: lambda w: w.from_model("show_organization_election", 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):
|
||||
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
|
||||
|
||||
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):
|
||||
"""Does this wizard know about a DomainRequest database record?"""
|
||||
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
|
||||
def prefix(self):
|
||||
"""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 self.request.user.is_org_user(self.request):
|
||||
portfolio = self.request.session.get("portfolio")
|
||||
self._domain_request = DomainRequest.objects.create(
|
||||
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:
|
||||
self._domain_request = DomainRequest.objects.create(creator=self.request.user)
|
||||
|
||||
|
@ -190,6 +294,12 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
|||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""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
|
||||
|
||||
# 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.
|
||||
# Creating a request will be handled in the post method for the
|
||||
# intro page.
|
||||
return render(request, "domain_request_intro.html", {})
|
||||
return render(request, "domain_request_intro.html", {"hide_requests": True, "hide_domains": True})
|
||||
else:
|
||||
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)
|
||||
|
||||
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."""
|
||||
|
||||
# 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]
|
||||
return [key for key, is_unlocked_checker in self.unlocking_steps.items() if is_unlocked_checker(self)]
|
||||
|
||||
def get_context_data(self):
|
||||
"""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
|
||||
|
||||
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>"
|
||||
context_stuff = {
|
||||
"not_form": False,
|
||||
"form_titles": self.TITLES,
|
||||
"form_titles": self.titles,
|
||||
"steps": self.steps,
|
||||
"visited": self.storage.get("step_history", []),
|
||||
"is_federal": self.domain_request.is_federal(),
|
||||
"modal_button": modal_button,
|
||||
"modal_heading": "You are about to submit a domain request for "
|
||||
+ str(self.domain_request.requested_domain),
|
||||
"modal_heading": "You are about to submit a domain request for ",
|
||||
"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.\
|
||||
You’ll only be able to withdraw your request.",
|
||||
"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>'
|
||||
context_stuff = {
|
||||
"not_form": True,
|
||||
"form_titles": self.TITLES,
|
||||
"form_titles": self.titles,
|
||||
"steps": self.steps,
|
||||
"visited": self.storage.get("step_history", []),
|
||||
"is_federal": self.domain_request.is_federal(),
|
||||
|
@ -413,14 +481,19 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
|||
"user": self.request.user,
|
||||
"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
|
||||
|
||||
def get_step_list(self) -> list:
|
||||
"""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):
|
||||
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
|
||||
# clicks the back button
|
||||
self.request.session["new_request"] = False
|
||||
|
@ -443,21 +516,21 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
|||
|
||||
def post(self, request, *args, **kwargs) -> HttpResponse:
|
||||
"""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?
|
||||
button: str = request.POST.get("submit_button", "")
|
||||
# If a user hits the new request url directly
|
||||
|
||||
if "new_request" not in request.session:
|
||||
request.session["new_request"] = True
|
||||
|
||||
# if user has acknowledged the intro message
|
||||
if button == "intro_acknowledge":
|
||||
if request.path_info == self.NEW_URL_NAME:
|
||||
|
||||
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)
|
||||
# Split into a function: C901 'DomainRequestWizard.post' is too complex (11)
|
||||
self.handle_intro_acknowledge(request)
|
||||
|
||||
# if accessing this class directly, redirect to the first step
|
||||
if self.__class__ == DomainRequestWizard:
|
||||
|
@ -488,6 +561,14 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
|||
# otherwise, proceed as normal
|
||||
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):
|
||||
"""
|
||||
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
|
||||
class PortfolioDomainRequestWizard(DomainRequestWizard):
|
||||
StepEnum: PortfolioDomainRequestStep = PortfolioDomainRequestStep # type: ignore
|
||||
|
||||
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
|
||||
is_portfolio = True
|
||||
|
||||
|
||||
# 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):
|
||||
template_name = "domain_request_org_type.html"
|
||||
forms = [forms.OrganizationTypeForm]
|
||||
|
@ -698,7 +777,7 @@ class Review(DomainRequestWizard):
|
|||
if DomainRequest._form_complete(self.domain_request, self.request) is False:
|
||||
logger.warning("User arrived at review page with an incomplete form.")
|
||||
context = super().get_context_data()
|
||||
context["Step"] = Step.__members__
|
||||
context["Step"] = self.get_step_enum().__members__
|
||||
context["domain_request"] = self.domain_request
|
||||
return context
|
||||
|
||||
|
@ -899,9 +978,9 @@ class PortfolioDomainRequestStatusViewOnly(DomainRequestPortfolioViewonlyView):
|
|||
# Create a temp wizard object to grab the step list
|
||||
wizard = PortfolioDomainRequestWizard()
|
||||
wizard.request = self.request
|
||||
context["Step"] = wizard.StepEnum.__members__
|
||||
context["steps"] = request_step_list(wizard)
|
||||
context["form_titles"] = wizard.TITLES
|
||||
context["Step"] = PortfolioDomainRequestStep.__members__
|
||||
context["steps"] = request_step_list(wizard, PortfolioDomainRequestStep)
|
||||
context["form_titles"] = wizard.titles
|
||||
return context
|
||||
|
||||
|
||||
|
|
|
@ -1,45 +1,41 @@
|
|||
from django.http import JsonResponse
|
||||
from django.core.paginator import Paginator
|
||||
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.user import User
|
||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
||||
|
||||
|
||||
@login_required
|
||||
def get_portfolio_members_json(request):
|
||||
"""Given the current request,
|
||||
get all members that are associated with the given portfolio"""
|
||||
"""Fetch members (permissions and invitations) for the given 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(
|
||||
portfolio=portfolio,
|
||||
roles__overlap=[
|
||||
UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
|
||||
],
|
||||
).values_list("user__id", flat=True)
|
||||
portfolio_invitation_emails = PortfolioInvitation.objects.filter(portfolio=portfolio).values_list(
|
||||
"email", flat=True
|
||||
)
|
||||
# Two initial querysets which will be combined
|
||||
permissions = initial_permissions_search(portfolio)
|
||||
invitations = initial_invitations_search(portfolio)
|
||||
|
||||
unfiltered_total = objects.count()
|
||||
# Get total across both querysets before applying filters
|
||||
unfiltered_total = permissions.count() + invitations.count()
|
||||
|
||||
objects = apply_search(objects, request)
|
||||
# objects = apply_status_filter(objects, request)
|
||||
permissions = apply_search_term(permissions, request)
|
||||
invitations = apply_search_term(invitations, request)
|
||||
|
||||
# Union the two querysets
|
||||
objects = permissions.union(invitations)
|
||||
objects = apply_sorting(objects, request)
|
||||
|
||||
paginator = Paginator(objects, 10)
|
||||
page_number = request.GET.get("page", 1)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
members = [
|
||||
serialize_members(request, portfolio, member, request.user, admin_ids, portfolio_invitation_emails)
|
||||
for member in page_obj.object_list
|
||||
]
|
||||
|
||||
members = [serialize_members(request, portfolio, item, request.user) for item in page_obj.object_list]
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
|
@ -54,71 +50,121 @@ def get_portfolio_members_json(request):
|
|||
)
|
||||
|
||||
|
||||
def get_member_ids_from_request(request, portfolio):
|
||||
"""Given the current request,
|
||||
get all members that are associated with the given portfolio"""
|
||||
member_ids = []
|
||||
if portfolio:
|
||||
member_ids = UserPortfolioPermission.objects.filter(portfolio=portfolio).values_list("user__id", flat=True)
|
||||
return member_ids
|
||||
def initial_permissions_search(portfolio):
|
||||
"""Perform initial search for permissions before applying any filters."""
|
||||
permissions = UserPortfolioPermission.objects.filter(portfolio=portfolio)
|
||||
permissions = (
|
||||
permissions.select_related("user")
|
||||
.annotate(
|
||||
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):
|
||||
search_term = request.GET.get("search_term")
|
||||
def initial_invitations_search(portfolio):
|
||||
"""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:
|
||||
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(email__icontains=search_term)
|
||||
| Q(email_display__icontains=search_term)
|
||||
)
|
||||
return queryset
|
||||
|
||||
|
||||
def apply_sorting(queryset, request):
|
||||
"""Apply sorting to the queryset."""
|
||||
sort_by = request.GET.get("sort_by", "id") # Default to 'id'
|
||||
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":
|
||||
sort_by = ["email", "first_name", "middle_name", "last_name"]
|
||||
else:
|
||||
sort_by = [sort_by]
|
||||
|
||||
sort_by = "member_display"
|
||||
if order == "desc":
|
||||
sort_by = [f"-{field}" for field in sort_by]
|
||||
|
||||
return queryset.order_by(*sort_by)
|
||||
queryset = queryset.order_by(F(sort_by).desc())
|
||||
else:
|
||||
queryset = queryset.order_by(sort_by)
|
||||
return queryset
|
||||
|
||||
|
||||
def serialize_members(request, portfolio, member, user, admin_ids, portfolio_invitation_emails):
|
||||
# ------- VIEW ONLY
|
||||
# If not view_only (the user has permissions to edit/manage users), show the gear icon with "Manage" link.
|
||||
# If view_only (the user only has view user permissions), show the "View" link (no gear icon).
|
||||
# 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
|
||||
def serialize_members(request, portfolio, item, user):
|
||||
# Check if the user can edit other users
|
||||
user_can_edit_other_users = any(
|
||||
user.has_perm(perm) for perm in ["registrar.full_access_permission", "registrar.change_user"]
|
||||
)
|
||||
|
||||
view_only = not user.has_edit_members_portfolio_permission(portfolio) or not user_can_edit_other_users
|
||||
|
||||
# ------- USER STATUSES
|
||||
is_invited = member.email in portfolio_invitation_emails
|
||||
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
|
||||
is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in (item.get("roles") or [])
|
||||
action_url = reverse("member" if item["source"] == "permission" else "invitedmember", kwargs={"pk": item["id"]})
|
||||
|
||||
# ------- SERIALIZE
|
||||
# Serialize member data
|
||||
member_json = {
|
||||
"id": member.id,
|
||||
"name": member.get_formatted_name(),
|
||||
"email": member.email,
|
||||
"id": item.get("id", ""),
|
||||
"name": " ".join(filter(None, [item.get("first_name", ""), item.get("last_name", "")])),
|
||||
"email": item.get("email_display", ""),
|
||||
"member_display": item.get("member_display", ""),
|
||||
"is_admin": is_admin,
|
||||
"last_active": last_active,
|
||||
"action_url": "#", # reverse("members", kwargs={"pk": member.id}), # TODO: Future ticket?
|
||||
"last_active": item.get("last_active", ""),
|
||||
"action_url": action_url,
|
||||
"action_label": ("View" if view_only else "Manage"),
|
||||
"svg_icon": ("visibility" if view_only else "settings"),
|
||||
}
|
||||
|
|
|
@ -3,20 +3,30 @@ from django.http import Http404
|
|||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
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.portfolio_invitation import PortfolioInvitation
|
||||
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 (
|
||||
PortfolioDomainRequestsPermissionView,
|
||||
PortfolioDomainsPermissionView,
|
||||
PortfolioBasePermissionView,
|
||||
NoPortfolioDomainsPermissionView,
|
||||
PortfolioInvitedMemberEditPermissionView,
|
||||
PortfolioInvitedMemberPermissionView,
|
||||
PortfolioMemberEditPermissionView,
|
||||
PortfolioMemberPermissionView,
|
||||
PortfolioMembersPermissionView,
|
||||
)
|
||||
from django.views.generic import View
|
||||
from django.views.generic.edit import FormMixin
|
||||
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -51,6 +61,155 @@ class PortfolioMembersView(PortfolioMembersPermissionView, View):
|
|||
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):
|
||||
"""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.
|
||||
|
|
|
@ -169,6 +169,17 @@ class ExportDataTypeUser(View):
|
|||
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):
|
||||
def get(self, request, *args, **kwargs):
|
||||
# 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 django.contrib.admin.views.decorators import staff_member_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.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)
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
if not self.request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
# The user has an ineligible flag
|
||||
if self.request.user.is_restricted():
|
||||
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
|
||||
|
||||
|
||||
|
@ -490,7 +512,81 @@ class PortfolioMembersPermission(PortfolioBasePermission):
|
|||
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):
|
||||
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 super().has_permission()
|
||||
|
|
|
@ -15,10 +15,14 @@ from .mixins import (
|
|||
DomainRequestWizardPermission,
|
||||
PortfolioDomainRequestsPermission,
|
||||
PortfolioDomainsPermission,
|
||||
PortfolioInvitedMemberEditPermission,
|
||||
PortfolioInvitedMemberPermission,
|
||||
PortfolioMemberEditPermission,
|
||||
UserDeleteDomainRolePermission,
|
||||
UserProfilePermission,
|
||||
PortfolioBasePermission,
|
||||
PortfolioMembersPermission,
|
||||
PortfolioMemberPermission,
|
||||
DomainRequestPortfolioViewonlyPermission,
|
||||
)
|
||||
import logging
|
||||
|
@ -253,7 +257,41 @@ class PortfolioDomainRequestsPermissionView(PortfolioDomainRequestsPermission, P
|
|||
|
||||
|
||||
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
|
||||
`template_name`.
|
||||
|
|
|
@ -71,6 +71,7 @@
|
|||
10038 OUTOFSCOPE http://app:8080/domain_requests/
|
||||
10038 OUTOFSCOPE http://app:8080/domains/
|
||||
10038 OUTOFSCOPE http://app:8080/organization/
|
||||
10038 OUTOFSCOPE http://app:8080/permissions
|
||||
10038 OUTOFSCOPE http://app:8080/suborganization/
|
||||
10038 OUTOFSCOPE http://app:8080/transfer/
|
||||
# This URL always returns 404, so include it as well.
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue