Merge remote-tracking branch 'origin/main' into ms/2307-send-notification-emails

This commit is contained in:
Matthew Spence 2024-10-17 09:39:34 -05:00
commit a722d3c9c5
No known key found for this signature in database
75 changed files with 4137 additions and 953 deletions

View file

@ -5,6 +5,11 @@ from django import forms
from django.db.models import Value, CharField, Q from django.db.models import Value, CharField, Q
from django.db.models.functions import Concat, Coalesce from django.db.models.functions import Concat, Coalesce
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from registrar.utility.admin_helpers import (
get_action_needed_reason_default_email,
get_rejection_reason_default_email,
get_field_links_as_list,
)
from django.conf import settings from django.conf import settings
from django.shortcuts import redirect from django.shortcuts import redirect
from django_fsm import get_available_FIELD_transitions, FSMField from django_fsm import get_available_FIELD_transitions, FSMField
@ -20,11 +25,6 @@ from epplibwrapper.errors import ErrorCode, RegistryError
from registrar.models.user_domain_role import UserDomainRole from registrar.models.user_domain_role import UserDomainRole
from waffle.admin import FlagAdmin from waffle.admin import FlagAdmin
from waffle.models import Sample, Switch from waffle.models import Sample, Switch
from registrar.utility.admin_helpers import (
get_all_action_needed_reason_emails,
get_action_needed_reason_default_email,
get_field_links_as_list,
)
from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial
from registrar.utility.constants import BranchChoices from registrar.utility.constants import BranchChoices
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
@ -190,11 +190,11 @@ class PortfolioInvitationAdminForm(UserChangeForm):
model = models.PortfolioInvitation model = models.PortfolioInvitation
fields = "__all__" fields = "__all__"
widgets = { widgets = {
"portfolio_roles": FilteredSelectMultipleArrayWidget( "roles": FilteredSelectMultipleArrayWidget(
"portfolio_roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices "roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices
), ),
"portfolio_additional_permissions": FilteredSelectMultipleArrayWidget( "additional_permissions": FilteredSelectMultipleArrayWidget(
"portfolio_additional_permissions", "additional_permissions",
is_stacked=False, is_stacked=False,
choices=UserPortfolioPermissionChoices.choices, choices=UserPortfolioPermissionChoices.choices,
), ),
@ -237,6 +237,7 @@ class DomainRequestAdminForm(forms.ModelForm):
} }
labels = { labels = {
"action_needed_reason_email": "Email", "action_needed_reason_email": "Email",
"rejection_reason_email": "Email",
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -1408,8 +1409,8 @@ class PortfolioInvitationAdmin(ListHeaderAdmin):
list_display = [ list_display = [
"email", "email",
"portfolio", "portfolio",
"portfolio_roles", "roles",
"portfolio_additional_permissions", "additional_permissions",
"status", "status",
] ]
@ -1750,6 +1751,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"status_history", "status_history",
"status", "status",
"rejection_reason", "rejection_reason",
"rejection_reason_email",
"action_needed_reason", "action_needed_reason",
"action_needed_reason_email", "action_needed_reason_email",
"investigator", "investigator",
@ -1905,25 +1907,11 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
# Get the original domain request from the database. # Get the original domain request from the database.
original_obj = models.DomainRequest.objects.get(pk=obj.pk) original_obj = models.DomainRequest.objects.get(pk=obj.pk)
# == Handle action_needed_reason == # # == Handle action needed and rejected emails == #
# Edge case: this logic is handled by javascript, so contexts outside that must be handled
reason_changed = obj.action_needed_reason != original_obj.action_needed_reason obj = self._handle_custom_emails(obj)
if reason_changed:
# Track the fact that we sent out an email
request.session["action_needed_email_sent"] = True
# Set the action_needed_reason_email to the default if nothing exists.
# Since this check occurs after save, if the user enters a value then we won't update.
default_email = get_action_needed_reason_default_email(request, obj, obj.action_needed_reason)
if obj.action_needed_reason_email:
emails = get_all_action_needed_reason_emails(request, obj)
is_custom_email = obj.action_needed_reason_email not in emails.values()
if not is_custom_email:
obj.action_needed_reason_email = default_email
else:
obj.action_needed_reason_email = default_email
# == Handle allowed emails == #
if obj.status in DomainRequest.get_statuses_that_send_emails() and not settings.IS_PRODUCTION: if obj.status in DomainRequest.get_statuses_that_send_emails() and not settings.IS_PRODUCTION:
self._check_for_valid_email(request, obj) self._check_for_valid_email(request, obj)
@ -1939,6 +1927,15 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
if should_save: if should_save:
return super().save_model(request, obj, form, change) return super().save_model(request, obj, form, change)
def _handle_custom_emails(self, obj):
if obj.status == DomainRequest.DomainRequestStatus.ACTION_NEEDED:
if obj.action_needed_reason and not obj.action_needed_reason_email:
obj.action_needed_reason_email = get_action_needed_reason_default_email(obj, obj.action_needed_reason)
elif obj.status == DomainRequest.DomainRequestStatus.REJECTED:
if obj.rejection_reason and not obj.rejection_reason_email:
obj.rejection_reason_email = get_rejection_reason_default_email(obj, obj.rejection_reason)
return obj
def _check_for_valid_email(self, request, obj): def _check_for_valid_email(self, request, obj):
"""Certain emails are whitelisted in non-production environments, """Certain emails are whitelisted in non-production environments,
so we should display that information using this function. so we should display that information using this function.
@ -1976,18 +1973,30 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
# If the status is not mapped properly, saving could cause # If the status is not mapped properly, saving could cause
# weird issues down the line. Instead, we should block this. # weird issues down the line. Instead, we should block this.
# NEEDS A UNIT TEST
should_proceed = False should_proceed = False
return should_proceed return (obj, should_proceed)
request_is_not_approved = obj.status != models.DomainRequest.DomainRequestStatus.APPROVED obj_is_not_approved = obj.status != models.DomainRequest.DomainRequestStatus.APPROVED
if request_is_not_approved and not obj.domain_is_not_active(): if obj_is_not_approved and not obj.domain_is_not_active():
# If an admin tried to set an approved domain request to # REDUNDANT CHECK / ERROR SCREEN AVOIDANCE:
# another status and the related domain is already # This action (moving a request from approved to
# active, shortcut the action and throw a friendly # another status) when the domain is already active (READY),
# error message. This action would still not go through # would still not go through even without this check as the rules are
# shortcut or not as the rules are duplicated on the model, # duplicated in the model and the error is raised from the model.
# but the error would be an ugly Django error screen. # This avoids an ugly Django error screen.
error_message = "This action is not permitted. The domain is already active." error_message = "This action is not permitted. The domain is already active."
elif (
original_obj.status != models.DomainRequest.DomainRequestStatus.APPROVED
and obj.status == models.DomainRequest.DomainRequestStatus.APPROVED
and original_obj.requested_domain is not None
and Domain.objects.filter(name=original_obj.requested_domain.name).exists()
):
# REDUNDANT CHECK:
# This action (approving a request when the domain exists)
# would still not go through even without this check as the rules are
# duplicated in the model and the error is raised from the model.
error_message = FSMDomainRequestError.get_error_message(FSMErrorCodes.APPROVE_DOMAIN_IN_USE)
elif obj.status == models.DomainRequest.DomainRequestStatus.REJECTED and not obj.rejection_reason: elif obj.status == models.DomainRequest.DomainRequestStatus.REJECTED and not obj.rejection_reason:
# This condition should never be triggered. # This condition should never be triggered.
# The opposite of this condition is acceptable (rejected -> other status and rejection_reason) # The opposite of this condition is acceptable (rejected -> other status and rejection_reason)
@ -2464,7 +2473,10 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
generic_org_type.admin_order_field = "domain_info__generic_org_type" # type: ignore generic_org_type.admin_order_field = "domain_info__generic_org_type" # type: ignore
def federal_agency(self, obj): def federal_agency(self, obj):
return obj.domain_info.federal_agency if obj.domain_info else None if obj.domain_info:
return obj.domain_info.federal_agency
else:
return None
federal_agency.admin_order_field = "domain_info__federal_agency" # type: ignore federal_agency.admin_order_field = "domain_info__federal_agency" # type: ignore

View file

@ -344,69 +344,6 @@ function initializeWidgetOnList(list, parentId) {
} }
} }
/** An IIFE for admin in DjangoAdmin to listen to changes on the domain request
* status select and to show/hide the rejection reason
*/
(function (){
let rejectionReasonFormGroup = document.querySelector('.field-rejection_reason')
// This is the "action needed reason" field
let actionNeededReasonFormGroup = document.querySelector('.field-action_needed_reason');
// This is the "Email" field
let actionNeededReasonEmailFormGroup = document.querySelector('.field-action_needed_reason_email')
if (rejectionReasonFormGroup && actionNeededReasonFormGroup && actionNeededReasonEmailFormGroup) {
let statusSelect = document.getElementById('id_status')
let isRejected = statusSelect.value == "rejected"
let isActionNeeded = statusSelect.value == "action needed"
// Initial handling of rejectionReasonFormGroup display
showOrHideObject(rejectionReasonFormGroup, show=isRejected)
showOrHideObject(actionNeededReasonFormGroup, show=isActionNeeded)
showOrHideObject(actionNeededReasonEmailFormGroup, show=isActionNeeded)
// Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage
statusSelect.addEventListener('change', function() {
// Show the rejection reason field if the status is rejected.
// Then track if its shown or hidden in our session cache.
isRejected = statusSelect.value == "rejected"
showOrHideObject(rejectionReasonFormGroup, show=isRejected)
addOrRemoveSessionBoolean("showRejectionReason", add=isRejected)
isActionNeeded = statusSelect.value == "action needed"
showOrHideObject(actionNeededReasonFormGroup, show=isActionNeeded)
showOrHideObject(actionNeededReasonEmailFormGroup, show=isActionNeeded)
addOrRemoveSessionBoolean("showActionNeededReason", add=isActionNeeded)
});
// Listen to Back/Forward button navigation and handle rejectionReasonFormGroup display based on session storage
// When you navigate using forward/back after changing status but not saving, when you land back on the DA page the
// status select will say (for example) Rejected but the selected option can be something else. To manage the show/hide
// accurately for this edge case, we use cache and test for the back/forward navigation.
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
if (entry.type === "back_forward") {
let showRejectionReason = sessionStorage.getItem("showRejectionReason") !== null
showOrHideObject(rejectionReasonFormGroup, show=showRejectionReason)
let showActionNeededReason = sessionStorage.getItem("showActionNeededReason") !== null
showOrHideObject(actionNeededReasonFormGroup, show=showActionNeededReason)
showOrHideObject(actionNeededReasonEmailFormGroup, show=isActionNeeded)
}
});
});
observer.observe({ type: "navigation" });
}
// Adds or removes the display-none class to object depending on the value of boolean show
function showOrHideObject(object, show){
if (show){
object.classList.remove("display-none");
}else {
object.classList.add("display-none");
}
}
})();
/** An IIFE for toggling the submit bar on domain request forms /** An IIFE for toggling the submit bar on domain request forms
*/ */
@ -501,82 +438,110 @@ function initializeWidgetOnList(list, parentId) {
})(); })();
/** An IIFE that hooks to the show/hide button underneath action needed reason. class CustomizableEmailBase {
* This shows the auto generated email on action needed reason.
/**
* @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() { constructor(config) {
const dropdown = document.getElementById("id_action_needed_reason"); this.config = config;
const textarea = document.getElementById("id_action_needed_reason_email") this.dropdown = config.dropdown;
const domainRequestId = dropdown ? document.getElementById("domain_request_id").value : null this.textarea = config.textarea;
const textareaPlaceholder = document.querySelector(".field-action_needed_reason_email__placeholder"); this.lastSentEmailContent = config.lastSentEmailContent;
const directEditButton = document.querySelector('.field-action_needed_reason_email__edit'); this.apiUrl = config.apiUrl;
const modalTrigger = document.querySelector('.field-action_needed_reason_email__modal-trigger'); this.apiErrorMessage = config.apiErrorMessage;
const modalConfirm = document.getElementById('confirm-edit-email'); this.modalConfirm = config.modalConfirm;
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;
// We will use the const to control the modal // These fields are hidden/shown on pageload depending on the current status
let isEmailAlreadySentConst = lastSentEmailContent.value.replace(/\s+/g, '') === textarea.value.replace(/\s+/g, ''); this.textAreaFormGroup = config.textAreaFormGroup;
// We will use the function to control the label and help this.dropdownFormGroup = config.dropdownFormGroup;
function isEmailAlreadySent() { this.statusToCheck = config.statusToCheck;
return lastSentEmailContent.value.replace(/\s+/g, '') === textarea.value.replace(/\s+/g, ''); 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) { // Handle showing/hiding the related fields on page load.
if (!reason) { initializeFormGroups() {
// No reason selected, we will set the label to "Email", show the "Make a selection" placeholder, hide the trigger, textarea, hide the help text let isStatus = this.statusSelect.value == this.statusToCheck;
formLabel.innerHTML = "Email:";
textareaPlaceholder.innerHTML = "Select an action needed reason to see email"; // Initial handling of these groups.
showElement(textareaPlaceholder); this.updateFormGroupVisibility(isStatus);
hideElement(directEditButton);
hideElement(modalTrigger); // Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage
hideElement(textarea); this.statusSelect.addEventListener('change', () => {
} else if (reason === 'other') { // Show the action needed field if the status is what we expect.
// '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 // Then track if its shown or hidden in our session cache.
formLabel.innerHTML = "Email:"; isStatus = this.statusSelect.value == this.statusToCheck;
textareaPlaceholder.innerHTML = "No email will be sent"; this.updateFormGroupVisibility(isStatus);
showElement(textareaPlaceholder); addOrRemoveSessionBoolean(this.sessionVariableName, isStatus);
hideElement(directEditButton); });
hideElement(modalTrigger);
hideElement(textarea); // 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 { }else {
// A triggering selection is selected, all hands on board: hideElement(this.textAreaFormGroup);
textarea.setAttribute('readonly', true); hideElement(this.dropdownFormGroup);
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:";
}
} }
} }
// Initialize UI initializeDropdown() {
updateUserInterface(dropdown.value); this.dropdown.addEventListener("change", () => {
let reason = this.dropdown.value;
dropdown.addEventListener("change", function() { if (this.initialDropdownValue !== this.dropdown.value || this.initialEmailValue !== this.textarea.value) {
const reason = dropdown.value; let searchParams = new URLSearchParams(
// Update the UI {
updateUserInterface(reason); "reason": reason,
if (reason && reason !== "other") { "domain_request_id": this.domainRequestId,
// If it's not the initial value }
if (initialDropdownValue !== dropdown.value || initialEmailValue !== textarea.value) { );
// Replace the email content // Replace the email content
fetch(`${apiUrl}?reason=${reason}&domain_request_id=${domainRequestId}`) fetch(`${this.apiUrl}?${searchParams.toString()}`)
.then(response => { .then(response => {
return response.json().then(data => data); return response.json().then(data => data);
}) })
@ -584,30 +549,213 @@ document.addEventListener('DOMContentLoaded', function() {
if (data.error) { if (data.error) {
console.error("Error in AJAX call: " + data.error); console.error("Error in AJAX call: " + data.error);
}else { }else {
textarea.value = data.action_needed_email; this.textarea.value = data.email;
} }
updateUserInterface(reason); this.updateUserInterface(reason);
}) })
.catch(error => { .catch(error => {
console.error("Error action needed email: ", error) console.error(this.apiErrorMessage, error)
}); });
} }
});
}
initializeModalConfirm() {
this.modalConfirm.addEventListener("click", () => {
this.textarea.removeAttribute('readonly');
this.textarea.focus();
hideElement(this.directEditButton);
hideElement(this.modalTrigger);
});
}
initializeDirectEditButton() {
this.directEditButton.addEventListener("click", () => {
this.textarea.removeAttribute('readonly');
this.textarea.focus();
hideElement(this.directEditButton);
hideElement(this.modalTrigger);
});
}
isEmailAlreadySent() {
return this.lastSentEmailContent.value.replace(/\s+/g, '') === this.textarea.value.replace(/\s+/g, '');
}
updateUserInterface(reason=this.dropdown.value, excluded_reasons=["other"]) {
if (!reason) {
// No reason selected, we will set the label to "Email", show the "Make a selection" placeholder, hide the trigger, textarea, hide the help text
this.showPlaceholderNoReason();
} else if (excluded_reasons.includes(reason)) {
// 'Other' selected, we will set the label to "Email", show the "No email will be sent" placeholder, hide the trigger, textarea, hide the help text
this.showPlaceholderOtherReason();
} else {
this.showReadonlyTextarea();
}
} }
// Helper function that makes overriding the readonly textarea easy
showReadonlyTextarea() {
// A triggering selection is selected, all hands on board:
this.textarea.setAttribute('readonly', true);
showElement(this.textarea);
hideElement(this.textareaPlaceholder);
if (this.isEmailAlreadySentConst) {
hideElement(this.directEditButton);
showElement(this.modalTrigger);
} else {
showElement(this.directEditButton);
hideElement(this.modalTrigger);
}
if (this.isEmailAlreadySent()) {
this.formLabel.innerHTML = "Email sent to creator:";
} else {
this.formLabel.innerHTML = "Email:";
}
}
// 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'); class customRejectedEmail extends CustomizableEmailBase {
textarea.focus(); constructor() {
hideElement(directEditButton); const emailConfig = {
hideElement(modalTrigger); dropdown: document.getElementById("id_rejection_reason"),
}); textarea: document.getElementById("id_rejection_reason_email"),
directEditButton.addEventListener("click", () => { lastSentEmailContent: document.getElementById("last-sent-rejection-email-content"),
textarea.removeAttribute('readonly'); modalConfirm: document.getElementById("rejection-reason__confirm-edit-email"),
textarea.focus(); apiUrl: document.getElementById("get-rejection-email-for-user-json")?.value || null,
hideElement(directEditButton); textAreaFormGroup: document.querySelector('.field-rejection_reason'),
hideElement(modalTrigger); dropdownFormGroup: document.querySelector('.field-rejection_reason_email'),
}); statusToCheck: "rejected",
sessionVariableName: "showRejectionReason",
errorMessage: "Error when attempting to grab rejected email: "
};
super(emailConfig);
}
loadRejectedEmail() {
this.initializeFormGroups();
this.updateUserInterface();
this.initializeDropdown();
this.initializeModalConfirm();
this.initializeDirectEditButton();
}
// Overrides the placeholder text when no reason is selected
showPlaceholderNoReason() {
this.showPlaceholder("Email:", "Select a rejection reason to see email");
}
updateUserInterface(reason=this.dropdown.value, excluded_reasons=[]) {
super.updateUserInterface(reason, excluded_reasons);
}
// Overrides the placeholder text when the reason other is selected
// showPlaceholderOtherReason() {
// this.showPlaceholder("Email:", "No email will be sent");
// }
}
/** An IIFE that hooks to the show/hide button underneath rejected reason.
* This shows the auto generated email on action needed reason.
*/
document.addEventListener('DOMContentLoaded', function() {
const domainRequestForm = document.getElementById("domainrequest_form");
if (!domainRequestForm) {
return;
}
// Initialize UI
const customEmail = new customRejectedEmail();
// Check that every variable was setup correctly
const nullItems = Object.entries(customEmail.config).filter(([key, value]) => value === null).map(([key]) => key);
if (nullItems.length > 0) {
console.error(`Failed to load customRejectedEmail(). Some variables were null: ${nullItems.join(", ")}`)
return;
}
customEmail.loadRejectedEmail()
}); });
@ -706,18 +854,6 @@ document.addEventListener('DOMContentLoaded', function() {
} }
return ''; return '';
} }
// Extract the submitter name, title, email, and phone number
const submitterDiv = document.querySelector('.form-row.field-submitter');
const submitterNameElement = document.getElementById('id_submitter');
// We have to account for different superuser and analyst markups
const submitterName = submitterNameElement
? submitterNameElement.options[submitterNameElement.selectedIndex].text
: submitterDiv.querySelector('a').text;
const submitterTitle = extractTextById('contact_info_title', submitterDiv);
const submitterEmail = extractTextById('contact_info_email', submitterDiv);
const submitterPhone = extractTextById('contact_info_phone', submitterDiv);
let submitterInfo = `${submitterName}${submitterTitle}${submitterEmail}${submitterPhone}`;
//------ Senior Official //------ Senior Official
const seniorOfficialDiv = document.querySelector('.form-row.field-senior_official'); const seniorOfficialDiv = document.querySelector('.form-row.field-senior_official');
@ -734,7 +870,6 @@ document.addEventListener('DOMContentLoaded', function() {
`<strong>Current Websites:</strong> ${existingWebsites.join(', ')}</br>` + `<strong>Current Websites:</strong> ${existingWebsites.join(', ')}</br>` +
`<strong>Rationale:</strong></br>` + `<strong>Rationale:</strong></br>` +
`<strong>Alternative Domains:</strong> ${alternativeDomains.join(', ')}</br>` + `<strong>Alternative Domains:</strong> ${alternativeDomains.join(', ')}</br>` +
`<strong>Submitter:</strong> ${submitterInfo}</br>` +
`<strong>Senior Official:</strong> ${seniorOfficialInfo}</br>` + `<strong>Senior Official:</strong> ${seniorOfficialInfo}</br>` +
`<strong>Other Employees:</strong> ${otherContactsSummary}</br>`; `<strong>Other Employees:</strong> ${otherContactsSummary}</br>`;

View file

@ -1498,12 +1498,23 @@ class DomainsTable extends LoadTableBase {
} }
} }
class DomainRequestsTable extends LoadTableBase { class DomainRequestsTable extends LoadTableBase {
constructor() { constructor() {
super('.domain-requests__table', '.domain-requests__table-wrapper', '#domain-requests__search-field', '#domain-requests__search-field-submit', '.domain-requests__reset-search', '.domain-requests__reset-filters', '.domain-requests__no-data', '.domain-requests__no-search-results'); super('.domain-requests__table', '.domain-requests__table-wrapper', '#domain-requests__search-field', '#domain-requests__search-field-submit', '.domain-requests__reset-search', '.domain-requests__reset-filters', '.domain-requests__no-data', '.domain-requests__no-search-results');
} }
toggleExportButton(requests) {
const exportButton = document.getElementById('export-csv');
if (exportButton) {
if (requests.length > 0) {
showElement(exportButton);
} else {
hideElement(exportButton);
}
}
}
/** /**
* Loads rows in the domains list, as well as updates pagination around the domains list * Loads rows in the domains list, as well as updates pagination around the domains list
* based on the supplied attributes. * based on the supplied attributes.
@ -1517,6 +1528,7 @@ class DomainRequestsTable extends LoadTableBase {
*/ */
loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm = this.currentSearchTerm, portfolio = this.portfolioValue) { loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm = this.currentSearchTerm, portfolio = this.portfolioValue) {
let baseUrl = document.getElementById("get_domain_requests_json_url"); let baseUrl = document.getElementById("get_domain_requests_json_url");
if (!baseUrl) { if (!baseUrl) {
return; return;
} }
@ -1548,6 +1560,9 @@ class DomainRequestsTable extends LoadTableBase {
return; return;
} }
// Manage "export as CSV" visibility for domain requests
this.toggleExportButton(data.domain_requests);
// handle the display of proper messaging in the event that no requests exist in the list or search returns no results // handle the display of proper messaging in the event that no requests exist in the list or search returns no results
this.updateDisplay(data, this.tableWrapper, this.noTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm); this.updateDisplay(data, this.tableWrapper, this.noTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm);
@ -1865,11 +1880,10 @@ class MembersTable extends LoadTableBase {
* @param {*} sortBy - the sort column option * @param {*} sortBy - the sort column option
* @param {*} order - the sort order {asc, desc} * @param {*} order - the sort order {asc, desc}
* @param {*} scroll - control for the scrollToElement functionality * @param {*} scroll - control for the scrollToElement functionality
* @param {*} status - control for the status filter
* @param {*} searchTerm - the search term * @param {*} searchTerm - the search term
* @param {*} portfolio - the portfolio id * @param {*} portfolio - the portfolio id
*/ */
loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) { loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) {
// --------- SEARCH // --------- SEARCH
let searchParams = new URLSearchParams( let searchParams = new URLSearchParams(
@ -1877,7 +1891,6 @@ class MembersTable extends LoadTableBase {
"page": page, "page": page,
"sort_by": sortBy, "sort_by": sortBy,
"order": order, "order": order,
"status": status,
"search_term": searchTerm "search_term": searchTerm
} }
); );
@ -1913,11 +1926,40 @@ class MembersTable extends LoadTableBase {
const memberList = document.querySelector('.members__table tbody'); const memberList = document.querySelector('.members__table tbody');
memberList.innerHTML = ''; memberList.innerHTML = '';
const invited = 'Invited';
data.members.forEach(member => { data.members.forEach(member => {
// const actionUrl = domain.action_url;
const member_name = member.name; const member_name = member.name;
const member_email = member.email; const member_display = member.member_display;
const last_active = member.last_active; const options = { year: 'numeric', month: 'short', day: 'numeric' };
// Handle last_active values
let last_active = member.last_active;
let last_active_formatted = '';
let last_active_sort_value = '';
// Handle 'Invited' or null/empty values differently from valid dates
if (last_active && last_active !== invited) {
try {
// Try to parse the last_active as a valid date
last_active = new Date(last_active);
if (!isNaN(last_active)) {
last_active_formatted = last_active.toLocaleDateString('en-US', options);
last_active_sort_value = last_active.getTime(); // For sorting purposes
} else {
last_active_formatted='Invalid date'
}
} catch (e) {
console.error(`Error parsing date: ${last_active}. Error: ${e}`);
last_active_formatted='Invalid date'
}
} else {
// Handle 'Invited' or null
last_active = invited;
last_active_formatted = invited;
last_active_sort_value = invited; // Keep 'Invited' as a sortable string
}
const action_url = member.action_url; const action_url = member.action_url;
const action_label = member.action_label; const action_label = member.action_label;
const svg_icon = member.svg_icon; const svg_icon = member.svg_icon;
@ -1930,10 +1972,10 @@ class MembersTable extends LoadTableBase {
row.innerHTML = ` row.innerHTML = `
<th scope="row" role="rowheader" data-label="member email"> <th scope="row" role="rowheader" data-label="member email">
${member_email ? member_email : member_name} ${admin_tagHTML} ${member_display} ${admin_tagHTML}
</th> </th>
<td data-sort-value="${last_active}" data-label="last_active"> <td data-sort-value="${last_active_sort_value}" data-label="last_active">
${last_active} ${last_active_formatted}
</td> </td>
<td> <td>
<a href="${action_url}"> <a href="${action_url}">

View file

@ -25,6 +25,8 @@
/** /**
* Edits made for dotgov project: * Edits made for dotgov project:
* - tooltip exposed to window to be accessible in other js files * - tooltip exposed to window to be accessible in other js files
* - tooltip positioning logic updated to allow position:fixed
* - tooltip dynamic content updated to include nested element (for better sizing control)
* - modal exposed to window to be accessible in other js files * - modal exposed to window to be accessible in other js files
* - fixed bug in createHeaderButton which added newlines to header button tooltips * - fixed bug in createHeaderButton which added newlines to header button tooltips
*/ */
@ -5938,6 +5940,22 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
return offset; return offset;
}; };
// ---- DOTGOV EDIT (Added section)
// DOTGOV: Tooltip positioning logic updated to allow position:fixed
const tooltipStyle = window.getComputedStyle(tooltipBody);
const tooltipIsFixedPositioned = tooltipStyle.position === 'fixed';
const triggerRect = tooltipTrigger.getBoundingClientRect(); //detect if tooltip is set to "fixed" position
const targetLeft = tooltipIsFixedPositioned ? triggerRect.left + triggerRect.width/2 + 'px': `50%`
const targetTop = tooltipIsFixedPositioned ? triggerRect.top + triggerRect.height/2 + 'px': `50%`
if (tooltipIsFixedPositioned) {
/* DOTGOV: Add listener to handle scrolling if tooltip position = 'fixed'
(so that the tooltip doesn't appear to stick to the screen) */
window.addEventListener('scroll', function() {
findBestPosition(tooltipBody)
});
}
// ---- END DOTGOV EDIT
/** /**
* Positions tooltip at the top * Positions tooltip at the top
* @param {HTMLElement} e - this is the tooltip body * @param {HTMLElement} e - this is the tooltip body
@ -5949,8 +5967,16 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
const topMargin = calculateMarginOffset("top", e.offsetHeight, tooltipTrigger); const topMargin = calculateMarginOffset("top", e.offsetHeight, tooltipTrigger);
const leftMargin = calculateMarginOffset("left", e.offsetWidth, tooltipTrigger); const leftMargin = calculateMarginOffset("left", e.offsetWidth, tooltipTrigger);
setPositionClass("top"); setPositionClass("top");
e.style.left = `50%`; // center the element
e.style.top = `-${TRIANGLE_SIZE}px`; // consider the pseudo element // ---- DOTGOV EDIT
// e.style.left = `50%`; // center the element
// e.style.top = `-${TRIANGLE_SIZE}px`; // consider the pseudo element
// DOTGOV: updated logic for position:fixed
e.style.left = targetLeft; // center the element
e.style.top = tooltipIsFixedPositioned ?`${triggerRect.top-TRIANGLE_SIZE}px`:`-${TRIANGLE_SIZE}px`; // consider the pseudo element
// ---- END DOTGOV EDIT
// apply our margins based on the offset // apply our margins based on the offset
e.style.margin = `-${topMargin}px 0 0 -${leftMargin / 2}px`; e.style.margin = `-${topMargin}px 0 0 -${leftMargin / 2}px`;
}; };
@ -5963,7 +5989,17 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
resetPositionStyles(e); resetPositionStyles(e);
const leftMargin = calculateMarginOffset("left", e.offsetWidth, tooltipTrigger); const leftMargin = calculateMarginOffset("left", e.offsetWidth, tooltipTrigger);
setPositionClass("bottom"); setPositionClass("bottom");
e.style.left = `50%`;
// ---- DOTGOV EDIT
// e.style.left = `50%`;
// DOTGOV: updated logic for position:fixed
if (tooltipIsFixedPositioned){
e.style.top = triggerRect.bottom+'px';
}
// ---- END DOTGOV EDIT
e.style.left = targetLeft;
e.style.margin = `${TRIANGLE_SIZE}px 0 0 -${leftMargin / 2}px`; e.style.margin = `${TRIANGLE_SIZE}px 0 0 -${leftMargin / 2}px`;
}; };
@ -5975,8 +6011,16 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
resetPositionStyles(e); resetPositionStyles(e);
const topMargin = calculateMarginOffset("top", e.offsetHeight, tooltipTrigger); const topMargin = calculateMarginOffset("top", e.offsetHeight, tooltipTrigger);
setPositionClass("right"); setPositionClass("right");
e.style.top = `50%`;
e.style.left = `${tooltipTrigger.offsetLeft + tooltipTrigger.offsetWidth + TRIANGLE_SIZE}px`; // ---- DOTGOV EDIT
// e.style.top = `50%`;
// e.style.left = `${tooltipTrigger.offsetLeft + tooltipTrigger.offsetWidth + TRIANGLE_SIZE}px`;
// DOTGOV: updated logic for position:fixed
e.style.top = targetTop;
e.style.left = tooltipIsFixedPositioned ? `${triggerRect.right + TRIANGLE_SIZE}px`:`${tooltipTrigger.offsetLeft + tooltipTrigger.offsetWidth + TRIANGLE_SIZE}px`;
// ---- END DOTGOV EDIT
e.style.margin = `-${topMargin / 2}px 0 0 0`; e.style.margin = `-${topMargin / 2}px 0 0 0`;
}; };
@ -5991,8 +6035,16 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
// we have to check for some utility margins // we have to check for some utility margins
const leftMargin = calculateMarginOffset("left", tooltipTrigger.offsetLeft > e.offsetWidth ? tooltipTrigger.offsetLeft - e.offsetWidth : e.offsetWidth, tooltipTrigger); const leftMargin = calculateMarginOffset("left", tooltipTrigger.offsetLeft > e.offsetWidth ? tooltipTrigger.offsetLeft - e.offsetWidth : e.offsetWidth, tooltipTrigger);
setPositionClass("left"); setPositionClass("left");
e.style.top = `50%`;
e.style.left = `-${TRIANGLE_SIZE}px`; // ---- DOTGOV EDIT
// e.style.top = `50%`;
// e.style.left = `-${TRIANGLE_SIZE}px`;
// DOTGOV: updated logic for position:fixed
e.style.top = targetTop;
e.style.left = tooltipIsFixedPositioned ? `${triggerRect.left-TRIANGLE_SIZE}px` : `-${TRIANGLE_SIZE}px`;
// ---- END DOTGOV EDIT
e.style.margin = `-${topMargin / 2}px 0 0 ${tooltipTrigger.offsetLeft > e.offsetWidth ? leftMargin : -leftMargin}px`; // adjust the margin e.style.margin = `-${topMargin / 2}px 0 0 ${tooltipTrigger.offsetLeft > e.offsetWidth ? leftMargin : -leftMargin}px`; // adjust the margin
}; };
@ -6017,6 +6069,7 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
if (i < positions.length) { if (i < positions.length) {
const pos = positions[i]; const pos = positions[i];
pos(element); pos(element);
if (!isElementInViewport(element)) { if (!isElementInViewport(element)) {
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
tryPositions(i += 1); tryPositions(i += 1);
@ -6128,7 +6181,17 @@ const setUpAttributes = tooltipTrigger => {
tooltipBody.setAttribute("aria-hidden", "true"); tooltipBody.setAttribute("aria-hidden", "true");
// place the text in the tooltip // place the text in the tooltip
tooltipBody.textContent = tooltipContent;
// -- DOTGOV EDIT
// tooltipBody.textContent = tooltipContent;
// DOTGOV: nest the text element to allow us greater control over width and wrapping behavior
tooltipBody.innerHTML = `
<span class="usa-tooltip__content">
${tooltipContent}
</span>`
// -- END DOTGOV EDIT
return { return {
tooltipBody, tooltipBody,
position, position,

View file

@ -385,6 +385,7 @@ a.button,
font-kerning: auto; font-kerning: auto;
font-family: inherit; font-family: inherit;
font-weight: normal; font-weight: normal;
text-decoration: none !important;
} }
.button svg, .button svg,
.button span, .button span,
@ -392,6 +393,9 @@ a.button,
.usa-button--dja span { .usa-button--dja span {
vertical-align: middle; vertical-align: middle;
} }
.usa-button--dja.usa-button--unstyled {
color: var(--link-fg);
}
.usa-button--dja:not(.usa-button--unstyled, .usa-button--outline, .usa-modal__close, .usa-button--secondary) { .usa-button--dja:not(.usa-button--unstyled, .usa-button--outline, .usa-modal__close, .usa-button--secondary) {
background: var(--button-bg); background: var(--button-bg);
} }
@ -421,11 +425,34 @@ input[type=submit].button--dja-toolbar {
input[type=submit].button--dja-toolbar:focus, input[type=submit].button--dja-toolbar:hover { input[type=submit].button--dja-toolbar:focus, input[type=submit].button--dja-toolbar:hover {
border-color: var(--body-quiet-color); border-color: var(--body-quiet-color);
} }
// Targets the DJA buttom with a nested icon .admin-icon-group {
button .usa-icon, position: relative;
.button .usa-icon, display: inline;
.button--clipboard .usa-icon { align-items: center;
vertical-align: middle;
input {
// Allow for padding around the copy button
padding-right: 35px !important;
}
button {
width: max-content;
}
@media (max-width: 1000px) {
button {
display: block;
}
}
span {
padding-left: 0.05rem;
}
}
.usa-button__small-text,
.usa-button__small-text span {
font-size: 13px;
} }
.module--custom { .module--custom {
@ -673,71 +700,10 @@ address.dja-address-contact-list {
} }
} }
// Make the clipboard button "float" inside of the input box
.admin-icon-group {
position: relative;
display: inline;
align-items: center;
input {
// Allow for padding around the copy button
padding-right: 35px !important;
// Match the height of other inputs
min-height: 2.25rem !important;
}
button {
line-height: 14px;
width: max-content;
font-size: unset;
text-decoration: none !important;
}
@media (max-width: 1000px) {
button {
display: block;
padding-top: 8px;
}
}
span {
padding-left: 0.1rem;
}
}
.admin-icon-group.admin-icon-group__clipboard-link {
position: relative;
display: inline;
align-items: center;
.usa-button--icon {
position: absolute;
right: auto;
left: 4px;
height: 100%;
top: -1px;
}
button {
font-size: unset !important;
display: inline-flex;
padding-top: 4px;
line-height: 14px;
width: max-content;
font-size: unset;
text-decoration: none !important;
}
}
.no-outline-on-click:focus { .no-outline-on-click:focus {
outline: none !important; outline: none !important;
} }
.usa-button__small-text {
font-size: small;
}
// Get rid of padding on all help texts // Get rid of padding on all help texts
form .aligned p.help, form .aligned div.help { form .aligned p.help, form .aligned div.help {
padding-left: 0px !important; padding-left: 0px !important;
@ -887,6 +853,9 @@ div.dja__model-description{
padding-top: 0 !important; padding-top: 0 !important;
} }
.padding-bottom-0 {
padding-bottom: 0 !important;
}
.flex-container { .flex-container {
@media screen and (min-width: 700px) and (max-width: 1150px) { @media screen and (min-width: 700px) and (max-width: 1150px) {

View file

@ -254,6 +254,7 @@ a .usa-icon,
// Note: Can be simplified by adding text-secondary to delete anchors in tables // Note: Can be simplified by adding text-secondary to delete anchors in tables
button.text-secondary, button.text-secondary,
button.text-secondary:hover, button.text-secondary:hover,
.dotgov-table a.text-secondary { a.text-secondary,
a.text-secondary:hover {
color: $theme-color-error; color: $theme-color-error;
} }

View file

@ -28,3 +28,47 @@
#extended-logo .usa-tooltip__body { #extended-logo .usa-tooltip__body {
font-weight: 400 !important; font-weight: 400 !important;
} }
.domains__table {
/*
Trick tooltips in the domains table to do 2 things...
1 - Shrink itself to a padded viewport window
(override width and wrapping properties in key areas to constrain tooltip size)
2 - NOT be clipped by the table's scrollable view
(Set tooltip position to "fixed", which prevents tooltip from being clipped by parent
containers. Fixed-position detection was added to uswds positioning logic to update positioning
calculations accordingly.)
*/
.usa-tooltip__body {
white-space: inherit;
max-width: fit-content; // prevent adjusted widths from being larger than content
position: fixed; // prevents clipping by parent containers
}
/*
Override width adustments in this dynamically added class
(this is original to the javascript handler as a way to shrink tooltip contents within the viewport,
but is insufficient for our needs. We cannot simply override its properties
because the logic surrounding its dynamic appearance in the DOM does not account
for parent containers (basically, this class isn't in the DOM when we need it).
Intercept .usa-tooltip__content instead and nullify the effects of
.usa-tooltip__body--wrap to prevent conflicts)
*/
.usa-tooltip__body--wrap {
min-width: inherit;
width: inherit;
}
/*
Add width and wrapping to tooltip content in order to confine it to a smaller viewport window.
*/
.usa-tooltip__content {
width: 50vw;
text-wrap: wrap;
text-align: center;
font-size: inherit; //inherit tooltip fontsize of .93rem
max-width: fit-content;
@include at-media('desktop') {
width: 70vw;
}
display: block;
}
}

View file

@ -20,6 +20,7 @@ from registrar.views.report_views import (
AnalyticsView, AnalyticsView,
ExportDomainRequestDataFull, ExportDomainRequestDataFull,
ExportDataTypeUser, ExportDataTypeUser,
ExportDataTypeRequests,
) )
# --jsons # --jsons
@ -30,9 +31,10 @@ from registrar.views.utility.api_views import (
get_senior_official_from_federal_agency_json, get_senior_official_from_federal_agency_json,
get_federal_and_portfolio_types_from_federal_agency_json, get_federal_and_portfolio_types_from_federal_agency_json,
get_action_needed_email_for_user_json, get_action_needed_email_for_user_json,
get_rejection_email_for_user_json,
) )
from registrar.views.domain_request import Step from registrar.views.domain_request import Step, PortfolioDomainRequestStep
from registrar.views.transfer_user import TransferUserView from registrar.views.transfer_user import TransferUserView
from registrar.views.utility import always_404 from registrar.views.utility import always_404
from api.views import available, rdap, get_current_federal, get_current_full from api.views import available, rdap, get_current_federal, get_current_full
@ -60,6 +62,9 @@ for step, view in [
(Step.ADDITIONAL_DETAILS, views.AdditionalDetails), (Step.ADDITIONAL_DETAILS, views.AdditionalDetails),
(Step.REQUIREMENTS, views.Requirements), (Step.REQUIREMENTS, views.Requirements),
(Step.REVIEW, views.Review), (Step.REVIEW, views.Review),
# Portfolio steps
(PortfolioDomainRequestStep.REQUESTING_ENTITY, views.RequestingEntity),
(PortfolioDomainRequestStep.ADDITIONAL_DETAILS, views.PortfolioAdditionalDetails),
]: ]:
domain_request_urls.append(path(f"{step}/", view.as_view(), name=step)) domain_request_urls.append(path(f"{step}/", view.as_view(), name=step))
@ -81,6 +86,26 @@ urlpatterns = [
views.PortfolioMembersView.as_view(), views.PortfolioMembersView.as_view(),
name="members", name="members",
), ),
path(
"member/<int:pk>",
views.PortfolioMemberView.as_view(),
name="member",
),
path(
"member/<int:pk>/permissions",
views.PortfolioMemberEditView.as_view(),
name="member-permissions",
),
path(
"invitedmember/<int:pk>",
views.PortfolioInvitedMemberView.as_view(),
name="invitedmember",
),
path(
"invitedmember/<int:pk>/permissions",
views.PortfolioInvitedMemberEditView.as_view(),
name="invitedmember-permissions",
),
# path( # path(
# "no-organization-members/", # "no-organization-members/",
# views.PortfolioNoMembersView.as_view(), # views.PortfolioNoMembersView.as_view(),
@ -171,6 +196,11 @@ urlpatterns = [
get_action_needed_email_for_user_json, get_action_needed_email_for_user_json,
name="get-action-needed-email-for-user-json", name="get-action-needed-email-for-user-json",
), ),
path(
"admin/api/get-rejection-email-for-user-json/",
get_rejection_email_for_user_json,
name="get-rejection-email-for-user-json",
),
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path( path(
"reports/export_data_type_user/", "reports/export_data_type_user/",
@ -178,7 +208,17 @@ urlpatterns = [
name="export_data_type_user", name="export_data_type_user",
), ),
path( path(
"domain-request/<id>/edit/", "reports/export_data_type_requests/",
ExportDataTypeRequests.as_view(),
name="export_data_type_requests",
),
path(
"reports/export_data_type_requests/",
ExportDataTypeRequests.as_view(),
name="export_data_type_requests",
),
path(
"domain-request/<int:id>/edit/",
views.DomainRequestWizard.as_view(), views.DomainRequestWizard.as_view(),
name=views.DomainRequestWizard.EDIT_URL_NAME, name=views.DomainRequestWizard.EDIT_URL_NAME,
), ),

View file

@ -7,7 +7,7 @@ from registrar.fixtures.fixtures_users import UserFixture
from registrar.models import User from registrar.models import User
from registrar.models.portfolio import Portfolio from registrar.models.portfolio import Portfolio
from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
fake = Faker() fake = Faker()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -58,6 +58,7 @@ class UserPortfolioPermissionFixture:
user=user, user=user,
portfolio=portfolio, portfolio=portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[UserPortfolioPermissionChoices.EDIT_MEMBERS],
) )
user_portfolio_permissions_to_create.append(user_portfolio_permission) user_portfolio_permissions_to_create.append(user_portfolio_permission)
else: else:

View file

@ -13,4 +13,5 @@ from .domain import (
) )
from .portfolio import ( from .portfolio import (
PortfolioOrgAddressForm, PortfolioOrgAddressForm,
PortfolioMemberForm,
) )

View file

@ -4,7 +4,7 @@ import logging
from django import forms from django import forms
from django.core.validators import MinValueValidator, MaxValueValidator, RegexValidator, MaxLengthValidator from django.core.validators import MinValueValidator, MaxValueValidator, RegexValidator, MaxLengthValidator
from django.forms import formset_factory from django.forms import formset_factory
from registrar.models import DomainRequest from registrar.models import DomainRequest, FederalAgency
from phonenumber_field.widgets import RegionalPhoneNumberWidget from phonenumber_field.widgets import RegionalPhoneNumberWidget
from registrar.models.suborganization import Suborganization from registrar.models.suborganization import Suborganization
from registrar.models.utility.domain_helper import DomainHelper from registrar.models.utility.domain_helper import DomainHelper
@ -35,7 +35,10 @@ class DomainAddUserForm(forms.Form):
email = forms.EmailField( email = forms.EmailField(
label="Email", label="Email",
max_length=None, max_length=None,
error_messages={"invalid": ("Enter your email address in the required format, like name@example.com.")}, error_messages={
"invalid": ("Enter an email address in the required format, like name@example.com."),
"required": ("Enter an email address in the required format, like name@example.com."),
},
validators=[ validators=[
MaxLengthValidator( MaxLengthValidator(
320, 320,
@ -285,7 +288,7 @@ class UserForm(forms.ModelForm):
"required": "Enter your title or role in your organization (e.g., Chief Information Officer)" "required": "Enter your title or role in your organization (e.g., Chief Information Officer)"
} }
self.fields["email"].error_messages = { self.fields["email"].error_messages = {
"required": "Enter your email address in the required format, like name@example.com." "required": "Enter an email address in the required format, like name@example.com."
} }
self.fields["phone"].error_messages["required"] = "Enter your phone number." self.fields["phone"].error_messages["required"] = "Enter your phone number."
self.domainInfo = None self.domainInfo = None
@ -342,7 +345,7 @@ class ContactForm(forms.ModelForm):
"required": "Enter your title or role in your organization (e.g., Chief Information Officer)" "required": "Enter your title or role in your organization (e.g., Chief Information Officer)"
} }
self.fields["email"].error_messages = { self.fields["email"].error_messages = {
"required": "Enter your email address in the required format, like name@example.com." "required": "Enter an email address in the required format, like name@example.com."
} }
self.fields["phone"].error_messages["required"] = "Enter your phone number." self.fields["phone"].error_messages["required"] = "Enter your phone number."
self.domainInfo = None self.domainInfo = None
@ -458,9 +461,12 @@ class DomainOrgNameAddressForm(forms.ModelForm):
validators=[ validators=[
RegexValidator( RegexValidator(
"^[0-9]{5}(?:-[0-9]{4})?$|^$", "^[0-9]{5}(?:-[0-9]{4})?$|^$",
message="Enter a zip code in the required format, like 12345 or 12345-6789.", message="Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.",
) )
], ],
error_messages={
"required": "Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.",
},
) )
class Meta: class Meta:
@ -529,17 +535,25 @@ class DomainOrgNameAddressForm(forms.ModelForm):
def save(self, commit=True): def save(self, commit=True):
"""Override the save() method of the BaseModelForm.""" """Override the save() method of the BaseModelForm."""
if self.has_changed(): if self.has_changed():
# This action should be blocked by the UI, as the text fields are readonly. # This action should be blocked by the UI, as the text fields are readonly.
# If they get past this point, we forbid it this way. # If they get past this point, we forbid it this way.
# This could be malicious, so lets reserve information for the backend only. # This could be malicious, so lets reserve information for the backend only.
if self.is_federal and not self._field_unchanged("federal_agency"):
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") raise ValueError("federal_agency cannot be modified when the generic_org_type is federal")
elif self.is_tribal and not self._field_unchanged("organization_name"): elif self.is_tribal and not self._field_unchanged("organization_name"):
raise ValueError("organization_name cannot be modified when the generic_org_type is tribal") raise ValueError("organization_name cannot be modified when the generic_org_type is tribal")
super().save() else: # If this error that means Non-Federal Agency is missing
non_federal_agency_instance = FederalAgency.get_non_federal_agency()
self.instance.federal_agency = non_federal_agency_instance
return super().save(commit=commit)
def _field_unchanged(self, field_name) -> bool: def _field_unchanged(self, field_name) -> bool:
""" """

View file

@ -21,6 +21,13 @@ from registrar.utility.constants import BranchChoices
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class RequestingEntityForm(RegistrarForm):
organization_name = forms.CharField(
label="Organization name",
error_messages={"required": "Enter the name of your organization."},
)
class OrganizationTypeForm(RegistrarForm): class OrganizationTypeForm(RegistrarForm):
generic_org_type = forms.ChoiceField( generic_org_type = forms.ChoiceField(
# use the long names in the domain request form # use the long names in the domain request form
@ -137,10 +144,10 @@ class OrganizationContactForm(RegistrarForm):
validators=[ validators=[
RegexValidator( RegexValidator(
"^[0-9]{5}(?:-[0-9]{4})?$|^$", "^[0-9]{5}(?:-[0-9]{4})?$|^$",
message="Enter a zip code in the form of 12345 or 12345-6789.", message="Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.",
) )
], ],
error_messages={"required": ("Enter a zip code in the form of 12345 or 12345-6789.")}, error_messages={"required": ("Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.")},
) )
urbanization = forms.CharField( urbanization = forms.CharField(
required=False, required=False,
@ -226,7 +233,10 @@ class SeniorOfficialForm(RegistrarForm):
email = forms.EmailField( email = forms.EmailField(
label="Email", label="Email",
max_length=None, max_length=None,
error_messages={"invalid": ("Enter an email address in the required format, like name@example.com.")}, error_messages={
"invalid": ("Enter an email address in the required format, like name@example.com."),
"required": ("Enter an email address in the required format, like name@example.com."),
},
validators=[ validators=[
MaxLengthValidator( MaxLengthValidator(
320, 320,
@ -603,7 +613,8 @@ class CisaRepresentativeForm(BaseDeletableRegistrarForm):
max_length=None, max_length=None,
required=False, required=False,
error_messages={ error_messages={
"invalid": ("Enter your representatives email address in the required format, like name@example.com."), "invalid": ("Enter an email address in the required format, like name@example.com."),
"required": ("Enter an email address in the required format, like name@example.com."),
}, },
validators=[ validators=[
MaxLengthValidator( MaxLengthValidator(

View file

@ -4,7 +4,14 @@ import logging
from django import forms from django import forms
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from ..models import DomainInformation, Portfolio, SeniorOfficial from registrar.models import (
PortfolioInvitation,
UserPortfolioPermission,
DomainInformation,
Portfolio,
SeniorOfficial,
)
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -17,9 +24,12 @@ class PortfolioOrgAddressForm(forms.ModelForm):
validators=[ validators=[
RegexValidator( RegexValidator(
"^[0-9]{5}(?:-[0-9]{4})?$|^$", "^[0-9]{5}(?:-[0-9]{4})?$|^$",
message="Enter a zip code in the required format, like 12345 or 12345-6789.", message="Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.",
) )
], ],
error_messages={
"required": "Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.",
},
) )
class Meta: class Meta:
@ -38,6 +48,7 @@ class PortfolioOrgAddressForm(forms.ModelForm):
"state_territory": { "state_territory": {
"required": "Select the state, territory, or military post where your organization is located." "required": "Select the state, territory, or military post where your organization is located."
}, },
"zipcode": {"required": "Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789."},
} }
widgets = { widgets = {
# We need to set the required attributed for State/territory # We need to set the required attributed for State/territory
@ -95,3 +106,57 @@ class PortfolioSeniorOfficialForm(forms.ModelForm):
cleaned_data = super().clean() cleaned_data = super().clean()
cleaned_data.pop("full_name", None) cleaned_data.pop("full_name", None)
return cleaned_data return cleaned_data
class PortfolioMemberForm(forms.ModelForm):
"""
Form for updating a portfolio member.
"""
roles = forms.MultipleChoiceField(
choices=UserPortfolioRoleChoices.choices,
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
required=False,
label="Roles",
)
additional_permissions = forms.MultipleChoiceField(
choices=UserPortfolioPermissionChoices.choices,
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
required=False,
label="Additional Permissions",
)
class Meta:
model = UserPortfolioPermission
fields = [
"roles",
"additional_permissions",
]
class PortfolioInvitedMemberForm(forms.ModelForm):
"""
Form for updating a portfolio invited member.
"""
roles = forms.MultipleChoiceField(
choices=UserPortfolioRoleChoices.choices,
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
required=False,
label="Roles",
)
additional_permissions = forms.MultipleChoiceField(
choices=UserPortfolioPermissionChoices.choices,
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
required=False,
label="Additional Permissions",
)
class Meta:
model = PortfolioInvitation
fields = [
"roles",
"additional_permissions",
]

View file

@ -58,7 +58,7 @@ class UserProfileForm(forms.ModelForm):
"required": "Enter your title or role in your organization (e.g., Chief Information Officer)" "required": "Enter your title or role in your organization (e.g., Chief Information Officer)"
} }
self.fields["email"].error_messages = { self.fields["email"].error_messages = {
"required": "Enter your email address in the required format, like name@example.com." "required": "Enter an email address in the required format, like name@example.com."
} }
self.fields["phone"].error_messages["required"] = "Enter your phone number." self.fields["phone"].error_messages["required"] = "Enter your phone number."

View file

@ -279,11 +279,11 @@ class BaseYesNoForm(RegistrarForm):
return initial_value return initial_value
def request_step_list(request_wizard): def request_step_list(request_wizard, step_enum):
"""Dynamically generated list of steps in the form wizard.""" """Dynamically generated list of steps in the form wizard."""
step_list = [] step_list = []
for step in request_wizard.StepEnum: for step in step_enum:
condition = request_wizard.WIZARD_CONDITIONS.get(step, True) condition = request_wizard.wizard_conditions.get(step, True)
if callable(condition): if callable(condition):
condition = condition(request_wizard) condition = condition(request_wizard)
if condition: if condition:

View file

@ -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,
),
),
]

View file

@ -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",
),
]

View file

@ -254,18 +254,18 @@ class DomainRequest(TimeStampedModel):
) )
class RejectionReasons(models.TextChoices): class RejectionReasons(models.TextChoices):
DOMAIN_PURPOSE = "purpose_not_met", "Purpose requirements not met" DOMAIN_PURPOSE = "domain_purpose", "Purpose requirements not met"
REQUESTOR = "requestor_not_eligible", "Requestor not eligible to make request" REQUESTOR_NOT_ELIGIBLE = "requestor_not_eligible", "Requestor not eligible to make request"
SECOND_DOMAIN_REASONING = ( ORG_HAS_DOMAIN = (
"org_has_domain", "org_has_domain",
"Org already has a .gov domain", "Org already has a .gov domain",
) )
CONTACTS_OR_ORGANIZATION_LEGITIMACY = ( CONTACTS_NOT_VERIFIED = (
"contacts_not_verified", "contacts_not_verified",
"Org contacts couldn't be verified", "Org contacts couldn't be verified",
) )
ORGANIZATION_ELIGIBILITY = "org_not_eligible", "Org not eligible for a .gov domain" ORG_NOT_ELIGIBLE = "org_not_eligible", "Org not eligible for a .gov domain"
NAMING_REQUIREMENTS = "naming_not_met", "Naming requirements not met" NAMING_REQUIREMENTS = "naming_requirements", "Naming requirements not met"
OTHER = "other", "Other/Unspecified" OTHER = "other", "Other/Unspecified"
@classmethod @classmethod
@ -300,6 +300,11 @@ class DomainRequest(TimeStampedModel):
blank=True, blank=True,
) )
rejection_reason_email = models.TextField(
null=True,
blank=True,
)
action_needed_reason = models.TextField( action_needed_reason = models.TextField(
choices=ActionNeededReasons.choices, choices=ActionNeededReasons.choices,
null=True, null=True,
@ -635,15 +640,16 @@ class DomainRequest(TimeStampedModel):
# Actually updates the organization_type field # Actually updates the organization_type field
org_type_helper.create_or_update_organization_type() org_type_helper.create_or_update_organization_type()
def _cache_status_and_action_needed_reason(self): def _cache_status_and_status_reasons(self):
"""Maintains a cache of properties so we can avoid a DB call""" """Maintains a cache of properties so we can avoid a DB call"""
self._cached_action_needed_reason = self.action_needed_reason self._cached_action_needed_reason = self.action_needed_reason
self._cached_rejection_reason = self.rejection_reason
self._cached_status = self.status self._cached_status = self.status
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Store original values for caching purposes. Used to compare them on save. # Store original values for caching purposes. Used to compare them on save.
self._cache_status_and_action_needed_reason() self._cache_status_and_status_reasons()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""Save override for custom properties""" """Save override for custom properties"""
@ -655,23 +661,63 @@ class DomainRequest(TimeStampedModel):
super().save(*args, **kwargs) super().save(*args, **kwargs)
# Handle the action needed email. # Handle custom status emails.
# An email is sent out when action_needed_reason is changed or added. # An email is sent out when a, for example, action_needed_reason is changed or added.
if self.action_needed_reason and self.status == self.DomainRequestStatus.ACTION_NEEDED: statuses_that_send_custom_emails = [self.DomainRequestStatus.ACTION_NEEDED, self.DomainRequestStatus.REJECTED]
self.sync_action_needed_reason() if self.status in statuses_that_send_custom_emails:
self.send_custom_status_update_email(self.status)
# Update the cached values after saving # Update the cached values after saving
self._cache_status_and_action_needed_reason() self._cache_status_and_status_reasons()
def sync_action_needed_reason(self): def send_custom_status_update_email(self, status):
"""Checks if we need to send another action needed email""" """Helper function to send out a second status email when the status remains the same,
was_already_action_needed = self._cached_status == self.DomainRequestStatus.ACTION_NEEDED but the reason has changed."""
reason_exists = self._cached_action_needed_reason is not None and self.action_needed_reason is not None
reason_changed = self._cached_action_needed_reason != self.action_needed_reason # Currently, we store all this information in three variables.
if was_already_action_needed and reason_exists and reason_changed: # When adding new reasons, this can be a lot to manage so we store it here
# We don't send emails out in state "other" # in a centralized location. However, this may need to change if this scales.
if self.action_needed_reason != self.ActionNeededReasons.OTHER: status_information = {
self._send_action_needed_reason_email(email_content=self.action_needed_reason_email) self.DomainRequestStatus.ACTION_NEEDED: {
"cached_reason": self._cached_action_needed_reason,
"reason": self.action_needed_reason,
"email": self.action_needed_reason_email,
"excluded_reasons": [DomainRequest.ActionNeededReasons.OTHER],
"wrap_email": True,
},
self.DomainRequestStatus.REJECTED: {
"cached_reason": self._cached_rejection_reason,
"reason": self.rejection_reason,
"email": self.rejection_reason_email,
"excluded_reasons": [],
# "excluded_reasons": [DomainRequest.RejectionReasons.OTHER],
"wrap_email": False,
},
}
status_info = status_information.get(status)
# Don't send an email if there is nothing to send.
if status_info.get("email") is None:
logger.warning("send_custom_status_update_email() => Tried sending an empty email.")
return
# We should never send an email if no reason was specified.
# Additionally, Don't send out emails for reasons that shouldn't send them.
if status_info.get("reason") is None or status_info.get("reason") in status_info.get("excluded_reasons"):
logger.warning("send_custom_status_update_email() => Tried sending a status email without a reason.")
return
# Only send out an email if the underlying reason itself changed or if no email was sent previously.
if status_info.get("cached_reason") != status_info.get("reason") or status_info.get("cached_reason") is None:
bcc_address = settings.DEFAULT_FROM_EMAIL if settings.IS_PRODUCTION else ""
self._send_status_update_email(
new_status=status,
email_template="emails/includes/custom_email.txt",
email_template_subject="emails/status_change_subject.txt",
bcc_address=bcc_address,
custom_email_content=status_info.get("email"),
wrap_email=status_information.get("wrap_email"),
)
def sync_yes_no_form_fields(self): def sync_yes_no_form_fields(self):
"""Some yes/no forms use a db field to track whether it was checked or not. """Some yes/no forms use a db field to track whether it was checked or not.
@ -899,7 +945,7 @@ class DomainRequest(TimeStampedModel):
target=DomainRequestStatus.ACTION_NEEDED, target=DomainRequestStatus.ACTION_NEEDED,
conditions=[domain_is_not_active, investigator_exists_and_is_staff], conditions=[domain_is_not_active, investigator_exists_and_is_staff],
) )
def action_needed(self, send_email=True): def action_needed(self):
"""Send back an domain request that is under investigation or rejected. """Send back an domain request that is under investigation or rejected.
This action is logged. This action is logged.
@ -907,43 +953,23 @@ class DomainRequest(TimeStampedModel):
This action cleans up the rejection status if moving away from rejected. This action cleans up the rejection status if moving away from rejected.
As side effects this will delete the domain and domain_information As side effects this will delete the domain and domain_information
(will cascade) when they exist.""" (will cascade) when they exist.
Afterwards, we send out an email for action_needed in def save().
See the function send_custom_status_update_email.
"""
if self.status == self.DomainRequestStatus.APPROVED: if self.status == self.DomainRequestStatus.APPROVED:
self.delete_and_clean_up_domain("reject_with_prejudice") self.delete_and_clean_up_domain("action_needed")
elif self.status == self.DomainRequestStatus.REJECTED: elif self.status == self.DomainRequestStatus.REJECTED:
self.rejection_reason = None self.rejection_reason = None
# Check if the tuple is setup correctly, then grab its value.
literal = DomainRequest.DomainRequestStatus.ACTION_NEEDED literal = DomainRequest.DomainRequestStatus.ACTION_NEEDED
# Check if the tuple is setup correctly, then grab its value
action_needed = literal if literal is not None else "Action Needed" action_needed = literal if literal is not None else "Action Needed"
logger.info(f"A status change occurred. {self} was changed to '{action_needed}'") logger.info(f"A status change occurred. {self} was changed to '{action_needed}'")
# Send out an email if an action needed reason exists
if self.action_needed_reason and self.action_needed_reason != self.ActionNeededReasons.OTHER:
email_content = self.action_needed_reason_email
self._send_action_needed_reason_email(send_email, email_content)
def _send_action_needed_reason_email(self, send_email=True, email_content=None):
"""Sends out an automatic email for each valid action needed reason provided"""
email_template_name = "custom_email.txt"
email_template_subject_name = f"{self.action_needed_reason}_subject.txt"
bcc_address = ""
if settings.IS_PRODUCTION:
bcc_address = settings.DEFAULT_FROM_EMAIL
self._send_status_update_email(
new_status="action needed",
email_template=f"emails/action_needed_reasons/{email_template_name}",
email_template_subject=f"emails/action_needed_reasons/{email_template_subject_name}",
send_email=send_email,
bcc_address=bcc_address,
custom_email_content=email_content,
wrap_email=True,
)
@transition( @transition(
field="status", field="status",
source=[ source=[
@ -1037,18 +1063,20 @@ class DomainRequest(TimeStampedModel):
def reject(self): def reject(self):
"""Reject an domain request that has been submitted. """Reject an domain request that has been submitted.
This action is logged.
This action cleans up the action needed status if moving away from action needed.
As side effects this will delete the domain and domain_information As side effects this will delete the domain and domain_information
(will cascade), and send an email notification.""" (will cascade) when they exist.
Afterwards, we send out an email for reject in def save().
See the function send_custom_status_update_email.
"""
if self.status == self.DomainRequestStatus.APPROVED: if self.status == self.DomainRequestStatus.APPROVED:
self.delete_and_clean_up_domain("reject") self.delete_and_clean_up_domain("reject")
self._send_status_update_email(
"action needed",
"emails/status_change_rejected.txt",
"emails/status_change_rejected_subject.txt",
)
@transition( @transition(
field="status", field="status",
source=[ source=[

View file

@ -4,6 +4,7 @@ import logging
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import models from django.db import models
from django_fsm import FSMField, transition from django_fsm import FSMField, transition
from registrar.models.domain_invitation import DomainInvitation
from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.user_portfolio_permission import UserPortfolioPermission
from .utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices # type: ignore from .utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices # type: ignore
from .utility.time_stamped_model import TimeStampedModel from .utility.time_stamped_model import TimeStampedModel
@ -38,7 +39,7 @@ class PortfolioInvitation(TimeStampedModel):
related_name="portfolios", related_name="portfolios",
) )
portfolio_roles = ArrayField( roles = ArrayField(
models.CharField( models.CharField(
max_length=50, max_length=50,
choices=UserPortfolioRoleChoices.choices, choices=UserPortfolioRoleChoices.choices,
@ -48,7 +49,7 @@ class PortfolioInvitation(TimeStampedModel):
help_text="Select one or more roles.", help_text="Select one or more roles.",
) )
portfolio_additional_permissions = ArrayField( additional_permissions = ArrayField(
models.CharField( models.CharField(
max_length=50, max_length=50,
choices=UserPortfolioPermissionChoices.choices, choices=UserPortfolioPermissionChoices.choices,
@ -67,6 +68,31 @@ class PortfolioInvitation(TimeStampedModel):
def __str__(self): def __str__(self):
return f"Invitation for {self.email} on {self.portfolio} is {self.status}" return f"Invitation for {self.email} on {self.portfolio} is {self.status}"
def get_managed_domains_count(self):
"""Return the count of domain invitations managed by the invited user for this portfolio."""
# Filter the UserDomainRole model to get domains where the user has a manager role
managed_domains = DomainInvitation.objects.filter(
email=self.email, domain__domain_info__portfolio=self.portfolio
).count()
return managed_domains
def get_portfolio_permissions(self):
"""
Retrieve the permissions for the user's portfolio roles from the invite.
This is similar logic to _get_portfolio_permissions in user_portfolio_permission
"""
# Use a set to avoid duplicate permissions
portfolio_permissions = set()
if self.roles:
for role in self.roles:
portfolio_permissions.update(UserPortfolioPermission.PORTFOLIO_ROLE_PERMISSIONS.get(role, []))
if self.additional_permissions:
portfolio_permissions.update(self.additional_permissions)
return list(portfolio_permissions)
@transition(field="status", source=PortfolioInvitationStatus.INVITED, target=PortfolioInvitationStatus.RETRIEVED) @transition(field="status", source=PortfolioInvitationStatus.INVITED, target=PortfolioInvitationStatus.RETRIEVED)
def retrieve(self): def retrieve(self):
"""When an invitation is retrieved, create the corresponding permission. """When an invitation is retrieved, create the corresponding permission.
@ -88,8 +114,8 @@ class PortfolioInvitation(TimeStampedModel):
user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
portfolio=self.portfolio, user=user portfolio=self.portfolio, user=user
) )
if self.portfolio_roles and len(self.portfolio_roles) > 0: if self.roles and len(self.roles) > 0:
user_portfolio_permission.roles = self.portfolio_roles user_portfolio_permission.roles = self.roles
if self.portfolio_additional_permissions and len(self.portfolio_additional_permissions) > 0: if self.additional_permissions and len(self.additional_permissions) > 0:
user_portfolio_permission.additional_permissions = self.portfolio_additional_permissions user_portfolio_permission.additional_permissions = self.additional_permissions
user_portfolio_permission.save() user_portfolio_permission.save()

View file

@ -229,6 +229,10 @@ class User(AbstractUser):
"""Determines if the current user can view all available domains in a given portfolio""" """Determines if the current user can view all available domains in a given portfolio"""
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS) return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)
def has_view_all_domain_requests_portfolio_permission(self, portfolio):
"""Determines if the current user can view all available domains in a given portfolio"""
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)
def has_any_requests_portfolio_permission(self, portfolio): def has_any_requests_portfolio_permission(self, portfolio):
# BEGIN # BEGIN
# Note code below is to add organization_request feature # Note code below is to add organization_request feature
@ -458,3 +462,12 @@ class User(AbstractUser):
return DomainInformation.objects.filter(portfolio=portfolio).values_list("domain_id", flat=True) return DomainInformation.objects.filter(portfolio=portfolio).values_list("domain_id", flat=True)
else: else:
return UserDomainRole.objects.filter(user=self).values_list("domain_id", flat=True) return UserDomainRole.objects.filter(user=self).values_list("domain_id", flat=True)
def get_user_domain_request_ids(self, request):
"""Returns either the domain request ids associated with this user on UserDomainRole or Portfolio"""
portfolio = request.session.get("portfolio")
if self.is_org_user(request) and self.has_view_all_domain_requests_portfolio_permission(portfolio):
return DomainRequest.objects.filter(portfolio=portfolio).values_list("id", flat=True)
else:
return UserDomainRole.objects.filter(user=self).values_list("id", flat=True)

View file

@ -1,5 +1,6 @@
from django.db import models from django.db import models
from django.forms import ValidationError from django.forms import ValidationError
from registrar.models.user_domain_role import UserDomainRole
from registrar.utility.waffle import flag_is_active_for_user from registrar.utility.waffle import flag_is_active_for_user
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from .utility.time_stamped_model import TimeStampedModel from .utility.time_stamped_model import TimeStampedModel
@ -79,6 +80,14 @@ class UserPortfolioPermission(TimeStampedModel):
) )
return readable_roles return readable_roles
def get_managed_domains_count(self):
"""Return the count of domains managed by the user for this portfolio."""
# Filter the UserDomainRole model to get domains where the user has a manager role
managed_domains = UserDomainRole.objects.filter(
user=self.user, role=UserDomainRole.Roles.MANAGER, domain__domain_info__portfolio=self.portfolio
).count()
return managed_domains
def _get_portfolio_permissions(self): def _get_portfolio_permissions(self):
""" """
Retrieve the permissions for the user's portfolio roles. Retrieve the permissions for the user's portfolio roles.

View file

@ -20,10 +20,11 @@
</li> </li>
{% if opts.model_name == 'domainrequest' %} {% if opts.model_name == 'domainrequest' %}
<li> <li>
<a id="id-copy-to-clipboard-summary" class="button--clipboard" type="button" href="#"> <a id="id-copy-to-clipboard-summary" class="usa-button--dja" type="button" href="#">
<svg class="usa-icon" > <svg class="usa-icon" >
<use xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use> <use xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
</svg> </svg>
<!-- the span is targeted in JS, do not remove -->
<span>{% translate "Copy request summary" %}</span> <span>{% translate "Copy request summary" %}</span>
</a> </a>
</li> </li>

View file

@ -8,7 +8,7 @@ Template for an input field with a clipboard
<div class="admin-icon-group"> <div class="admin-icon-group">
{{ field }} {{ field }}
<button <button
class="usa-button usa-button--unstyled padding-left-1 usa-button--icon button--clipboard copy-to-clipboard" class="usa-button--dja usa-button usa-button__small-text usa-button--unstyled padding-left-1 usa-button--icon copy-to-clipboard"
type="button" type="button"
> >
<div class="no-outline-on-click"> <div class="no-outline-on-click">
@ -17,15 +17,17 @@ Template for an input field with a clipboard
> >
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use> <use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
</svg> </svg>
Copy <!-- the span is targeted in JS, do not remove -->
<span>Copy</span>
</div> </div>
</button> </button>
</div> </div>
{% else %} {% else %}
<div class="admin-icon-group admin-icon-group__clipboard-link"> <div class="admin-icon-group">
<input aria-hidden="true" class="display-none" value="{{ field.email }}" /> <input aria-hidden="true" class="display-none" value="{{ field.email }}" />
{% if field.email is not None %}
<button <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" type="button"
> >
<svg <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> <use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
</svg> </svg>
Copy <!-- the span is targeted in JS, do not remove -->
<span>Copy</span>
</button> </button>
{% endif %}
</div> </div>
{% endif %} {% endif %}

View file

@ -10,6 +10,8 @@
<input id="has_audit_logs" class="display-none" value="{%if filtered_audit_log_entries %}true{% else %}false{% endif %}"/> <input id="has_audit_logs" class="display-none" value="{%if filtered_audit_log_entries %}true{% else %}false{% endif %}"/>
{% url 'get-action-needed-email-for-user-json' as url %} {% url 'get-action-needed-email-for-user-json' as url %}
<input id="get-action-needed-email-for-user-json" class="display-none" value="{{ url }}" /> <input id="get-action-needed-email-for-user-json" class="display-none" value="{{ url }}" />
{% url 'get-rejection-email-for-user-json' as url_2 %}
<input id="get-rejection-email-for-user-json" class="display-none" value="{{ url_2 }}" />
{% for fieldset in adminform %} {% for fieldset in adminform %}
{% comment %} {% comment %}
TODO: this will eventually need to be changed to something like this TODO: this will eventually need to be changed to something like this

View file

@ -26,7 +26,7 @@
{% if user.email %} {% if user.email %}
<span id="contact_info_email">{{ user.email }}</span> <span id="contact_info_email">{{ user.email }}</span>
{% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %} {% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %}
<br class="admin-icon-group__br"> <br>
{% else %} {% else %}
None<br> None<br>
{% endif %} {% endif %}

View file

@ -137,29 +137,28 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% block field_other %} {% block field_other %}
{% if field.field.name == "action_needed_reason_email" %} {% if field.field.name == "action_needed_reason_email" %}
{{ field.field }}
<div class="margin-top-05 text-faded field-action_needed_reason_email__placeholder"> <div class="margin-top-05 text-faded custom-email-placeholder">
&ndash; &ndash;
</div> </div>
{{ field.field }}
<button <button
aria-label="Edit email in textarea" aria-label="Edit email in textarea"
type="button" type="button"
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text margin-left-1 text-no-underline field-action_needed_reason_email__edit flex-align-self-start" class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text margin-left-1 text-no-underline flex-align-self-start edit-email-button"
><img src="/public/admin/img/icon-changelink.svg" alt="Change"> Edit email</button ><img src="/public/admin/img/icon-changelink.svg" alt="Change"> Edit email</button
> >
<a <a
href="#email-already-sent-modal" href="#action-needed-email-already-sent-modal"
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text text-no-underline margin-left-1 field-action_needed_reason_email__modal-trigger flex-align-self-start" class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text text-no-underline margin-left-1 edit-button-modal-trigger flex-align-self-start"
aria-controls="email-already-sent-modal" aria-controls="action-needed-email-already-sent-modal"
data-open-modal data-open-modal
><img src="/public/admin/img/icon-changelink.svg" alt="Change"> Edit email</a ><img src="/public/admin/img/icon-changelink.svg" alt="Change"> Edit email</a
> >
<div <div
class="usa-modal" class="usa-modal"
id="email-already-sent-modal" id="action-needed-email-already-sent-modal"
aria-labelledby="Are you sure you want to edit this email?" aria-labelledby="Are you sure you want to edit this email?"
aria-describedby="The creator of this request already received an email" aria-describedby="The creator of this request already received an email"
> >
@ -187,8 +186,8 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
<li class="usa-button-group__item"> <li class="usa-button-group__item">
<button <button
type="submit" type="submit"
id="action-needed-reason__confirm-edit-email"
class="usa-button" class="usa-button"
id="confirm-edit-email"
data-close-modal data-close-modal
> >
Yes, continue editing Yes, continue editing
@ -221,11 +220,99 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
</div> </div>
{% if original_object.action_needed_reason_email %} {% if original_object.action_needed_reason_email %}
<input id="last-sent-email-content" class="display-none" value="{{original_object.action_needed_reason_email}}"> <input id="last-sent-action-needed-email-content" class="display-none" value="{{original_object.action_needed_reason_email}}">
{% else %} {% else %}
<input id="last-sent-email-content" class="display-none" value="None"> <input id="last-sent-action-needed-email-content" class="display-none" value="None">
{% endif %} {% endif %}
{% elif field.field.name == "rejection_reason_email" %}
{{ field.field }}
<div class="margin-top-05 text-faded custom-email-placeholder">
&ndash;
</div>
<button
aria-label="Edit email in textarea"
type="button"
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text margin-left-1 text-no-underline flex-align-self-start edit-email-button"
><img src="/public/admin/img/icon-changelink.svg" alt="Change"> Edit email</button
>
<a
href="#rejection-reason-email-already-sent-modal"
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text text-no-underline margin-left-1 edit-button-modal-trigger flex-align-self-start"
aria-controls="rejection-reason-email-already-sent-modal"
data-open-modal
><img src="/public/admin/img/icon-changelink.svg" alt="Change"> Edit email</a
>
<div
class="usa-modal"
id="rejection-reason-email-already-sent-modal"
aria-labelledby="Are you sure you want to edit this email?"
aria-describedby="The creator of this request already received an email"
>
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading">
Are you sure you want to edit this email?
</h2>
<div class="usa-prose">
<p>
The creator of this request already received an email for this status/reason:
</p>
<ul>
<li class="font-body-sm">Status: <b>Rejected</b></li>
<li class="font-body-sm">Reason: <b>{{ original_object.get_rejection_reason_display }}</b></li>
</ul>
<p>
If you edit this email's text, <b>the system will send another email</b> to
the creator after you “save” your changes. If you do not want to send another email, click “cancel” below.
</p>
</div>
<div class="usa-modal__footer">
<ul class="usa-button-group">
<li class="usa-button-group__item">
<button
type="submit"
id="rejection-reason__confirm-edit-email"
class="usa-button"
data-close-modal
>
Yes, continue editing
</button>
</li>
<li class="usa-button-group__item">
<button
type="button"
class="usa-button usa-button--unstyled padding-105 text-center"
name="_cancel_edit_email"
data-close-modal
>
Cancel
</button>
</li>
</ul>
</div>
</div>
<button
type="button"
class="usa-button usa-modal__close"
aria-label="Close this window"
data-close-modal
>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg>
</button>
</div>
</div>
{% if original_object.rejection_reason_email %}
<input id="last-sent-rejection-email-content" class="display-none" value="{{original_object.rejection_reason_email}}">
{% else %}
<input id="last-sent-rejection-email-content" class="display-none" value="None">
{% endif %}
{% else %} {% else %}
{{ field.field }} {{ field.field }}
{% endif %} {% endif %}
@ -254,7 +341,7 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
<table> <table>
<thead> <thead>
<tr> <tr>
<th colspan="4">Other contact information</th> <th colspan="5">Other contact information</th>
<tr> <tr>
</thead> </thead>
<tbody> <tbody>
@ -267,9 +354,20 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
</td> </td>
<td class="padding-left-1">{{ contact.phone }}</td> <td class="padding-left-1">{{ contact.phone }}</td>
<td class="padding-left-1 text-size-small"> <td class="padding-left-1 text-size-small">
{% if contact.email %}
<input aria-hidden="true" class="display-none" value="{{ contact.email }}" /> <input aria-hidden="true" class="display-none" value="{{ contact.email }}" />
<button <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" type="button"
> >
<svg <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> <use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
</svg> </svg>
<!-- the span is targeted in JS, do not remove -->
<span>Copy email</span> <span>Copy email</span>
</button> </button>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

View file

@ -45,7 +45,7 @@
<div class="usa-alert usa-alert--info usa-alert--slim"> <div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body"> <div class="usa-alert__body">
<p class="usa-alert__text "> <p class="usa-alert__text ">
To manage information for this domain, you must add yourself as a domain manager. You don't have access to manage {{domain.name}}. If you need to make updates, contact one of the listed domain managers.
</p> </p>
</div> </div>
</div> </div>

View file

@ -42,7 +42,7 @@
{% input_with_errors form.state_territory %} {% input_with_errors form.state_territory %}
{% with add_class="usa-input--small" sublabel_text="Enter a zip code in the required format, like 12345 or 12345-6789." %} {% with add_class="usa-input--small" sublabel_text="Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789." %}
{% input_with_errors form.zipcode %} {% input_with_errors form.zipcode %}
{% endwith %} {% endwith %}

View file

@ -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. <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 states two-letter abbreviation.{% endif %}</p> {% if not is_federal %}In most instances, this requires including your states two-letter abbreviation.{% endif %}</p>
{% if not portfolio %}
<p>Requests for your organizations initials or an abbreviated name might not be approved, but we encourage you to request the name you want.</p> <p>Requests for your organizations initials or an abbreviated name might not be approved, but we encourage you to request the name you want.</p>
{% endif %}
<p>Note that <strong>only federal agencies can request generic terms</strong> like <p>Note that <strong>only federal agencies can request generic terms</strong> like
vote.gov.</p> vote.gov.</p>

View file

@ -12,7 +12,11 @@
<h1>Youre about to start your .gov domain request.</h1> <h1>Youre about to start your .gov domain request.</h1>
<p>You dont have to complete the process in one session. You can save what you enter and come back to it when youre ready.</p> <p>You dont have to complete the process in one session. You can save what you enter and come back to it when youre ready.</p>
{% if portfolio %}
<p>Well use the information you provide to verify your domain request meets our guidelines.</p>
{% else %}
<p>Well use the information you provide to verify your organizations eligibility for a .gov domain. Well also verify that the domain you request meets our guidelines.</p> <p>Well use the information you provide to verify your organizations eligibility for a .gov domain. Well also verify that the domain you request meets our guidelines.</p>
{% endif %}
<h2>Time to complete the form</h2> <h2>Time to complete the form</h2>
<p>If you have <a href="{% public_site_url 'domains/before/#information-you%E2%80%99ll-need-to-complete-the-domain-request-form' %}" target="_blank" class="usa-link">all the information you need</a>, <p>If you have <a href="{% public_site_url 'domains/before/#information-you%E2%80%99ll-need-to-complete-the-domain-request-form' %}" target="_blank" class="usa-link">all the information you need</a>,
completing your domain request might take around 15 minutes.</p> completing your domain request might take around 15 minutes.</p>

View file

@ -33,7 +33,7 @@
{% input_with_errors forms.0.state_territory %} {% input_with_errors forms.0.state_territory %}
{% with add_class="usa-input--small" sublabel_text="Enter a zip code in the required format, like 12345 or 12345-6789." %} {% with add_class="usa-input--small" sublabel_text="Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789." %}
{% input_with_errors forms.0.zipcode %} {% input_with_errors forms.0.zipcode %}
{% endwith %} {% endwith %}

View file

@ -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 %}

View file

@ -19,5 +19,9 @@
{% endblock %} {% endblock %}
{% block form_fields %} {% 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 %} {% include "includes/request_review_steps.html" with is_editable=True %}
{% endif %}
{% endblock %} {% endblock %}

View file

@ -11,8 +11,13 @@
<p> <p>
The name of your suborganization will be publicly listed as the domain registrant. The name of your suborganization will be publicly listed as the domain registrant.
This list of suborganizations has been populated the .gov program. </p>
If you believe there is an error please contact <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>. <p>
When this field is blank, the domain registrant will be listed as the overarching organization: {{ portfolio }}.
</p>
<p>
If you dont see your suborganization in the menu or need to edit one of the options,
please contact <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
</p> </p>
{% if has_any_domains_portfolio_permission and has_edit_suborganization_portfolio_permission %} {% if has_any_domains_portfolio_permission and has_edit_suborganization_portfolio_permission %}

View file

@ -8,8 +8,8 @@ REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
STATUS: Rejected STATUS: Rejected
---------------------------------------------------------------- ----------------------------------------------------------------
{% if domain_request.rejection_reason != 'other' %} {% if reason != domain_request.RejectionReasons.DOMAIN_PURPOSE.OTHER %}
REJECTION REASON{% endif %}{% if domain_request.rejection_reason == 'purpose_not_met' %} REJECTION REASON{% endif %}{% if reason == domain_request.RejectionReasons.DOMAIN_PURPOSE %}
Your domain request was rejected because the purpose you provided did not meet our Your domain request was rejected because the purpose you provided did not meet our
requirements. You didnt provide enough information about how you intend to use the requirements. You didnt provide enough information about how you intend to use the
domain. domain.
@ -18,7 +18,7 @@ Learn more about:
- Eligibility for a .gov domain <https://get.gov/domains/eligibility> - Eligibility for a .gov domain <https://get.gov/domains/eligibility>
- What you can and cant do with .gov domains <https://get.gov/domains/requirements/> - What you can and cant 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 dont believe youre eligible to request a Your domain request was rejected because we dont believe youre eligible to request a
.gov domain on behalf of {{ domain_request.organization_name }}. You must be a government employee, or be .gov domain on behalf of {{ domain_request.organization_name }}. You must be a government employee, or be
working on behalf of a government organization, to request a .gov domain. working on behalf of a government organization, to request a .gov domain.
@ -26,7 +26,7 @@ working on behalf of a government organization, to request a .gov domain.
DEMONSTRATE ELIGIBILITY DEMONSTRATE ELIGIBILITY
If you can provide more information that demonstrates your eligibility, or you want to If you can provide more information that demonstrates your eligibility, or you want to
discuss further, reply to this email.{% elif domain_request.rejection_reason == 'org_has_domain' %} discuss further, reply to this email.{% elif reason == domain_request.RejectionReasons.DOMAIN_PURPOSE.ORG_HAS_DOMAIN %}
Your domain request was rejected because {{ domain_request.organization_name }} has a .gov domain. Our Your domain request was rejected because {{ domain_request.organization_name }} has a .gov domain. Our
practice is to approve one domain per online service per government organization. We practice is to approve one domain per online service per government organization. We
evaluate additional requests on a case-by-case basis. You did not provide sufficient evaluate additional requests on a case-by-case basis. You did not provide sufficient
@ -35,9 +35,9 @@ justification for an additional domain.
Read more about our practice of approving one domain per online service Read more about our practice of approving one domain per online service
<https://get.gov/domains/before/#one-domain-per-service>. <https://get.gov/domains/before/#one-domain-per-service>.
If you have questions or comments, reply to this email.{% elif domain_request.rejection_reason == 'contacts_not_verified' %} If you have questions or comments, reply to this email.{% elif reason == 'contacts_not_verified' %}
Your domain request was rejected because we could not verify the organizational Your domain request was rejected because we could not verify the organizational
contacts you provided. If you have questions or comments, reply to this email.{% elif domain_request.rejection_reason == 'org_not_eligible' %} contacts you provided. If you have questions or comments, reply to this email.{% elif reason == domain_request.RejectionReasons.DOMAIN_PURPOSE.ORG_NOT_ELIGIBLE %}
Your domain request was rejected because we determined that {{ domain_request.organization_name }} is not Your domain request was rejected because we determined that {{ domain_request.organization_name }} is not
eligible for a .gov domain. .Gov domains are only available to official U.S.-based eligible for a .gov domain. .Gov domains are only available to official U.S.-based
government organizations. government organizations.
@ -46,7 +46,7 @@ Learn more about eligibility for .gov domains
<https://get.gov/domains/eligibility/>. <https://get.gov/domains/eligibility/>.
If you have questions or comments, reply to this email. If you have questions or comments, reply to this email.
{% elif domain_request.rejection_reason == 'naming_not_met' %} {% elif reason == domain_request.RejectionReasons.DOMAIN_PURPOSE.NAMING_REQUIREMENTS %}
Your domain request was rejected because it does not meet our naming requirements. Your domain request was rejected because it does not meet our naming requirements.
Domains should uniquely identify a government organization and be clear to the Domains should uniquely identify a government organization and be clear to the
general public. Learn more about naming requirements for your type of organization general public. Learn more about naming requirements for your type of organization
@ -55,7 +55,7 @@ general public. Learn more about naming requirements for your type of organizati
YOU CAN SUBMIT A NEW REQUEST YOU CAN SUBMIT A NEW REQUEST
We encourage you to request a domain that meets our requirements. If you have We encourage you to request a domain that meets our requirements. If you have
questions or want to discuss potential domain names, reply to this email.{% elif domain_request.rejection_reason == 'other' %} questions or want to discuss potential domain names, reply to this email.{% elif reason == domain_request.RejectionReasons.DOMAIN_PURPOSE.OTHER %}
YOU CAN SUBMIT A NEW REQUEST YOU CAN SUBMIT A NEW REQUEST
If your organization is eligible for a .gov domain and you meet our other requirements, you can submit a new request. If your organization is eligible for a .gov domain and you meet our other requirements, you can submit a new request.

View file

@ -3,21 +3,21 @@
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %} {% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
{% url 'get_domain_requests_json' as url %} {% url 'get_domain_requests_json' as url %}
<span id="get_domain_requests_json_url" class="display-none">{{url}}</span> <span id="get_domain_requests_json_url" class="display-none">{{url}}</span>
<section class="section-outlined domain-requests{% if portfolio %} section-outlined--border-base-light{% endif %}" id="domain-requests"> <section class="section-outlined domain-requests{% if portfolio %} section-outlined--border-base-light{% endif %}" id="domain-requests">
<div class="grid-row"> <div class="section-outlined__header margin-bottom-3 {% if not portfolio %} section-outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}">
{% if not portfolio %} {% if not portfolio %}
<div class="mobile:grid-col-12 desktop:grid-col-6"> <h2 id="domain-requests-header" class="display-inline-block">Domain requests</h2>
<h2 id="domain-requests-header" class="flex-6">Domain requests</h2>
</div>
{% else %} {% else %}
<!-- Embedding the portfolio value in a data attribute --> <!-- Embedding the portfolio value in a data attribute -->
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span> <span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>
{% endif %} {% endif %}
<div class="mobile:grid-col-12 desktop:grid-col-6">
<section aria-label="Domain requests search component" class="flex-6 margin-y-2"> <div class="section-outlined__search {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6{% endif %} {% if is_widescreen_mode %} section-outlined__search--widescreen {% endif %}">
<section aria-label="Domain requests search component" class="margin-top-2">
<form class="usa-search usa-search--small" method="POST" role="search"> <form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %} {% 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"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use> <use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg> </svg>
@ -49,7 +49,19 @@
</form> </form>
</section> </section>
</div> </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> </div>
{% endif %}
</div>
{% if portfolio %} {% if portfolio %}
<div class="display-flex flex-align-center"> <div class="display-flex flex-align-center">
<span class="margin-right-2 margin-top-neg-1 usa-prose text-base-darker">Filter by</span> <span class="margin-right-2 margin-top-neg-1 usa-prose text-base-darker">Filter by</span>
@ -79,9 +91,7 @@
name="filter-status" name="filter-status"
value="started" value="started"
/> />
<label class="usa-checkbox__label" for="filter-status-started" <label class="usa-checkbox__label" for="filter-status-started">Started</label>
>Started</label
>
</div> </div>
<div class="usa-checkbox"> <div class="usa-checkbox">
<input <input
@ -91,9 +101,7 @@
name="filter-status" name="filter-status"
value="submitted" value="submitted"
/> />
<label class="usa-checkbox__label" for="filter-status-submitted" <label class="usa-checkbox__label" for="filter-status-submitted">Submitted</label>
>Submitted</label
>
</div> </div>
<div class="usa-checkbox"> <div class="usa-checkbox">
<input <input
@ -103,9 +111,7 @@
name="filter-status" name="filter-status"
value="in review" value="in review"
/> />
<label class="usa-checkbox__label" for="filter-status-in-review" <label class="usa-checkbox__label" for="filter-status-in-review">In review</label>
>In review</label
>
</div> </div>
<div class="usa-checkbox"> <div class="usa-checkbox">
<input <input
@ -115,9 +121,7 @@
name="filter-status" name="filter-status"
value="action needed" value="action needed"
/> />
<label class="usa-checkbox__label" for="filter-status-action-needed" <label class="usa-checkbox__label" for="filter-status-action-needed">Action needed</label>
>Action needed</label
>
</div> </div>
<div class="usa-checkbox"> <div class="usa-checkbox">
<input <input
@ -127,9 +131,7 @@
name="filter-status" name="filter-status"
value="rejected" value="rejected"
/> />
<label class="usa-checkbox__label" for="filter-status-rejected" <label class="usa-checkbox__label" for="filter-status-rejected">Rejected</label>
>Rejected</label
>
</div> </div>
<div class="usa-checkbox"> <div class="usa-checkbox">
<input <input
@ -139,9 +141,7 @@
name="filter-status" name="filter-status"
value="withdrawn" value="withdrawn"
/> />
<label class="usa-checkbox__label" for="filter-status-withdrawn" <label class="usa-checkbox__label" for="filter-status-withdrawn">Withdrawn</label>
>Withdrawn</label
>
</div> </div>
<div class="usa-checkbox"> <div class="usa-checkbox">
<input <input
@ -151,9 +151,7 @@
name="filter-status" name="filter-status"
value="ineligible" value="ineligible"
/> />
<label class="usa-checkbox__label" for="filter-status-ineligible" <label class="usa-checkbox__label" for="filter-status-ineligible">Ineligible</label>
>Ineligible</label
>
</div> </div>
</fieldset> </fieldset>
</div> </div>
@ -169,6 +167,7 @@
</button> </button>
</div> </div>
{% endif %} {% endif %}
<div class="domain-requests__table-wrapper display-none usa-table-container--scrollable margin-top-0" tabindex="0"> <div class="domain-requests__table-wrapper display-none usa-table-container--scrollable margin-top-0" tabindex="0">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked domain-requests__table"> <table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked domain-requests__table">
<caption class="sr-only">Your domain requests</caption> <caption class="sr-only">Your domain requests</caption>
@ -188,18 +187,18 @@
<!-- AJAX will populate this tbody --> <!-- AJAX will populate this tbody -->
</tbody> </tbody>
</table> </table>
<div <div class="usa-sr-only usa-table__announcement-region" aria-live="polite"></div>
class="usa-sr-only usa-table__announcement-region"
aria-live="polite"
></div>
</div> </div>
<div class="domain-requests__no-data display-none"> <div class="domain-requests__no-data display-none">
<p>You haven't requested any domains.</p> <p>You haven't requested any domains.</p>
</div> </div>
<div class="domain-requests__no-search-results display-none"> <div class="domain-requests__no-search-results display-none">
<p>No results found</p> <p>No results found</p>
</div> </div>
</section> </section>
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domain-requests-pagination"> <nav aria-label="Pagination" class="usa-pagination flex-justify" id="domain-requests-pagination">
<span class="usa-pagination__counter text-base-dark padding-left-2 margin-bottom-1"> <span class="usa-pagination__counter text-base-dark padding-left-2 margin-bottom-1">
<!-- Count will be dynamically populated by JS --> <!-- Count will be dynamically populated by JS -->

View file

@ -34,6 +34,7 @@
</ul> </ul>
</div> </div>
<ul class="usa-nav__primary usa-accordion"> <ul class="usa-nav__primary usa-accordion">
{% if not hide_domains %}
<li class="usa-nav__primary-item"> <li class="usa-nav__primary-item">
{% if has_any_domains_portfolio_permission %} {% if has_any_domains_portfolio_permission %}
{% url 'domains' as url %} {% url 'domains' as url %}
@ -44,13 +45,14 @@
Domains Domains
</a> </a>
</li> </li>
{% endif %}
<!-- <li class="usa-nav__primary-item"> <!-- <li class="usa-nav__primary-item">
<a href="#" class="usa-nav-link"> <a href="#" class="usa-nav-link">
Domain groups Domain groups
</a> </a>
</li> --> </li> -->
{% if has_organization_requests_flag %} {% if has_organization_requests_flag and not hide_requests %}
<li class="usa-nav__primary-item"> <li class="usa-nav__primary-item">
<!-- user has one of the view permissions plus the edit permission, show the dropdown --> <!-- user has one of the view permissions plus the edit permission, show the dropdown -->
{% if has_edit_request_portfolio_permission %} {% if has_edit_request_portfolio_permission %}
@ -91,7 +93,7 @@
</li> </li>
{% endif %} {% endif %}
{% if has_organization_members_flag and has_view_members_portfolio_permission %} {% if has_organization_members_flag %}
<li class="usa-nav__primary-item"> <li class="usa-nav__primary-item">
<a href="/members/" class="usa-nav-link {% if path|is_members_subpage %} usa-current{% endif %}"> <a href="/members/" class="usa-nav-link {% if path|is_members_subpage %} usa-current{% endif %}">
Members Members

View file

@ -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 %}

View 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 %}

View file

@ -4,6 +4,11 @@
<div class="usa-modal__main"> <div class="usa-modal__main">
<h2 class="usa-modal__heading" id="modal-1-heading"> <h2 class="usa-modal__heading" id="modal-1-heading">
{{ modal_heading }} {{ modal_heading }}
{%if domain_name_modal is not None %}
<span class="domain-name-wrap">
{{ domain_name_modal }}
</span>
{%endif%}
{% if heading_value is not None %} {% if heading_value is not None %}
{# Add a breakpoint #} {# Add a breakpoint #}
<div aria-hidden="true"></div> <div aria-hidden="true"></div>

View file

@ -8,7 +8,6 @@
{% endif %} {% endif %}
{% if step == Step.REQUESTING_ENTITY %} {% if step == Step.REQUESTING_ENTITY %}
{% if domain_request.organization_name %} {% if domain_request.organization_name %}
{% with title=form_titles|get_item:step value=domain_request %} {% with title=form_titles|get_item:step value=domain_request %}
{% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=is_editable edit_link=domain_request_url address='true' %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=is_editable edit_link=domain_request_url address='true' %}
@ -54,32 +53,8 @@
{% endif %} {% endif %}
{% if step == Step.ADDITIONAL_DETAILS %} {% if step == Step.ADDITIONAL_DETAILS %}
{% with title=form_titles|get_item:step %} {% with title=form_titles|get_item:step value=domain_request.anything_else|default:"<span class='text-bold text-secondary-dark'>Incomplete</span>"|safe %}
{% if domain_request.has_additional_details %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=is_editable edit_link=domain_request_url %}
{% include "includes/summary_item.html" with title="Additional Details" value=" " heading_level=heading_level editable=is_editable edit_link=domain_request_url %}
<h3 class="register-form-review-header">CISA Regional Representative</h3>
<ul class="usa-list usa-list--unstyled margin-top-0">
{% if domain_request.cisa_representative_first_name %}
<li>{{domain_request.cisa_representative_first_name}} {{domain_request.cisa_representative_last_name}}</li>
{% if domain_request.cisa_representative_email %}
<li>{{domain_request.cisa_representative_email}}</li>
{% endif %}
{% else %}
No
{% endif %}
</ul>
<h3 class="register-form-review-header">Anything else</h3>
<ul class="usa-list usa-list--unstyled margin-top-0">
{% if domain_request.anything_else %}
{{domain_request.anything_else}}
{% else %}
No
{% endif %}
</ul>
{% else %}
{% include "includes/summary_item.html" with title="Additional Details" value="<span class='text-bold text-secondary-dark'>Incomplete</span>"|safe heading_level=heading_level editable=is_editable edit_link=domain_request_url %}
{% endif %}
{% endwith %} {% endwith %}
{% endif %} {% endif %}

View file

@ -24,7 +24,11 @@
{% if sub_header_text %} {% if sub_header_text %}
<h4 class="register-form-review-header">{{ sub_header_text }}</h4> <h4 class="register-form-review-header">{{ sub_header_text }}</h4>
{% endif %} {% endif %}
{% if address %} {% if permissions %}
{% include "includes/member_permissions.html" with permissions=value %}
{% elif domain_mgmt %}
{% include "includes/member_domain_management.html" with domain_count=value %}
{% elif address %}
{% include "includes/organization_address.html" with organization=value %} {% include "includes/organization_address.html" with organization=value %}
{% elif contact %} {% elif contact %}
{% if list %} {% if list %}
@ -122,9 +126,9 @@
class="usa-link usa-link--icon font-sans-sm line-height-sans-5" class="usa-link usa-link--icon font-sans-sm line-height-sans-5"
> >
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{% static 'img/sprite.svg' %}#edit"></use> <use xlink:href="{% static 'img/sprite.svg' %}#{% if manage_button %}settings{% elif view_button %}visibility{% else %}edit{% endif %}"></use>
</svg> </svg>
Edit<span class="sr-only"> {{ title }}</span> {% if manage_button %}Manage{% elif view_button %}View{% else %}Edit{% endif %}<span class="sr-only"> {{ title }}</span>
</a> </a>
</div> </div>
{% endif %} {% endif %}

View file

@ -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 youd 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 %}

View 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 %}

View 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 %}

View file

@ -18,6 +18,7 @@
<div class="mobile:grid-col-12 tablet:grid-col-6"> <div class="mobile:grid-col-12 tablet:grid-col-6">
<h1 id="members-header">Members</h1> <h1 id="members-header">Members</h1>
</div> </div>
{% if has_edit_members_portfolio_permission %}
<div class="mobile:grid-col-12 tablet:grid-col-6"> <div class="mobile:grid-col-12 tablet:grid-col-6">
<p class="float-right-tablet tablet:margin-y-0"> <p class="float-right-tablet tablet:margin-y-0">
<a href="#" class="usa-button" <a href="#" class="usa-button"
@ -26,6 +27,7 @@
</a> </a>
</p> </p>
</div> </div>
{% endif %}
</div> </div>
{% include "includes/members_table.html" with portfolio=portfolio %} {% include "includes/members_table.html" with portfolio=portfolio %}

View file

@ -45,7 +45,7 @@
{% input_with_errors form.address_line2 %} {% input_with_errors form.address_line2 %}
{% input_with_errors form.city %} {% input_with_errors form.city %}
{% input_with_errors form.state_territory %} {% input_with_errors form.state_territory %}
{% with add_class="usa-input--small" sublabel_text="Enter a zip code in the required format, like 12345 or 12345-6789." %} {% with add_class="usa-input--small" sublabel_text="Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789." %}
{% input_with_errors form.zipcode %} {% input_with_errors form.zipcode %}
{% endwith %} {% endwith %}
<button type="submit" class="usa-button"> <button type="submit" class="usa-button">

View file

@ -246,9 +246,7 @@ def is_members_subpage(path):
"""Checks if the given page is a subpage of members. """Checks if the given page is a subpage of members.
Takes a path name, like '/organization/'.""" Takes a path name, like '/organization/'."""
# Since our pages aren't unified under a common path, we need this approach for now. # Since our pages aren't unified under a common path, we need this approach for now.
url_names = [ url_names = ["members", "member", "member-permissions", "invitedmember", "invitedmember-permissions"]
"members",
]
return get_url_name(path) in url_names return get_url_name(path) in url_names

View file

@ -654,7 +654,7 @@ class TestDomainInformationAdmin(TestCase):
self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields) self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields)
# Test for the copy link # Test for the copy link
self.assertContains(response, "button--clipboard", count=3) self.assertContains(response, "copy-to-clipboard", count=3)
# cleanup this test # cleanup this test
domain_info.delete() domain_info.delete()

View file

@ -535,7 +535,7 @@ class TestDomainAdminWithClient(TestCase):
self.assertContains(response, "Testy Tester") self.assertContains(response, "Testy Tester")
# Test for the copy link # Test for the copy link
self.assertContains(response, "button--clipboard") self.assertContains(response, "copy-to-clipboard")
# cleanup from this test # cleanup from this test
domain.delete() domain.delete()

View file

@ -595,7 +595,12 @@ class TestDomainRequestAdmin(MockEppLib):
@less_console_noise_decorator @less_console_noise_decorator
def transition_state_and_send_email( def transition_state_and_send_email(
self, domain_request, status, rejection_reason=None, action_needed_reason=None, action_needed_reason_email=None self,
domain_request,
status,
rejection_reason=None,
action_needed_reason=None,
action_needed_reason_email=None,
): ):
"""Helper method for the email test cases.""" """Helper method for the email test cases."""
@ -687,6 +692,10 @@ class TestDomainRequestAdmin(MockEppLib):
self.assert_email_is_accurate("ORGANIZATION ALREADY HAS A .GOV DOMAIN", 0, EMAIL, bcc_email_address=BCC_EMAIL) self.assert_email_is_accurate("ORGANIZATION ALREADY HAS A .GOV DOMAIN", 0, EMAIL, bcc_email_address=BCC_EMAIL)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
# We use javascript to reset the content of this. It is only automatically set
# if the email itself is somehow None.
self._reset_action_needed_email(domain_request)
# Test the email sent out for bad_name # Test the email sent out for bad_name
bad_name = DomainRequest.ActionNeededReasons.BAD_NAME bad_name = DomainRequest.ActionNeededReasons.BAD_NAME
self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=bad_name) self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=bad_name)
@ -694,6 +703,7 @@ class TestDomainRequestAdmin(MockEppLib):
"DOMAIN NAME DOES NOT MEET .GOV REQUIREMENTS", 1, EMAIL, bcc_email_address=BCC_EMAIL "DOMAIN NAME DOES NOT MEET .GOV REQUIREMENTS", 1, EMAIL, bcc_email_address=BCC_EMAIL
) )
self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
self._reset_action_needed_email(domain_request)
# Test the email sent out for eligibility_unclear # Test the email sent out for eligibility_unclear
eligibility_unclear = DomainRequest.ActionNeededReasons.ELIGIBILITY_UNCLEAR eligibility_unclear = DomainRequest.ActionNeededReasons.ELIGIBILITY_UNCLEAR
@ -702,6 +712,7 @@ class TestDomainRequestAdmin(MockEppLib):
"ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS", 2, EMAIL, bcc_email_address=BCC_EMAIL "ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS", 2, EMAIL, bcc_email_address=BCC_EMAIL
) )
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
self._reset_action_needed_email(domain_request)
# Test that a custom email is sent out for questionable_so # Test that a custom email is sent out for questionable_so
questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL
@ -710,6 +721,7 @@ class TestDomainRequestAdmin(MockEppLib):
"SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", 3, _creator.email, bcc_email_address=BCC_EMAIL "SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", 3, _creator.email, bcc_email_address=BCC_EMAIL
) )
self.assertEqual(len(self.mock_client.EMAILS_SENT), 4) self.assertEqual(len(self.mock_client.EMAILS_SENT), 4)
self._reset_action_needed_email(domain_request)
# Assert that no other emails are sent on OTHER # Assert that no other emails are sent on OTHER
other = DomainRequest.ActionNeededReasons.OTHER other = DomainRequest.ActionNeededReasons.OTHER
@ -717,6 +729,7 @@ class TestDomainRequestAdmin(MockEppLib):
# Should be unchanged from before # Should be unchanged from before
self.assertEqual(len(self.mock_client.EMAILS_SENT), 4) self.assertEqual(len(self.mock_client.EMAILS_SENT), 4)
self._reset_action_needed_email(domain_request)
# Tests if an analyst can override existing email content # Tests if an analyst can override existing email content
questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL
@ -730,6 +743,7 @@ class TestDomainRequestAdmin(MockEppLib):
domain_request.refresh_from_db() domain_request.refresh_from_db()
self.assert_email_is_accurate("custom email content", 4, _creator.email, bcc_email_address=BCC_EMAIL) self.assert_email_is_accurate("custom email content", 4, _creator.email, bcc_email_address=BCC_EMAIL)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 5) self.assertEqual(len(self.mock_client.EMAILS_SENT), 5)
self._reset_action_needed_email(domain_request)
# Tests if a new email gets sent when just the email is changed. # Tests if a new email gets sent when just the email is changed.
# An email should NOT be sent out if we just modify the email content. # An email should NOT be sent out if we just modify the email content.
@ -741,6 +755,7 @@ class TestDomainRequestAdmin(MockEppLib):
) )
self.assertEqual(len(self.mock_client.EMAILS_SENT), 5) self.assertEqual(len(self.mock_client.EMAILS_SENT), 5)
self._reset_action_needed_email(domain_request)
# Set the request back to in review # Set the request back to in review
domain_request.in_review() domain_request.in_review()
@ -757,55 +772,53 @@ class TestDomainRequestAdmin(MockEppLib):
) )
self.assertEqual(len(self.mock_client.EMAILS_SENT), 6) self.assertEqual(len(self.mock_client.EMAILS_SENT), 6)
# def test_action_needed_sends_reason_email_prod_bcc(self): def _reset_action_needed_email(self, domain_request):
# """When an action needed reason is set, an email is sent out and help@get.gov """Sets the given action needed email back to none"""
# is BCC'd in production""" domain_request.action_needed_reason_email = None
# # Ensure there is no user with this email domain_request.save()
# EMAIL = "mayor@igorville.gov" domain_request.refresh_from_db()
# BCC_EMAIL = settings.DEFAULT_FROM_EMAIL
# User.objects.filter(email=EMAIL).delete()
# in_review = DomainRequest.DomainRequestStatus.IN_REVIEW
# action_needed = DomainRequest.DomainRequestStatus.ACTION_NEEDED
# # Create a sample domain request @override_settings(IS_PRODUCTION=True)
# domain_request = completed_domain_request(status=in_review) @less_console_noise_decorator
def test_rejected_sends_reason_email_prod_bcc(self):
"""When a rejection reason is set, an email is sent out and help@get.gov
is BCC'd in production"""
# Create fake creator
EMAIL = "meoward.jones@igorville.gov"
# # Test the email sent out for already_has_domains _creator = User.objects.create(
# already_has_domains = DomainRequest.ActionNeededReasons.ALREADY_HAS_DOMAINS username="MrMeoward",
# self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=already_has_domains) first_name="Meoward",
# self.assert_email_is_accurate("ORGANIZATION ALREADY HAS A .GOV DOMAIN", 0, EMAIL, bcc_email_address=BCC_EMAIL) last_name="Jones",
# self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) email=EMAIL,
phone="(555) 123 12345",
title="Treat inspector",
)
# # Test the email sent out for bad_name BCC_EMAIL = settings.DEFAULT_FROM_EMAIL
# bad_name = DomainRequest.ActionNeededReasons.BAD_NAME in_review = DomainRequest.DomainRequestStatus.IN_REVIEW
# self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=bad_name) rejected = DomainRequest.DomainRequestStatus.REJECTED
# self.assert_email_is_accurate(
# "DOMAIN NAME DOES NOT MEET .GOV REQUIREMENTS", 1, EMAIL, bcc_email_address=BCC_EMAIL
# )
# self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
# # Test the email sent out for eligibility_unclear # Create a sample domain request
# eligibility_unclear = DomainRequest.ActionNeededReasons.ELIGIBILITY_UNCLEAR domain_request = completed_domain_request(status=in_review, user=_creator)
# self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=eligibility_unclear)
# self.assert_email_is_accurate(
# "ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS", 2, EMAIL, bcc_email_address=BCC_EMAIL
# )
# self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
# # Test the email sent out for questionable_so expected_emails = {
# questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL DomainRequest.RejectionReasons.DOMAIN_PURPOSE: "You didnt provide enough information about how",
# self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=questionable_so) DomainRequest.RejectionReasons.REQUESTOR_NOT_ELIGIBLE: "You must be a government employee, or be",
# self.assert_email_is_accurate( DomainRequest.RejectionReasons.ORG_HAS_DOMAIN: "practice is to approve one domain",
# "SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", 3, EMAIL, bcc_email_address=BCC_EMAIL DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED: "we could not verify the organizational",
# ) DomainRequest.RejectionReasons.ORG_NOT_ELIGIBLE: ".Gov domains are only available to official U.S.-based",
# self.assertEqual(len(self.mock_client.EMAILS_SENT), 4) DomainRequest.RejectionReasons.NAMING_REQUIREMENTS: "does not meet our naming requirements",
DomainRequest.RejectionReasons.OTHER: "YOU CAN SUBMIT A NEW REQUEST",
# # Assert that no other emails are sent on OTHER }
# other = DomainRequest.ActionNeededReasons.OTHER for i, (reason, email_content) in enumerate(expected_emails.items()):
# self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=other) with self.subTest(reason=reason):
self.transition_state_and_send_email(domain_request, status=rejected, rejection_reason=reason)
# # Should be unchanged from before self.assert_email_is_accurate(email_content, i, EMAIL, bcc_email_address=BCC_EMAIL)
# self.assertEqual(len(self.mock_client.EMAILS_SENT), 4) self.assertEqual(len(self.mock_client.EMAILS_SENT), i + 1)
domain_request.rejection_reason_email = None
domain_request.save()
domain_request.refresh_from_db()
@less_console_noise_decorator @less_console_noise_decorator
def test_save_model_sends_submitted_email(self): def test_save_model_sends_submitted_email(self):
@ -1034,7 +1047,9 @@ class TestDomainRequestAdmin(MockEppLib):
# Reject for reason REQUESTOR and test email including dynamic organization name # Reject for reason REQUESTOR and test email including dynamic organization name
self.transition_state_and_send_email( self.transition_state_and_send_email(
domain_request, DomainRequest.DomainRequestStatus.REJECTED, DomainRequest.RejectionReasons.REQUESTOR domain_request,
DomainRequest.DomainRequestStatus.REJECTED,
DomainRequest.RejectionReasons.REQUESTOR_NOT_ELIGIBLE,
) )
self.assert_email_is_accurate( self.assert_email_is_accurate(
"Your domain request was rejected because we dont believe youre eligible to request a \n.gov " "Your domain request was rejected because we dont believe youre eligible to request a \n.gov "
@ -1072,7 +1087,7 @@ class TestDomainRequestAdmin(MockEppLib):
self.transition_state_and_send_email( self.transition_state_and_send_email(
domain_request, domain_request,
DomainRequest.DomainRequestStatus.REJECTED, DomainRequest.DomainRequestStatus.REJECTED,
DomainRequest.RejectionReasons.SECOND_DOMAIN_REASONING, DomainRequest.RejectionReasons.ORG_HAS_DOMAIN,
) )
self.assert_email_is_accurate( self.assert_email_is_accurate(
"Your domain request was rejected because Testorg has a .gov domain.", 0, _creator.email "Your domain request was rejected because Testorg has a .gov domain.", 0, _creator.email
@ -1108,7 +1123,7 @@ class TestDomainRequestAdmin(MockEppLib):
self.transition_state_and_send_email( self.transition_state_and_send_email(
domain_request, domain_request,
DomainRequest.DomainRequestStatus.REJECTED, DomainRequest.DomainRequestStatus.REJECTED,
DomainRequest.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY, DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED,
) )
self.assert_email_is_accurate( self.assert_email_is_accurate(
"Your domain request was rejected because we could not verify the organizational \n" "Your domain request was rejected because we could not verify the organizational \n"
@ -1146,7 +1161,7 @@ class TestDomainRequestAdmin(MockEppLib):
self.transition_state_and_send_email( self.transition_state_and_send_email(
domain_request, domain_request,
DomainRequest.DomainRequestStatus.REJECTED, DomainRequest.DomainRequestStatus.REJECTED,
DomainRequest.RejectionReasons.ORGANIZATION_ELIGIBILITY, DomainRequest.RejectionReasons.ORG_NOT_ELIGIBLE,
) )
self.assert_email_is_accurate( self.assert_email_is_accurate(
"Your domain request was rejected because we determined that Testorg is not \neligible for " "Your domain request was rejected because we determined that Testorg is not \neligible for "
@ -1275,7 +1290,7 @@ class TestDomainRequestAdmin(MockEppLib):
stack.enter_context(patch.object(messages, "error")) stack.enter_context(patch.object(messages, "error"))
stack.enter_context(patch.object(messages, "warning")) stack.enter_context(patch.object(messages, "warning"))
domain_request.status = DomainRequest.DomainRequestStatus.REJECTED domain_request.status = DomainRequest.DomainRequestStatus.REJECTED
domain_request.rejection_reason = DomainRequest.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY domain_request.rejection_reason = DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED
self.admin.save_model(request, domain_request, None, True) self.admin.save_model(request, domain_request, None, True)
@ -1511,7 +1526,7 @@ class TestDomainRequestAdmin(MockEppLib):
self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields) self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields)
# Test for the copy link # Test for the copy link
self.assertContains(response, "button--clipboard", count=4) self.assertContains(response, "copy-to-clipboard", count=4)
# Test that Creator counts display properly # Test that Creator counts display properly
self.assertNotContains(response, "Approved domains") self.assertNotContains(response, "Approved domains")
@ -1621,6 +1636,7 @@ class TestDomainRequestAdmin(MockEppLib):
"updated_at", "updated_at",
"status", "status",
"rejection_reason", "rejection_reason",
"rejection_reason_email",
"action_needed_reason", "action_needed_reason",
"action_needed_reason_email", "action_needed_reason_email",
"federal_agency", "federal_agency",
@ -1840,12 +1856,64 @@ class TestDomainRequestAdmin(MockEppLib):
self.trigger_saving_approved_to_another_state( self.trigger_saving_approved_to_another_state(
False, False,
DomainRequest.DomainRequestStatus.REJECTED, DomainRequest.DomainRequestStatus.REJECTED,
DomainRequest.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY, DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED,
) )
def test_side_effects_when_saving_approved_to_ineligible(self): def test_side_effects_when_saving_approved_to_ineligible(self):
self.trigger_saving_approved_to_another_state(False, DomainRequest.DomainRequestStatus.INELIGIBLE) self.trigger_saving_approved_to_another_state(False, DomainRequest.DomainRequestStatus.INELIGIBLE)
@less_console_noise
def test_error_when_saving_to_approved_and_domain_exists(self):
"""Redundant admin check on model transition not allowed."""
Domain.objects.create(name="wabbitseason.gov")
new_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.SUBMITTED, name="wabbitseason.gov"
)
# Create a request object with a superuser
request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(new_request.pk))
request.user = self.superuser
request.session = {}
# Use ExitStack to combine patch contexts
with ExitStack() as stack:
# Patch django.contrib.messages.error
stack.enter_context(patch.object(messages, "error"))
new_request.status = DomainRequest.DomainRequestStatus.APPROVED
self.admin.save_model(request, new_request, None, True)
messages.error.assert_called_once_with(
request,
"Cannot approve. Requested domain is already in use.",
)
@less_console_noise
def test_no_error_when_saving_to_approved_and_domain_exists(self):
"""The negative of the redundant admin check on model transition not allowed."""
new_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.SUBMITTED)
# Create a request object with a superuser
request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(new_request.pk))
request.user = self.superuser
request.session = {}
# Use ExitStack to combine patch contexts
with ExitStack() as stack:
# Patch Domain.is_active and django.contrib.messages.error simultaneously
stack.enter_context(patch.object(messages, "error"))
new_request.status = DomainRequest.DomainRequestStatus.APPROVED
self.admin.save_model(request, new_request, None, True)
# Assert that the error message was never called
messages.error.assert_not_called()
def test_has_correct_filters(self): def test_has_correct_filters(self):
""" """
This test verifies that DomainRequestAdmin has the correct filters set up. This test verifies that DomainRequestAdmin has the correct filters set up.

View file

@ -143,8 +143,8 @@ class GetActionNeededEmailForUserJsonTest(TestCase):
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
data = response.json() data = response.json()
self.assertIn("action_needed_email", data) self.assertIn("email", data)
self.assertIn("ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS", data["action_needed_email"]) self.assertIn("ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS", data["email"])
@less_console_noise_decorator @less_console_noise_decorator
def test_get_action_needed_email_for_user_json_analyst(self): def test_get_action_needed_email_for_user_json_analyst(self):
@ -160,8 +160,8 @@ class GetActionNeededEmailForUserJsonTest(TestCase):
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
data = response.json() data = response.json()
self.assertIn("action_needed_email", data) self.assertIn("email", data)
self.assertIn("SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", data["action_needed_email"]) self.assertIn("SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", data["email"])
@less_console_noise_decorator @less_console_noise_decorator
def test_get_action_needed_email_for_user_json_regular(self): def test_get_action_needed_email_for_user_json_regular(self):
@ -176,3 +176,71 @@ class GetActionNeededEmailForUserJsonTest(TestCase):
}, },
) )
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
class GetRejectionEmailForUserJsonTest(TestCase):
def setUp(self):
self.client = Client()
self.superuser = create_superuser()
self.analyst_user = create_user()
self.agency = FederalAgency.objects.create(agency="Test Agency")
self.domain_request = completed_domain_request(
federal_agency=self.agency,
name="test.gov",
status=DomainRequest.DomainRequestStatus.REJECTED,
)
self.api_url = reverse("get-rejection-email-for-user-json")
def tearDown(self):
DomainRequest.objects.all().delete()
User.objects.all().delete()
FederalAgency.objects.all().delete()
@less_console_noise_decorator
def test_get_rejected_email_for_user_json_superuser(self):
"""Test that a superuser can fetch the action needed email."""
self.client.force_login(self.superuser)
response = self.client.get(
self.api_url,
{
"reason": DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED,
"domain_request_id": self.domain_request.id,
},
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIn("email", data)
self.assertIn("we could not verify the organizational", data["email"])
@less_console_noise_decorator
def test_get_rejected_email_for_user_json_analyst(self):
"""Test that an analyst can fetch the action needed email."""
self.client.force_login(self.analyst_user)
response = self.client.get(
self.api_url,
{
"reason": DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED,
"domain_request_id": self.domain_request.id,
},
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIn("email", data)
self.assertIn("we could not verify the organizational", data["email"])
@less_console_noise_decorator
def test_get_rejected_email_for_user_json_regular(self):
"""Test that a regular user receives a 403 with an error message."""
p = "password"
self.client.login(username="testuser", password=p)
response = self.client.get(
self.api_url,
{
"reason": DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED,
"domain_request_id": self.domain_request.id,
},
)
self.assertEqual(response.status_code, 302)

View file

@ -33,7 +33,7 @@ class TestFormValidation(MockEppLib):
form = OrganizationContactForm(data={"zipcode": "nah"}) form = OrganizationContactForm(data={"zipcode": "nah"})
self.assertEqual( self.assertEqual(
form.errors["zipcode"], form.errors["zipcode"],
["Enter a zip code in the form of 12345 or 12345-6789."], ["Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789."],
) )
def test_org_contact_zip_valid(self): def test_org_contact_zip_valid(self):

View file

@ -1,7 +1,5 @@
from django.forms import ValidationError from django.forms import ValidationError
from django.test import TestCase from django.test import TestCase
from django.db.utils import IntegrityError
from django.db import transaction
from unittest.mock import patch from unittest.mock import patch
from django.test import RequestFactory from django.test import RequestFactory
@ -20,28 +18,24 @@ from registrar.models import (
UserPortfolioPermission, UserPortfolioPermission,
AllowedEmail, AllowedEmail,
) )
import boto3_mocking import boto3_mocking
from registrar.models.portfolio import Portfolio from registrar.models.portfolio import Portfolio
from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.transition_domain import TransitionDomain from registrar.models.transition_domain import TransitionDomain
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.models.verified_by_staff import VerifiedByStaff # type: ignore from registrar.models.verified_by_staff import VerifiedByStaff # type: ignore
from registrar.utility.constants import BranchChoices
from .common import ( from .common import (
MockSESClient, MockSESClient,
less_console_noise,
completed_domain_request, completed_domain_request,
set_domain_request_investigators,
create_test_user, create_test_user,
) )
from django_fsm import TransitionNotAllowed
from waffle.testutils import override_flag from waffle.testutils import override_flag
from api.tests.common import less_console_noise_decorator from api.tests.common import less_console_noise_decorator
<<<<<<< HEAD
@boto3_mocking.patching @boto3_mocking.patching
class TestDomainRequest(TestCase): class TestDomainRequest(TestCase):
@less_console_noise_decorator @less_console_noise_decorator
@ -1059,6 +1053,8 @@ class TestPermissions(TestCase):
self.assertTrue(UserDomainRole.objects.get(user=user, domain=domain)) self.assertTrue(UserDomainRole.objects.get(user=user, domain=domain))
=======
>>>>>>> origin/main
class TestDomainInformation(TestCase): class TestDomainInformation(TestCase):
"""Test the DomainInformation model, when approved or otherwise""" """Test the DomainInformation model, when approved or otherwise"""
@ -1176,12 +1172,15 @@ class TestPortfolioInvitations(TestCase):
self.invitation, _ = PortfolioInvitation.objects.get_or_create( self.invitation, _ = PortfolioInvitation.objects.get_or_create(
email=self.email, email=self.email,
portfolio=self.portfolio, portfolio=self.portfolio,
portfolio_roles=[self.portfolio_role_base, self.portfolio_role_admin], roles=[self.portfolio_role_base, self.portfolio_role_admin],
portfolio_additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2], additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
) )
def tearDown(self): def tearDown(self):
super().tearDown() super().tearDown()
DomainInvitation.objects.all().delete()
DomainInformation.objects.all().delete()
Domain.objects.all().delete()
UserPortfolioPermission.objects.all().delete() UserPortfolioPermission.objects.all().delete()
Portfolio.objects.all().delete() Portfolio.objects.all().delete()
PortfolioInvitation.objects.all().delete() PortfolioInvitation.objects.all().delete()
@ -1233,8 +1232,8 @@ class TestPortfolioInvitations(TestCase):
PortfolioInvitation.objects.get_or_create( PortfolioInvitation.objects.get_or_create(
email=self.email, email=self.email,
portfolio=portfolio2, portfolio=portfolio2,
portfolio_roles=[self.portfolio_role_base, self.portfolio_role_admin], roles=[self.portfolio_role_base, self.portfolio_role_admin],
portfolio_additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2], additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
) )
with override_flag("multiple_portfolios", active=True): with override_flag("multiple_portfolios", active=True):
self.user.check_portfolio_invitations_on_login() self.user.check_portfolio_invitations_on_login()
@ -1257,8 +1256,8 @@ class TestPortfolioInvitations(TestCase):
PortfolioInvitation.objects.get_or_create( PortfolioInvitation.objects.get_or_create(
email=self.email, email=self.email,
portfolio=portfolio2, portfolio=portfolio2,
portfolio_roles=[self.portfolio_role_base, self.portfolio_role_admin], roles=[self.portfolio_role_base, self.portfolio_role_admin],
portfolio_additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2], additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
) )
self.user.check_portfolio_invitations_on_login() self.user.check_portfolio_invitations_on_login()
self.user.refresh_from_db() self.user.refresh_from_db()
@ -1269,6 +1268,52 @@ class TestPortfolioInvitations(TestCase):
updated_invitation2, _ = PortfolioInvitation.objects.get_or_create(email=self.email, portfolio=portfolio2) updated_invitation2, _ = PortfolioInvitation.objects.get_or_create(email=self.email, portfolio=portfolio2)
self.assertEqual(updated_invitation2.status, PortfolioInvitation.PortfolioInvitationStatus.INVITED) self.assertEqual(updated_invitation2.status, PortfolioInvitation.PortfolioInvitationStatus.INVITED)
@less_console_noise_decorator
def test_get_managed_domains_count(self):
"""Test that the correct number of domains, which are associated with the portfolio and
have invited the email of the portfolio invitation, are returned."""
# Add three domains, one which is in the portfolio and email is invited to,
# one which is in the portfolio and email is not invited to,
# and one which is email is invited to and not in the portfolio.
# Arrange
# domain_in_portfolio should not be included in the count
domain_in_portfolio, _ = Domain.objects.get_or_create(name="domain_in_portfolio.gov", state=Domain.State.READY)
DomainInformation.objects.get_or_create(creator=self.user, domain=domain_in_portfolio, portfolio=self.portfolio)
# domain_in_portfolio_and_invited should be included in the count
domain_in_portfolio_and_invited, _ = Domain.objects.get_or_create(
name="domain_in_portfolio_and_invited.gov", state=Domain.State.READY
)
DomainInformation.objects.get_or_create(
creator=self.user, domain=domain_in_portfolio_and_invited, portfolio=self.portfolio
)
DomainInvitation.objects.get_or_create(email=self.email, domain=domain_in_portfolio_and_invited)
# domain_invited should not be included in the count
domain_invited, _ = Domain.objects.get_or_create(name="domain_invited.gov", state=Domain.State.READY)
DomainInformation.objects.get_or_create(creator=self.user, domain=domain_invited)
DomainInvitation.objects.get_or_create(email=self.email, domain=domain_invited)
# Assert
self.assertEqual(self.invitation.get_managed_domains_count(), 1)
@less_console_noise_decorator
def test_get_portfolio_permissions(self):
"""Test that get_portfolio_permissions returns the expected list of permissions,
based on the roles and permissions assigned to the invitation."""
# Arrange
test_permission_list = set()
# add the arrays that are defined in UserPortfolioPermission for member and admin
test_permission_list.update(
UserPortfolioPermission.PORTFOLIO_ROLE_PERMISSIONS.get(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [])
)
test_permission_list.update(
UserPortfolioPermission.PORTFOLIO_ROLE_PERMISSIONS.get(UserPortfolioRoleChoices.ORGANIZATION_ADMIN, [])
)
# add the permissions that are added to the invitation as additional_permissions
test_permission_list.update([self.portfolio_permission_1, self.portfolio_permission_2])
perm_list = list(test_permission_list)
# Verify
self.assertEquals(self.invitation.get_portfolio_permissions(), perm_list)
class TestUserPortfolioPermission(TestCase): class TestUserPortfolioPermission(TestCase):
@less_console_noise_decorator @less_console_noise_decorator
@ -1338,6 +1383,40 @@ class TestUserPortfolioPermission(TestCase):
), ),
) )
@less_console_noise_decorator
def test_get_managed_domains_count(self):
"""Test that the correct number of managed domains associated with the portfolio
are returned."""
# Add three domains, one which is in the portfolio and managed by the user,
# one which is in the portfolio and not managed by the user,
# and one which is managed by the user and not in the portfolio.
# Arrange
portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California")
test_user = create_test_user()
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
portfolio=portfolio, user=test_user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
# domain_in_portfolio should not be included in the count
domain_in_portfolio, _ = Domain.objects.get_or_create(name="domain_in_portfolio.gov", state=Domain.State.READY)
DomainInformation.objects.get_or_create(creator=self.user, domain=domain_in_portfolio, portfolio=portfolio)
# domain_in_portfolio_and_managed should be included in the count
domain_in_portfolio_and_managed, _ = Domain.objects.get_or_create(
name="domain_in_portfolio_and_managed.gov", state=Domain.State.READY
)
DomainInformation.objects.get_or_create(
creator=self.user, domain=domain_in_portfolio_and_managed, portfolio=portfolio
)
UserDomainRole.objects.get_or_create(
user=test_user, domain=domain_in_portfolio_and_managed, role=UserDomainRole.Roles.MANAGER
)
# domain_managed should not be included in the count
domain_managed, _ = Domain.objects.get_or_create(name="domain_managed.gov", state=Domain.State.READY)
DomainInformation.objects.get_or_create(creator=self.user, domain=domain_managed)
UserDomainRole.objects.get_or_create(user=test_user, domain=domain_managed, role=UserDomainRole.Roles.MANAGER)
# Assert
self.assertEqual(portfolio_permission.get_managed_domains_count(), 1)
class TestUser(TestCase): class TestUser(TestCase):
"""Test actions that occur on user login, """Test actions that occur on user login,

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,7 @@ from registrar.models import (
Domain, Domain,
UserDomainRole, UserDomainRole,
) )
from registrar.models import Portfolio from registrar.models import Portfolio, DraftDomain
from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
from registrar.utility.csv_export import ( from registrar.utility.csv_export import (
@ -14,6 +14,7 @@ from registrar.utility.csv_export import (
DomainDataType, DomainDataType,
DomainDataFederal, DomainDataFederal,
DomainDataTypeUser, DomainDataTypeUser,
DomainRequestsDataType,
DomainGrowth, DomainGrowth,
DomainManaged, DomainManaged,
DomainUnmanaged, DomainUnmanaged,
@ -389,6 +390,77 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
return csv_content return csv_content
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
def test_domain_request_data_type_user_with_portfolio(self):
"""Tests DomainRequestsDataType export with portfolio permissions"""
# Create a portfolio and assign it to the user
portfolio = Portfolio.objects.create(creator=self.user, organization_name="Test Portfolio")
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(portfolio=portfolio, user=self.user)
# Create DraftDomain objects
dd_1 = DraftDomain.objects.create(name="example1.com")
dd_2 = DraftDomain.objects.create(name="example2.com")
dd_3 = DraftDomain.objects.create(name="example3.com")
# Create some domain requests
dr_1 = DomainRequest.objects.create(creator=self.user, requested_domain=dd_1, portfolio=portfolio)
dr_2 = DomainRequest.objects.create(creator=self.user, requested_domain=dd_2)
dr_3 = DomainRequest.objects.create(creator=self.user, requested_domain=dd_3, portfolio=portfolio)
# Set up user permissions
portfolio_permission.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
portfolio_permission.save()
portfolio_permission.refresh_from_db()
# Make a GET request using self.client to get a request object
request = get_wsgi_request_object(client=self.client, user=self.user)
# Get the CSV content
csv_content = self._run_domain_request_data_type_user_export(request)
# We expect only domain requests associated with the user's portfolio
self.assertIn(dd_1.name, csv_content)
self.assertIn(dd_3.name, csv_content)
self.assertNotIn(dd_2.name, csv_content)
# Get the csv content
csv_content = self._run_domain_request_data_type_user_export(request)
self.assertIn(dd_1.name, csv_content)
self.assertIn(dd_3.name, csv_content)
self.assertNotIn(dd_2.name, csv_content)
portfolio_permission.roles = [UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
portfolio_permission.save()
portfolio_permission.refresh_from_db()
# Domain Request NOT in Portfolio
csv_content = self._run_domain_request_data_type_user_export(request)
self.assertNotIn(dd_1.name, csv_content)
self.assertNotIn(dd_3.name, csv_content)
self.assertNotIn(dd_2.name, csv_content)
# Clean up the created objects
dr_1.delete()
dr_2.delete()
dr_3.delete()
portfolio.delete()
def _run_domain_request_data_type_user_export(self, request):
"""Helper function to run the exporting_dr_data_to_csv function on DomainRequestsDataType"""
csv_file = StringIO()
DomainRequestsDataType.exporting_dr_data_to_csv(csv_file, request=request)
csv_file.seek(0)
csv_content = csv_file.read()
return csv_content
@less_console_noise_decorator @less_console_noise_decorator
def test_domain_data_full(self): def test_domain_data_full(self):
"""Shows security contacts, filtered by state""" """Shows security contacts, filtered by state"""

View file

@ -349,7 +349,10 @@ class TestDomainDetail(TestDomainOverview):
detail_page = self.client.get(f"/domain/{domain.id}") detail_page = self.client.get(f"/domain/{domain.id}")
# Check that alert message displays properly # Check that alert message displays properly
self.assertContains( self.assertContains(
detail_page, "To manage information for this domain, you must add yourself as a domain manager." detail_page,
"You don't have access to manage "
+ domain.name
+ ". If you need to make updates, contact one of the listed domain managers.",
) )
# Check that user does not have option to Edit domain # Check that user does not have option to Edit domain
self.assertNotContains(detail_page, "Edit") self.assertNotContains(detail_page, "Edit")

View file

@ -37,6 +37,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
UserDomainRole.objects.all().delete() UserDomainRole.objects.all().delete()
UserPortfolioPermission.objects.all().delete() UserPortfolioPermission.objects.all().delete()
DomainInformation.objects.all().delete() DomainInformation.objects.all().delete()
Domain.objects.all().delete()
Portfolio.objects.all().delete() Portfolio.objects.all().delete()
super().tearDown() super().tearDown()

View file

@ -1,6 +1,7 @@
from django.urls import reverse from django.urls import reverse
from registrar.models.portfolio import Portfolio from registrar.models.portfolio import Portfolio
from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.user import User from registrar.models.user import User
from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
@ -38,6 +39,7 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
phone="8003114567", phone="8003114567",
title="Admin", title="Admin",
) )
cls.email5 = "fifth@example.com"
# Create Portfolio # Create Portfolio
cls.portfolio = Portfolio.objects.create(creator=cls.user, organization_name="Test Portfolio") cls.portfolio = Portfolio.objects.create(creator=cls.user, organization_name="Test Portfolio")
@ -67,6 +69,23 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
portfolio=cls.portfolio, portfolio=cls.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
) )
PortfolioInvitation.objects.create(
email=cls.email5,
portfolio=cls.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
@classmethod
def tearDownClass(cls):
PortfolioInvitation.objects.all().delete()
UserPortfolioPermission.objects.all().delete()
Portfolio.objects.all().delete()
User.objects.all().delete()
super().tearDownClass()
def setUp(self): def setUp(self):
super().setUp() super().setUp()
@ -83,14 +102,21 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
self.assertFalse(data["has_previous"]) self.assertFalse(data["has_previous"])
self.assertFalse(data["has_next"]) self.assertFalse(data["has_next"])
self.assertEqual(data["num_pages"], 1) self.assertEqual(data["num_pages"], 1)
self.assertEqual(data["total"], 4) self.assertEqual(data["total"], 5)
self.assertEqual(data["unfiltered_total"], 4) self.assertEqual(data["unfiltered_total"], 5)
# Check the number of members # Check the number of members
self.assertEqual(len(data["members"]), 4) self.assertEqual(len(data["members"]), 5)
# Check member fields # Check member fields
expected_emails = {self.user.email, self.user2.email, self.user3.email, self.user4.email} expected_emails = {
self.user.email,
self.user2.email,
self.user3.email,
self.user4.email,
self.user4.email,
self.email5,
}
actual_emails = {member["email"] for member in data["members"]} actual_emails = {member["email"] for member in data["members"]}
self.assertEqual(expected_emails, actual_emails) self.assertEqual(expected_emails, actual_emails)
@ -123,8 +149,8 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
self.assertTrue(data["has_next"]) self.assertTrue(data["has_next"])
self.assertFalse(data["has_previous"]) self.assertFalse(data["has_previous"])
self.assertEqual(data["num_pages"], 2) self.assertEqual(data["num_pages"], 2)
self.assertEqual(data["total"], 14) self.assertEqual(data["total"], 15)
self.assertEqual(data["unfiltered_total"], 14) self.assertEqual(data["unfiltered_total"], 15)
# Check the number of members on page 1 # Check the number of members on page 1
self.assertEqual(len(data["members"]), 10) self.assertEqual(len(data["members"]), 10)
@ -142,7 +168,7 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
self.assertEqual(data["num_pages"], 2) self.assertEqual(data["num_pages"], 2)
# Check the number of members on page 2 # Check the number of members on page 2
self.assertEqual(len(data["members"]), 4) self.assertEqual(len(data["members"]), 5)
def test_search(self): def test_search(self):
"""Test search functionality for portfolio members.""" """Test search functionality for portfolio members."""

View file

@ -10,6 +10,7 @@ from registrar.models import (
UserDomainRole, UserDomainRole,
User, User,
) )
from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.user_group import UserGroup from registrar.models.user_group import UserGroup
from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
@ -288,9 +289,9 @@ class TestPortfolio(WebTest):
def test_accessible_pages_when_user_does_not_have_role(self): def test_accessible_pages_when_user_does_not_have_role(self):
"""Test that admin / memmber roles are associated with the right access""" """Test that admin / memmber roles are associated with the right access"""
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN] roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
user=self.user, portfolio=self.portfolio, roles=portfolio_roles user=self.user, portfolio=self.portfolio, roles=roles
) )
with override_flag("organization_feature", active=True): with override_flag("organization_feature", active=True):
# This will redirect the user to the portfolio page. # This will redirect the user to the portfolio page.
@ -398,8 +399,8 @@ class TestPortfolio(WebTest):
"""When organization_feature flag is true and user has a portfolio, """When organization_feature flag is true and user has a portfolio,
the portfolio should be set in session.""" the portfolio should be set in session."""
self.client.force_login(self.user) self.client.force_login(self.user)
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN] roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles) UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=roles)
with override_flag("organization_feature", active=True): with override_flag("organization_feature", active=True):
response = self.client.get(reverse("home")) response = self.client.get(reverse("home"))
# Ensure that middleware processes the session # Ensure that middleware processes the session
@ -420,8 +421,8 @@ class TestPortfolio(WebTest):
This test also satisfies the condition when multiple_portfolios flag This test also satisfies the condition when multiple_portfolios flag
is false and user has a portfolio, so won't add a redundant test for that.""" is false and user has a portfolio, so won't add a redundant test for that."""
self.client.force_login(self.user) self.client.force_login(self.user)
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN] roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles) UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=roles)
response = self.client.get(reverse("home")) response = self.client.get(reverse("home"))
# Ensure that middleware processes the session # Ensure that middleware processes the session
session_middleware = SessionMiddleware(lambda request: None) session_middleware = SessionMiddleware(lambda request: None)
@ -457,8 +458,8 @@ class TestPortfolio(WebTest):
"""When multiple_portfolios flag is true and user has a portfolio, """When multiple_portfolios flag is true and user has a portfolio,
the portfolio should be set in session.""" the portfolio should be set in session."""
self.client.force_login(self.user) self.client.force_login(self.user)
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN] roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles) UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=roles)
with override_flag("organization_feature", active=True), override_flag("multiple_portfolios", active=True): with override_flag("organization_feature", active=True), override_flag("multiple_portfolios", active=True):
response = self.client.get(reverse("home")) response = self.client.get(reverse("home"))
# Ensure that middleware processes the session # Ensure that middleware processes the session
@ -817,7 +818,6 @@ class TestPortfolio(WebTest):
# Verify that view-only settings are sent in the dynamic HTML # Verify that view-only settings are sent in the dynamic HTML
response = self.client.get(reverse("get_portfolio_members_json") + f"?portfolio={self.portfolio.pk}") response = self.client.get(reverse("get_portfolio_members_json") + f"?portfolio={self.portfolio.pk}")
print(response.content)
self.assertContains(response, '"action_label": "View"') self.assertContains(response, '"action_label": "View"')
self.assertContains(response, '"svg_icon": "visibility"') self.assertContains(response, '"svg_icon": "visibility"')
@ -856,6 +856,230 @@ class TestPortfolio(WebTest):
# TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"{response.content}") # TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"{response.content}")
self.assertContains(response, '"is_admin": true') self.assertContains(response, '"is_admin": true')
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
def test_cannot_view_member_page_when_flag_is_off(self):
"""Test that user cannot access the member page when waffle flag is off"""
# Verify that the user cannot access the member page
self.client.force_login(self.user)
response = self.client.get(reverse("member", kwargs={"pk": 1}), follow=True)
# Make sure the page is denied
self.assertEqual(response.status_code, 403)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_cannot_view_member_page_when_user_has_no_permission(self):
"""Test that user cannot access the member page without proper permission"""
# give user base permissions
UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
)
# Verify that the user cannot access the member page
self.client.force_login(self.user)
response = self.client.get(reverse("member", kwargs={"pk": 1}), follow=True)
# Make sure the page is denied
self.assertEqual(response.status_code, 403)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_can_view_member_page_when_user_has_view_members(self):
"""Test that user can access the member page with view_members permission"""
# Arrange
# give user permissions to view members
permission_obj, _ = UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
],
)
# Verify the page can be accessed
self.client.force_login(self.user)
response = self.client.get(reverse("member", kwargs={"pk": permission_obj.pk}), follow=True)
self.assertEqual(response.status_code, 200)
# Assert text within the page is correct
self.assertContains(response, "First Last")
self.assertContains(response, self.user.email)
self.assertContains(response, "Basic access")
self.assertContains(response, "No access")
self.assertContains(response, "View all members")
self.assertContains(response, "This member does not manage any domains.")
# Assert buttons and links within the page are correct
self.assertNotContains(response, "usa-button--more-actions") # test that 3 dot is not present
self.assertNotContains(response, "sprite.svg#edit") # test that Edit link is not present
self.assertNotContains(response, "sprite.svg#settings") # test that Manage link is not present
self.assertContains(response, "sprite.svg#visibility") # test that View link is present
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_can_view_member_page_when_user_has_edit_members(self):
"""Test that user can access the member page with edit_members permission"""
# Arrange
# give user permissions to view AND manage members
permission_obj, _ = UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
# Verify the page can be accessed
self.client.force_login(self.user)
response = self.client.get(reverse("member", kwargs={"pk": permission_obj.pk}), follow=True)
self.assertEqual(response.status_code, 200)
# Assert text within the page is correct
self.assertContains(response, "First Last")
self.assertContains(response, self.user.email)
self.assertContains(response, "Admin access")
self.assertContains(response, "View all requests plus create requests")
self.assertContains(response, "View all members plus manage members")
self.assertContains(
response, 'This member does not manage any domains. To assign this member a domain, click "Manage"'
)
# Assert buttons and links within the page are correct
self.assertContains(response, "usa-button--more-actions") # test that 3 dot is present
self.assertContains(response, "sprite.svg#edit") # test that Edit link is present
self.assertContains(response, "sprite.svg#settings") # test that Manage link is present
self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
def test_cannot_view_invitedmember_page_when_flag_is_off(self):
"""Test that user cannot access the invitedmember page when waffle flag is off"""
# Verify that the user cannot access the member page
self.client.force_login(self.user)
response = self.client.get(reverse("invitedmember", kwargs={"pk": 1}), follow=True)
# Make sure the page is denied
self.assertEqual(response.status_code, 403)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_cannot_view_invitedmember_page_when_user_has_no_permission(self):
"""Test that user cannot access the invitedmember page without proper permission"""
# give user base permissions
UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
)
# Verify that the user cannot access the member page
self.client.force_login(self.user)
response = self.client.get(reverse("invitedmember", kwargs={"pk": 1}), follow=True)
# Make sure the page is denied
self.assertEqual(response.status_code, 403)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_can_view_invitedmember_page_when_user_has_view_members(self):
"""Test that user can access the invitedmember page with view_members permission"""
# Arrange
# give user permissions to view members
UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
],
)
portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(
email="info@example.com",
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
],
)
# Verify the page can be accessed
self.client.force_login(self.user)
response = self.client.get(reverse("invitedmember", kwargs={"pk": portfolio_invitation.pk}), follow=True)
self.assertEqual(response.status_code, 200)
# Assert text within the page is correct
self.assertContains(response, "Invited")
self.assertContains(response, portfolio_invitation.email)
self.assertContains(response, "Basic access")
self.assertContains(response, "No access")
self.assertContains(response, "View all members")
self.assertContains(response, "This member does not manage any domains.")
# Assert buttons and links within the page are correct
self.assertNotContains(response, "usa-button--more-actions") # test that 3 dot is not present
self.assertNotContains(response, "sprite.svg#edit") # test that Edit link is not present
self.assertNotContains(response, "sprite.svg#settings") # test that Manage link is not present
self.assertContains(response, "sprite.svg#visibility") # test that View link is present
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_can_view_invitedmember_page_when_user_has_edit_members(self):
"""Test that user can access the invitedmember page with edit_members permission"""
# Arrange
# give user permissions to view AND manage members
permission_obj, _ = UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(
email="info@example.com",
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
# Verify the page can be accessed
self.client.force_login(self.user)
response = self.client.get(reverse("invitedmember", kwargs={"pk": portfolio_invitation.pk}), follow=True)
self.assertEqual(response.status_code, 200)
# Assert text within the page is correct
self.assertContains(response, "Invited")
self.assertContains(response, portfolio_invitation.email)
self.assertContains(response, "Admin access")
self.assertContains(response, "View all requests plus create requests")
self.assertContains(response, "View all members plus manage members")
self.assertContains(
response, 'This member does not manage any domains. To assign this member a domain, click "Manage"'
)
# Assert buttons and links within the page are correct
self.assertContains(response, "usa-button--more-actions") # test that 3 dot is present
self.assertContains(response, "sprite.svg#edit") # test that Edit link is present
self.assertContains(response, "sprite.svg#settings") # test that Manage link is present
self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present
@less_console_noise_decorator @less_console_noise_decorator
@override_flag("organization_feature", active=True) @override_flag("organization_feature", active=True)
def test_portfolio_domain_requests_page_when_user_has_no_permissions(self): def test_portfolio_domain_requests_page_when_user_has_no_permissions(self):
@ -1015,8 +1239,8 @@ class TestPortfolio(WebTest):
def test_portfolio_cache_updates_when_modified(self): def test_portfolio_cache_updates_when_modified(self):
"""Test that the portfolio in session updates when the portfolio is modified""" """Test that the portfolio in session updates when the portfolio is modified"""
self.client.force_login(self.user) self.client.force_login(self.user)
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN] roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles) UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=roles)
with override_flag("organization_feature", active=True): with override_flag("organization_feature", active=True):
# Initial request to set the portfolio in session # Initial request to set the portfolio in session
@ -1044,8 +1268,8 @@ class TestPortfolio(WebTest):
def test_portfolio_cache_updates_when_flag_disabled_while_logged_in(self): def test_portfolio_cache_updates_when_flag_disabled_while_logged_in(self):
"""Test that the portfolio in session is set to None when the organization_feature flag is disabled""" """Test that the portfolio in session is set to None when the organization_feature flag is disabled"""
self.client.force_login(self.user) self.client.force_login(self.user)
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN] roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles) UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=roles)
with override_flag("organization_feature", active=True): with override_flag("organization_feature", active=True):
# Initial request to set the portfolio in session # Initial request to set the portfolio in session

View file

@ -43,7 +43,7 @@ class DomainRequestTests(TestWithUser, WebTest):
super().setUp() super().setUp()
self.federal_agency, _ = FederalAgency.objects.get_or_create(agency="General Services Administration") self.federal_agency, _ = FederalAgency.objects.get_or_create(agency="General Services Administration")
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
self.TITLES = DomainRequestWizard.TITLES self.TITLES = DomainRequestWizard.REGULAR_TITLES
def tearDown(self): def tearDown(self):
super().tearDown() super().tearDown()
@ -82,7 +82,6 @@ class DomainRequestTests(TestWithUser, WebTest):
response = self.app.get(f"/domain-request/{domain_request.id}") response = self.app.get(f"/domain-request/{domain_request.id}")
# Ensure that the date is still set to None # Ensure that the date is still set to None
self.assertIsNone(domain_request.last_status_update) self.assertIsNone(domain_request.last_status_update)
print(response)
# We should still grab a date for this field in this event - but it should come from the audit log instead # We should still grab a date for this field in this event - but it should come from the audit log instead
self.assertContains(response, "Started on:") self.assertContains(response, "Started on:")
self.assertContains(response, fixed_date.strftime("%B %-d, %Y")) self.assertContains(response, fixed_date.strftime("%B %-d, %Y"))
@ -522,7 +521,8 @@ class DomainRequestTests(TestWithUser, WebTest):
# And the existence of the modal's data parked and ready for the js init. # And the existence of the modal's data parked and ready for the js init.
# The next assert also tests for the passed requested domain context from # The next assert also tests for the passed requested domain context from
# the view > domain_request_form > modal # the view > domain_request_form > modal
self.assertContains(review_page, "You are about to submit a domain request for city.gov") self.assertContains(review_page, "You are about to submit a domain request for")
self.assertContains(review_page, "city.gov")
# final submission results in a redirect to the "finished" URL # final submission results in a redirect to the "finished" URL
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
@ -2742,6 +2742,66 @@ class DomainRequestTests(TestWithUser, WebTest):
self.assertContains(review_page, "toggle-submit-domain-request") self.assertContains(review_page, "toggle-submit-domain-request")
self.assertContains(review_page, "Your request form is incomplete") self.assertContains(review_page, "Your request form is incomplete")
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
def test_portfolio_user_missing_edit_permissions(self):
"""Tests that a portfolio user without edit request permissions cannot edit or add new requests"""
portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Test Portfolio")
portfolio_perm, _ = UserPortfolioPermission.objects.get_or_create(
user=self.user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
)
# This user should be forbidden from creating new domain requests
intro_page = self.app.get(reverse("domain-request:"), expect_errors=True)
self.assertEqual(intro_page.status_code, 403)
# This user should also be forbidden from editing existing ones
domain_request = completed_domain_request(user=self.user)
edit_page = self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk}), expect_errors=True)
self.assertEqual(edit_page.status_code, 403)
# Cleanup
portfolio_perm.delete()
portfolio.delete()
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
def test_portfolio_user_with_edit_permissions(self):
"""Tests that a portfolio user with edit request permissions can edit and add new requests"""
portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Test Portfolio")
portfolio_perm, _ = UserPortfolioPermission.objects.get_or_create(
user=self.user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
# This user should be allowed to create new domain requests
intro_page = self.app.get(reverse("domain-request:"))
self.assertEqual(intro_page.status_code, 200)
# This user should also be allowed to edit existing ones
domain_request = completed_domain_request(user=self.user)
edit_page = self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk})).follow()
self.assertEqual(edit_page.status_code, 200)
# Cleanup
DomainRequest.objects.all().delete()
portfolio_perm.delete()
portfolio.delete()
def test_non_creator_access(self):
"""Tests that a user cannot edit a domain request they didn't create"""
p = "password"
other_user = User.objects.create_user(username="other_user", password=p)
domain_request = completed_domain_request(user=other_user)
edit_page = self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk}), expect_errors=True)
self.assertEqual(edit_page.status_code, 403)
def test_creator_access(self):
"""Tests that a user can edit a domain request they created"""
domain_request = completed_domain_request(user=self.user)
edit_page = self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk})).follow()
self.assertEqual(edit_page.status_code, 200)
class DomainRequestTestDifferentStatuses(TestWithUser, WebTest): class DomainRequestTestDifferentStatuses(TestWithUser, WebTest):
def setUp(self): def setUp(self):
@ -2904,7 +2964,7 @@ class DomainRequestTestDifferentStatuses(TestWithUser, WebTest):
self.assertNotContains(home_page, "city.gov") self.assertNotContains(home_page, "city.gov")
class TestWizardUnlockingSteps(TestWithUser, WebTest): class TestDomainRequestWizard(TestWithUser, WebTest):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
@ -3026,6 +3086,94 @@ class TestWizardUnlockingSteps(TestWithUser, WebTest):
else: else:
self.fail(f"Expected a redirect, but got a different response: {response}") self.fail(f"Expected a redirect, but got a different response: {response}")
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
def test_wizard_steps_portfolio(self):
"""
Tests the behavior of the domain request wizard for portfolios.
Ensures that:
- The user can access the organization page.
- The expected number of steps are locked/unlocked (implicit test for expected steps).
- The user lands on the "Requesting entity" page
- The user does not see the Domain and Domain requests buttons
"""
# This should unlock 4 steps by default.
# Purpose, .gov domain, current websites, and requirements for operating
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
user=self.user,
)
domain_request.anything_else = None
domain_request.save()
federal_agency = FederalAgency.objects.get(agency="Non-Federal Agency")
# Add a portfolio
portfolio = Portfolio.objects.create(
creator=self.user,
organization_name="test portfolio",
federal_agency=federal_agency,
)
user_portfolio_permission = UserPortfolioPermission.objects.create(
user=self.user,
portfolio=portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
)
response = self.app.get(f"/domain-request/{domain_request.id}/edit/")
# django-webtest does not handle cookie-based sessions well because it keeps
# resetting the session key on each new request, thus destroying the concept
# of a "session". We are going to do it manually, saving the session ID here
# and then setting the cookie on each request.
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# Check if the response is a redirect
if response.status_code == 302:
# Follow the redirect manually
try:
detail_page = response.follow()
self.wizard.get_context_data()
except Exception as err:
# Handle any potential errors while following the redirect
self.fail(f"Error following the redirect {err}")
# Now 'detail_page' contains the response after following the redirect
self.assertEqual(detail_page.status_code, 200)
# Assert that we're on the organization page
self.assertContains(detail_page, portfolio.organization_name)
# We should only see one unlocked step
self.assertContains(detail_page, "#check_circle", count=4)
# One pages should still be locked (additional details)
self.assertContains(detail_page, "#lock", 1)
# The current option should be selected
self.assertContains(detail_page, "usa-current", count=1)
# We default to the requesting entity page
expected_url = reverse("domain-request:portfolio_requesting_entity")
# This returns the entire url, thus "in"
self.assertIn(expected_url, detail_page.request.url)
# We shouldn't show the "domains" and "domain requests" buttons
# on this page.
self.assertNotContains(detail_page, "Domains")
self.assertNotContains(detail_page, "Domain requests")
else:
self.fail(f"Expected a redirect, but got a different response: {response}")
# Data cleanup
user_portfolio_permission.delete()
portfolio.delete()
federal_agency.delete()
domain_request.delete()
class TestPortfolioDomainRequestViewonly(TestWithUser, WebTest): class TestPortfolioDomainRequestViewonly(TestWithUser, WebTest):
@ -3037,7 +3185,7 @@ class TestPortfolioDomainRequestViewonly(TestWithUser, WebTest):
super().setUp() super().setUp()
self.federal_agency, _ = FederalAgency.objects.get_or_create(agency="General Services Administration") self.federal_agency, _ = FederalAgency.objects.get_or_create(agency="General Services Administration")
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
self.TITLES = DomainRequestWizard.TITLES self.TITLES = DomainRequestWizard.REGULAR_TITLES
def tearDown(self): def tearDown(self):
super().tearDown() super().tearDown()

View file

@ -6,36 +6,39 @@ from django.utils.html import escape
from registrar.models.utility.generic_helper import value_of_attribute from registrar.models.utility.generic_helper import value_of_attribute
def get_all_action_needed_reason_emails(request, domain_request): def get_action_needed_reason_default_email(domain_request, action_needed_reason):
"""Returns a dictionary of every action needed reason and its associated email """Returns the default email associated with the given action needed reason"""
for this particular domain request.""" return _get_default_email(
domain_request,
emails = {} file_path=f"emails/action_needed_reasons/{action_needed_reason}.txt",
for action_needed_reason in domain_request.ActionNeededReasons: reason=action_needed_reason,
# Map the action_needed_reason to its default email excluded_reasons=[DomainRequest.ActionNeededReasons.OTHER],
emails[action_needed_reason.value] = get_action_needed_reason_default_email(
request, domain_request, action_needed_reason.value
) )
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): def _get_default_email(domain_request, file_path, reason, excluded_reasons=None):
"""Returns the default email associated with the given action needed reason""" if not reason:
if not action_needed_reason or action_needed_reason == DomainRequest.ActionNeededReasons.OTHER: return None
if excluded_reasons and reason in excluded_reasons:
return None return None
recipient = domain_request.creator recipient = domain_request.creator
# Return the context of the rendered views # Return the context of the rendered views
context = {"domain_request": domain_request, "recipient": recipient} context = {"domain_request": domain_request, "recipient": recipient, "reason": reason}
# Get the email body email_body_text = get_template(file_path).render(context=context)
template_path = f"emails/action_needed_reasons/{action_needed_reason}.txt" email_body_text_cleaned = email_body_text.strip().lstrip("\n") if email_body_text else None
email_body_text = get_template(template_path).render(context=context)
email_body_text_cleaned = None
if email_body_text:
email_body_text_cleaned = email_body_text.strip().lstrip("\n")
return email_body_text_cleaned return email_body_text_cleaned

View file

@ -583,6 +583,105 @@ class DomainDataTypeUser(DomainDataType):
return Q(domain__id__in=request.user.get_user_domain_ids(request)) return Q(domain__id__in=request.user.get_user_domain_ids(request))
class DomainRequestsDataType:
"""
The DomainRequestsDataType report, but filtered based on the current request user
"""
@classmethod
def get_filter_conditions(cls, request=None):
if request is None or not hasattr(request, "user") or not request.user.is_authenticated:
return Q(id__in=[])
request_ids = request.user.get_user_domain_request_ids(request)
return Q(id__in=request_ids)
@classmethod
def get_queryset(cls, request):
return DomainRequest.objects.filter(cls.get_filter_conditions(request))
def safe_get(attribute, default="N/A"):
# Return the attribute value or default if not present
return attribute if attribute is not None else default
@classmethod
def exporting_dr_data_to_csv(cls, response, request=None):
import csv
writer = csv.writer(response)
# CSV headers
writer.writerow(
[
"Domain request",
"Region",
"Status",
"Election office",
"Federal type",
"Domain type",
"Request additional details",
"Creator approved domains count",
"Creator active requests count",
"Alternative domains",
"Other contacts",
"Current websites",
"Federal agency",
"SO first name",
"SO last name",
"SO email",
"SO title/role",
"Creator first name",
"Creator last name",
"Creator email",
"Organization name",
"City",
"State/territory",
"Request purpose",
"CISA regional representative",
"Last submitted date",
"First submitted date",
"Last status update",
]
)
queryset = cls.get_queryset(request)
for request in queryset:
writer.writerow(
[
request.requested_domain,
cls.safe_get(getattr(request, "region_field", None)),
request.status,
cls.safe_get(getattr(request, "election_office", None)),
request.federal_type,
cls.safe_get(getattr(request, "domain_type", None)),
cls.safe_get(getattr(request, "additional_details", None)),
cls.safe_get(getattr(request, "creator_approved_domains_count", None)),
cls.safe_get(getattr(request, "creator_active_requests_count", None)),
cls.safe_get(getattr(request, "all_alternative_domains", None)),
cls.safe_get(getattr(request, "all_other_contacts", None)),
cls.safe_get(getattr(request, "all_current_websites", None)),
cls.safe_get(getattr(request, "federal_agency", None)),
cls.safe_get(getattr(request.senior_official, "first_name", None)),
cls.safe_get(getattr(request.senior_official, "last_name", None)),
cls.safe_get(getattr(request.senior_official, "email", None)),
cls.safe_get(getattr(request.senior_official, "title", None)),
cls.safe_get(getattr(request.creator, "first_name", None)),
cls.safe_get(getattr(request.creator, "last_name", None)),
cls.safe_get(getattr(request.creator, "email", None)),
cls.safe_get(getattr(request, "organization_name", None)),
cls.safe_get(getattr(request, "city", None)),
cls.safe_get(getattr(request, "state_territory", None)),
cls.safe_get(getattr(request, "purpose", None)),
cls.safe_get(getattr(request, "cisa_representative_email", None)),
cls.safe_get(getattr(request, "last_submitted_date", None)),
cls.safe_get(getattr(request, "first_submitted_date", None)),
cls.safe_get(getattr(request, "last_status_update", None)),
]
)
return response
class DomainDataFull(DomainExport): class DomainDataFull(DomainExport):
""" """
Shows security contacts, filtered by state Shows security contacts, filtered by state

View file

@ -74,10 +74,13 @@ class PortfolioDomainRequestStep(StrEnum):
appear in the order they are defined. (Order matters.) appear in the order they are defined. (Order matters.)
""" """
# Portfolio # NOTE: Append portfolio_ when customizing a view for portfolio.
REQUESTING_ENTITY = "organization_name" # By default, these will redirect to the normal request flow views.
# After creating a new view, you will need to add this to urls.py.
REQUESTING_ENTITY = "portfolio_requesting_entity"
CURRENT_SITES = "current_sites" CURRENT_SITES = "current_sites"
DOTGOV_DOMAIN = "dotgov_domain" DOTGOV_DOMAIN = "dotgov_domain"
PURPOSE = "purpose" PURPOSE = "purpose"
ADDITIONAL_DETAILS = "additional_details" ADDITIONAL_DETAILS = "portfolio_additional_details"
REQUIREMENTS = "requirements" REQUIREMENTS = "requirements"
REVIEW = "review"

View file

@ -240,7 +240,7 @@ class SecurityEmailError(Exception):
""" """
_error_mapping = { _error_mapping = {
SecurityEmailErrorCodes.BAD_DATA: ("Enter an email address in the required format, " "like name@example.com."), SecurityEmailErrorCodes.BAD_DATA: ("Enter an email address in the required format, like name@example.com."),
} }
def __init__(self, *args, code=None, **kwargs): def __init__(self, *args, code=None, **kwargs):

View file

@ -43,8 +43,8 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
although not without consulting the base implementation, first. although not without consulting the base implementation, first.
""" """
StepEnum: Step = Step # type: ignore
template_name = "" template_name = ""
is_portfolio = False
# uniquely namespace the wizard in urls.py # uniquely namespace the wizard in urls.py
# (this is not seen _in_ urls, only for Django's internal naming) # (this is not seen _in_ urls, only for Django's internal naming)
@ -54,42 +54,140 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
# name for accessing /domain-request/<id>/edit # name for accessing /domain-request/<id>/edit
EDIT_URL_NAME = "edit-domain-request" EDIT_URL_NAME = "edit-domain-request"
NEW_URL_NAME = "/request/" NEW_URL_NAME = "/request/"
# region: Titles
# We need to pass our human-readable step titles as context to the templates. # We need to pass our human-readable step titles as context to the templates.
TITLES = { REGULAR_TITLES = {
StepEnum.ORGANIZATION_TYPE: _("Type of organization"), Step.ORGANIZATION_TYPE: _("Type of organization"),
StepEnum.TRIBAL_GOVERNMENT: _("Tribal government"), Step.TRIBAL_GOVERNMENT: _("Tribal government"),
StepEnum.ORGANIZATION_FEDERAL: _("Federal government branch"), Step.ORGANIZATION_FEDERAL: _("Federal government branch"),
StepEnum.ORGANIZATION_ELECTION: _("Election office"), Step.ORGANIZATION_ELECTION: _("Election office"),
StepEnum.ORGANIZATION_CONTACT: _("Organization"), Step.ORGANIZATION_CONTACT: _("Organization"),
StepEnum.ABOUT_YOUR_ORGANIZATION: _("About your organization"), Step.ABOUT_YOUR_ORGANIZATION: _("About your organization"),
StepEnum.SENIOR_OFFICIAL: _("Senior official"), Step.SENIOR_OFFICIAL: _("Senior official"),
StepEnum.CURRENT_SITES: _("Current websites"), Step.CURRENT_SITES: _("Current websites"),
StepEnum.DOTGOV_DOMAIN: _(".gov domain"), Step.DOTGOV_DOMAIN: _(".gov domain"),
StepEnum.PURPOSE: _("Purpose of your domain"), Step.PURPOSE: _("Purpose of your domain"),
StepEnum.OTHER_CONTACTS: _("Other employees from your organization"), Step.OTHER_CONTACTS: _("Other employees from your organization"),
StepEnum.ADDITIONAL_DETAILS: _("Additional details"), Step.ADDITIONAL_DETAILS: _("Additional details"),
StepEnum.REQUIREMENTS: _("Requirements for operating a .gov domain"), Step.REQUIREMENTS: _("Requirements for operating a .gov domain"),
StepEnum.REVIEW: _("Review and submit your domain request"), Step.REVIEW: _("Review and submit your domain request"),
} }
# Titles for the portfolio context
PORTFOLIO_TITLES = {
PortfolioDomainRequestStep.REQUESTING_ENTITY: _("Requesting entity"),
PortfolioDomainRequestStep.CURRENT_SITES: _("Current websites"),
PortfolioDomainRequestStep.DOTGOV_DOMAIN: _(".gov domain"),
PortfolioDomainRequestStep.PURPOSE: _("Purpose of your domain"),
PortfolioDomainRequestStep.ADDITIONAL_DETAILS: _("Additional details"),
PortfolioDomainRequestStep.REQUIREMENTS: _("Requirements for operating a .gov domain"),
PortfolioDomainRequestStep.REVIEW: _("Review and submit your domain request"),
}
# endregion
# region: Wizard conditions
# We can use a dictionary with step names and callables that return booleans # We can use a dictionary with step names and callables that return booleans
# to show or hide particular steps based on the state of the process. # to show or hide particular steps based on the state of the process.
WIZARD_CONDITIONS = { REGULAR_WIZARD_CONDITIONS = {
StepEnum.ORGANIZATION_FEDERAL: lambda w: w.from_model("show_organization_federal", False), Step.ORGANIZATION_FEDERAL: lambda w: w.from_model("show_organization_federal", False),
StepEnum.TRIBAL_GOVERNMENT: lambda w: w.from_model("show_tribal_government", False), Step.TRIBAL_GOVERNMENT: lambda w: w.from_model("show_tribal_government", False),
StepEnum.ORGANIZATION_ELECTION: lambda w: w.from_model("show_organization_election", False), Step.ORGANIZATION_ELECTION: lambda w: w.from_model("show_organization_election", False),
StepEnum.ABOUT_YOUR_ORGANIZATION: lambda w: w.from_model("show_about_your_organization", False), Step.ABOUT_YOUR_ORGANIZATION: lambda w: w.from_model("show_about_your_organization", False),
} }
PORTFOLIO_WIZARD_CONDITIONS = {} # type: ignore
# endregion
# region: Unlocking steps
# The conditions by which each step is "unlocked" or "locked"
REGULAR_UNLOCKING_STEPS = {
Step.ORGANIZATION_TYPE: lambda self: self.domain_request.generic_org_type is not None,
Step.TRIBAL_GOVERNMENT: lambda self: self.domain_request.tribe_name is not None,
Step.ORGANIZATION_FEDERAL: lambda self: self.domain_request.federal_type is not None,
Step.ORGANIZATION_ELECTION: lambda self: self.domain_request.is_election_board is not None,
Step.ORGANIZATION_CONTACT: lambda self: (
self.domain_request.federal_agency is not None
or self.domain_request.organization_name is not None
or self.domain_request.address_line1 is not None
or self.domain_request.city is not None
or self.domain_request.state_territory is not None
or self.domain_request.zipcode is not None
or self.domain_request.urbanization is not None
),
Step.ABOUT_YOUR_ORGANIZATION: lambda self: self.domain_request.about_your_organization is not None,
Step.SENIOR_OFFICIAL: lambda self: self.domain_request.senior_official is not None,
Step.CURRENT_SITES: lambda self: (
self.domain_request.current_websites.exists() or self.domain_request.requested_domain is not None
),
Step.DOTGOV_DOMAIN: lambda self: self.domain_request.requested_domain is not None,
Step.PURPOSE: lambda self: self.domain_request.purpose is not None,
Step.OTHER_CONTACTS: lambda self: (
self.domain_request.other_contacts.exists() or self.domain_request.no_other_contacts_rationale is not None
),
Step.ADDITIONAL_DETAILS: lambda self: (
# Additional details is complete as long as "has anything else" and "has cisa rep" are not None
(
self.domain_request.has_anything_else_text is not None
and self.domain_request.has_cisa_representative is not None
)
),
Step.REQUIREMENTS: lambda self: self.domain_request.is_policy_acknowledged is not None,
Step.REVIEW: lambda self: self.domain_request.is_policy_acknowledged is not None,
}
PORTFOLIO_UNLOCKING_STEPS = {
PortfolioDomainRequestStep.REQUESTING_ENTITY: lambda self: self.domain_request.organization_name is not None,
PortfolioDomainRequestStep.CURRENT_SITES: lambda self: (
self.domain_request.current_websites.exists() or self.domain_request.requested_domain is not None
),
PortfolioDomainRequestStep.DOTGOV_DOMAIN: lambda self: self.domain_request.requested_domain is not None,
PortfolioDomainRequestStep.PURPOSE: lambda self: self.domain_request.purpose is not None,
PortfolioDomainRequestStep.ADDITIONAL_DETAILS: lambda self: self.domain_request.anything_else is not None,
PortfolioDomainRequestStep.REQUIREMENTS: lambda self: self.domain_request.is_policy_acknowledged is not None,
PortfolioDomainRequestStep.REVIEW: lambda self: self.domain_request.is_policy_acknowledged is not None,
}
# endregion
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.steps = StepsHelper(self) self.titles = {}
self.wizard_conditions = {}
self.unlocking_steps = {}
self.steps = None
# Configure titles, wizard_conditions, unlocking_steps, and steps
self.configure_step_options()
self._domain_request = None # for caching self._domain_request = None # for caching
def configure_step_options(self):
"""Changes which steps are available to the user based on self.is_portfolio.
This may change on the fly, so we need to evaluate it on the fly.
Using this information, we then set three configuration variables.
- self.titles => Returns the page titles for each step
- self.wizard_conditions => Conditionally shows / hides certain steps
- self.unlocking_steps => Determines what steps are locked/unlocked
Then, we create self.steps.
"""
if self.is_portfolio:
self.titles = self.PORTFOLIO_TITLES
self.wizard_conditions = self.PORTFOLIO_WIZARD_CONDITIONS
self.unlocking_steps = self.PORTFOLIO_UNLOCKING_STEPS
else:
self.titles = self.REGULAR_TITLES
self.wizard_conditions = self.REGULAR_WIZARD_CONDITIONS
self.unlocking_steps = self.REGULAR_UNLOCKING_STEPS
self.steps = StepsHelper(self)
def has_pk(self): def has_pk(self):
"""Does this wizard know about a DomainRequest database record?""" """Does this wizard know about a DomainRequest database record?"""
return "domain_request_id" in self.storage return "domain_request_id" in self.storage
def get_step_enum(self):
"""Determines which step enum we should use for the wizard"""
return PortfolioDomainRequestStep if self.is_portfolio else Step
@property @property
def prefix(self): def prefix(self):
"""Namespace the wizard to avoid clashes in session variable names.""" """Namespace the wizard to avoid clashes in session variable names."""
@ -128,10 +226,16 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
# If a user is creating a request, we assume that perms are handled upstream # If a user is creating a request, we assume that perms are handled upstream
if self.request.user.is_org_user(self.request): if self.request.user.is_org_user(self.request):
portfolio = self.request.session.get("portfolio")
self._domain_request = DomainRequest.objects.create( self._domain_request = DomainRequest.objects.create(
creator=self.request.user, creator=self.request.user,
portfolio=self.request.session.get("portfolio"), portfolio=portfolio,
) )
# Question for reviewers: we should probably be doing this right?
if portfolio and not self._domain_request.generic_org_type:
self._domain_request.generic_org_type = portfolio.organization_type
self._domain_request.save()
else: else:
self._domain_request = DomainRequest.objects.create(creator=self.request.user) self._domain_request = DomainRequest.objects.create(creator=self.request.user)
@ -190,6 +294,12 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
"""This method handles GET requests.""" """This method handles GET requests."""
if not self.is_portfolio and self.request.user.is_org_user(request):
self.is_portfolio = True
# Configure titles, wizard_conditions, unlocking_steps, and steps
self.configure_step_options()
current_url = resolve(request.path_info).url_name current_url = resolve(request.path_info).url_name
# if user visited via an "edit" url, associate the id of the # if user visited via an "edit" url, associate the id of the
@ -209,7 +319,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
# Clear context so the prop getter won't create a request here. # Clear context so the prop getter won't create a request here.
# Creating a request will be handled in the post method for the # Creating a request will be handled in the post method for the
# intro page. # intro page.
return render(request, "domain_request_intro.html", {}) return render(request, "domain_request_intro.html", {"hide_requests": True, "hide_domains": True})
else: else:
return self.goto(self.steps.first) return self.goto(self.steps.first)
@ -321,56 +431,9 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
return DomainRequest.objects.filter(creator=self.request.user, status__in=check_statuses) return DomainRequest.objects.filter(creator=self.request.user, status__in=check_statuses)
def db_check_for_unlocking_steps(self): def db_check_for_unlocking_steps(self):
"""Helper for get_context_data """Helper for get_context_data.
Queries the DB for a domain request and returns a list of unlocked steps.""" Queries the DB for a domain request and returns a list of unlocked steps."""
return [key for key, is_unlocked_checker in self.unlocking_steps.items() if is_unlocked_checker(self)]
# The way this works is as follows:
# Each step is assigned a true/false value to determine if it is
# "unlocked" or not. This dictionary of values is looped through
# at the end of this function and any step with a "true" value is
# added to a simple array that is returned at the end of this function.
# This array is eventually passed to the frontend context (eg. domain_request_sidebar.html),
# and is used to determine how steps appear in the side nav.
# It is worth noting that any step assigned "false" here will be EXCLUDED
# from the list of "unlocked" steps.
history_dict = {
"generic_org_type": self.domain_request.generic_org_type is not None,
"tribal_government": self.domain_request.tribe_name is not None,
"organization_federal": self.domain_request.federal_type is not None,
"organization_election": self.domain_request.is_election_board is not None,
"organization_contact": (
self.domain_request.federal_agency is not None
or self.domain_request.organization_name is not None
or self.domain_request.address_line1 is not None
or self.domain_request.city is not None
or self.domain_request.state_territory is not None
or self.domain_request.zipcode is not None
or self.domain_request.urbanization is not None
),
"about_your_organization": self.domain_request.about_your_organization is not None,
"senior_official": self.domain_request.senior_official is not None,
"current_sites": (
self.domain_request.current_websites.exists() or self.domain_request.requested_domain is not None
),
"dotgov_domain": self.domain_request.requested_domain is not None,
"purpose": self.domain_request.purpose is not None,
"other_contacts": (
self.domain_request.other_contacts.exists()
or self.domain_request.no_other_contacts_rationale is not None
),
"additional_details": (
# Additional details is complete as long as "has anything else" and "has cisa rep" are not None
(
self.domain_request.has_anything_else_text is not None
and self.domain_request.has_cisa_representative is not None
)
),
"requirements": self.domain_request.is_policy_acknowledged is not None,
"review": self.domain_request.is_policy_acknowledged is not None,
}
return [key for key, value in history_dict.items() if value]
def get_context_data(self): def get_context_data(self):
"""Define context for access on all wizard pages.""" """Define context for access on all wizard pages."""
@ -380,17 +443,22 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
requested_domain_name = self.domain_request.requested_domain.name requested_domain_name = self.domain_request.requested_domain.name
context_stuff = {} context_stuff = {}
if DomainRequest._form_complete(self.domain_request, self.request):
# Note: we will want to consolidate the non_org_steps_complete check into the same check that
# org_steps_complete is using at some point.
non_org_steps_complete = DomainRequest._form_complete(self.domain_request, self.request)
org_steps_complete = len(self.db_check_for_unlocking_steps()) == len(self.steps)
if (not self.is_portfolio and non_org_steps_complete) or (self.is_portfolio and org_steps_complete):
modal_button = '<button type="submit" ' 'class="usa-button" ' ">Submit request</button>" modal_button = '<button type="submit" ' 'class="usa-button" ' ">Submit request</button>"
context_stuff = { context_stuff = {
"not_form": False, "not_form": False,
"form_titles": self.TITLES, "form_titles": self.titles,
"steps": self.steps, "steps": self.steps,
"visited": self.storage.get("step_history", []), "visited": self.storage.get("step_history", []),
"is_federal": self.domain_request.is_federal(), "is_federal": self.domain_request.is_federal(),
"modal_button": modal_button, "modal_button": modal_button,
"modal_heading": "You are about to submit a domain request for " "modal_heading": "You are about to submit a domain request for ",
+ str(self.domain_request.requested_domain), "domain_name_modal": str(self.domain_request.requested_domain),
"modal_description": "Once you submit this request, you wont be able to edit it until we review it.\ "modal_description": "Once you submit this request, you wont be able to edit it until we review it.\
Youll only be able to withdraw your request.", Youll only be able to withdraw your request.",
"review_form_is_complete": True, "review_form_is_complete": True,
@ -401,7 +469,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
modal_button = '<button type="button" class="usa-button" data-close-modal>Return to request</button>' modal_button = '<button type="button" class="usa-button" data-close-modal>Return to request</button>'
context_stuff = { context_stuff = {
"not_form": True, "not_form": True,
"form_titles": self.TITLES, "form_titles": self.titles,
"steps": self.steps, "steps": self.steps,
"visited": self.storage.get("step_history", []), "visited": self.storage.get("step_history", []),
"is_federal": self.domain_request.is_federal(), "is_federal": self.domain_request.is_federal(),
@ -413,14 +481,19 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
"user": self.request.user, "user": self.request.user,
"requested_domain__name": requested_domain_name, "requested_domain__name": requested_domain_name,
} }
# Hides the requests and domains buttons in the navbar
context_stuff["hide_requests"] = self.is_portfolio
context_stuff["hide_domains"] = self.is_portfolio
return context_stuff return context_stuff
def get_step_list(self) -> list: def get_step_list(self) -> list:
"""Dynamically generated list of steps in the form wizard.""" """Dynamically generated list of steps in the form wizard."""
return request_step_list(self) return request_step_list(self, self.get_step_enum())
def goto(self, step): def goto(self, step):
if step == "generic_org_type": if step == "generic_org_type" or step == "portfolio_requesting_entity":
# We need to avoid creating a new domain request if the user # We need to avoid creating a new domain request if the user
# clicks the back button # clicks the back button
self.request.session["new_request"] = False self.request.session["new_request"] = False
@ -443,21 +516,21 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
def post(self, request, *args, **kwargs) -> HttpResponse: def post(self, request, *args, **kwargs) -> HttpResponse:
"""This method handles POST requests.""" """This method handles POST requests."""
if not self.is_portfolio and self.request.user.is_org_user(request): # type: ignore
self.is_portfolio = True
# Configure titles, wizard_conditions, unlocking_steps, and steps
self.configure_step_options()
# which button did the user press? # which button did the user press?
button: str = request.POST.get("submit_button", "") button: str = request.POST.get("submit_button", "")
# If a user hits the new request url directly
if "new_request" not in request.session: if "new_request" not in request.session:
request.session["new_request"] = True request.session["new_request"] = True
# if user has acknowledged the intro message # if user has acknowledged the intro message
if button == "intro_acknowledge": if button == "intro_acknowledge":
if request.path_info == self.NEW_URL_NAME: # Split into a function: C901 'DomainRequestWizard.post' is too complex (11)
self.handle_intro_acknowledge(request)
if self.request.session["new_request"] is True:
# This will trigger the domain_request getter into creating a new DomainRequest
del self.storage
return self.goto(self.steps.first)
# if accessing this class directly, redirect to the first step # if accessing this class directly, redirect to the first step
if self.__class__ == DomainRequestWizard: if self.__class__ == DomainRequestWizard:
@ -488,6 +561,14 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
# otherwise, proceed as normal # otherwise, proceed as normal
return self.goto_next_step() return self.goto_next_step()
def handle_intro_acknowledge(self, request):
"""If we are starting a new request, clear storage
and redirect to the first step"""
if request.path_info == self.NEW_URL_NAME:
if self.request.session["new_request"] is True:
del self.storage
return self.goto(self.steps.first)
def save(self, forms: list): def save(self, forms: list):
""" """
Unpack the form responses onto the model object properties. Unpack the form responses onto the model object properties.
@ -501,24 +582,22 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
# TODO - this is a WIP until the domain request experience for portfolios is complete # TODO - this is a WIP until the domain request experience for portfolios is complete
class PortfolioDomainRequestWizard(DomainRequestWizard): class PortfolioDomainRequestWizard(DomainRequestWizard):
StepEnum: PortfolioDomainRequestStep = PortfolioDomainRequestStep # type: ignore is_portfolio = True
TITLES = {
StepEnum.REQUESTING_ENTITY: _("Requesting entity"),
StepEnum.CURRENT_SITES: _("Current websites"),
StepEnum.DOTGOV_DOMAIN: _(".gov domain"),
StepEnum.PURPOSE: _("Purpose of your domain"),
StepEnum.ADDITIONAL_DETAILS: _("Additional details"),
StepEnum.REQUIREMENTS: _("Requirements for operating a .gov domain"),
# Step.REVIEW: _("Review and submit your domain request"),
}
def __init__(self):
super().__init__()
self.steps = StepsHelper(self)
self._domain_request = None # for caching
# Portfolio pages
class RequestingEntity(DomainRequestWizard):
template_name = "domain_request_requesting_entity.html"
forms = [forms.RequestingEntityForm]
class PortfolioAdditionalDetails(DomainRequestWizard):
template_name = "portfolio_domain_request_additional_details.html"
forms = [forms.AnythingElseForm]
# Non-portfolio pages
class OrganizationType(DomainRequestWizard): class OrganizationType(DomainRequestWizard):
template_name = "domain_request_org_type.html" template_name = "domain_request_org_type.html"
forms = [forms.OrganizationTypeForm] forms = [forms.OrganizationTypeForm]
@ -698,7 +777,7 @@ class Review(DomainRequestWizard):
if DomainRequest._form_complete(self.domain_request, self.request) is False: if DomainRequest._form_complete(self.domain_request, self.request) is False:
logger.warning("User arrived at review page with an incomplete form.") logger.warning("User arrived at review page with an incomplete form.")
context = super().get_context_data() context = super().get_context_data()
context["Step"] = Step.__members__ context["Step"] = self.get_step_enum().__members__
context["domain_request"] = self.domain_request context["domain_request"] = self.domain_request
return context return context
@ -899,9 +978,9 @@ class PortfolioDomainRequestStatusViewOnly(DomainRequestPortfolioViewonlyView):
# Create a temp wizard object to grab the step list # Create a temp wizard object to grab the step list
wizard = PortfolioDomainRequestWizard() wizard = PortfolioDomainRequestWizard()
wizard.request = self.request wizard.request = self.request
context["Step"] = wizard.StepEnum.__members__ context["Step"] = PortfolioDomainRequestStep.__members__
context["steps"] = request_step_list(wizard) context["steps"] = request_step_list(wizard, PortfolioDomainRequestStep)
context["form_titles"] = wizard.TITLES context["form_titles"] = wizard.titles
return context return context

View file

@ -1,45 +1,41 @@
from django.http import JsonResponse from django.http import JsonResponse
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db.models import Q from django.db.models import Value, F, CharField, TextField, Q, Case, When
from django.db.models.functions import Concat, Coalesce
from django.urls import reverse
from django.db.models.functions import Cast
from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.user import User
from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
@login_required @login_required
def get_portfolio_members_json(request): def get_portfolio_members_json(request):
"""Given the current request, """Fetch members (permissions and invitations) for the given portfolio."""
get all members that are associated with the given portfolio"""
portfolio = request.GET.get("portfolio") portfolio = request.GET.get("portfolio")
member_ids = get_member_ids_from_request(request, portfolio)
objects = User.objects.filter(id__in=member_ids)
admin_ids = UserPortfolioPermission.objects.filter( # Two initial querysets which will be combined
portfolio=portfolio, permissions = initial_permissions_search(portfolio)
roles__overlap=[ invitations = initial_invitations_search(portfolio)
UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
],
).values_list("user__id", flat=True)
portfolio_invitation_emails = PortfolioInvitation.objects.filter(portfolio=portfolio).values_list(
"email", flat=True
)
unfiltered_total = objects.count() # Get total across both querysets before applying filters
unfiltered_total = permissions.count() + invitations.count()
objects = apply_search(objects, request) permissions = apply_search_term(permissions, request)
# objects = apply_status_filter(objects, request) invitations = apply_search_term(invitations, request)
# Union the two querysets
objects = permissions.union(invitations)
objects = apply_sorting(objects, request) objects = apply_sorting(objects, request)
paginator = Paginator(objects, 10) paginator = Paginator(objects, 10)
page_number = request.GET.get("page", 1) page_number = request.GET.get("page", 1)
page_obj = paginator.get_page(page_number) page_obj = paginator.get_page(page_number)
members = [
serialize_members(request, portfolio, member, request.user, admin_ids, portfolio_invitation_emails) members = [serialize_members(request, portfolio, item, request.user) for item in page_obj.object_list]
for member in page_obj.object_list
]
return JsonResponse( return JsonResponse(
{ {
@ -54,71 +50,121 @@ def get_portfolio_members_json(request):
) )
def get_member_ids_from_request(request, portfolio): def initial_permissions_search(portfolio):
"""Given the current request, """Perform initial search for permissions before applying any filters."""
get all members that are associated with the given portfolio""" permissions = UserPortfolioPermission.objects.filter(portfolio=portfolio)
member_ids = [] permissions = (
if portfolio: permissions.select_related("user")
member_ids = UserPortfolioPermission.objects.filter(portfolio=portfolio).values_list("user__id", flat=True) .annotate(
return member_ids first_name=F("user__first_name"),
last_name=F("user__last_name"),
email_display=F("user__email"),
last_active=Cast(F("user__last_login"), output_field=TextField()), # Cast last_login to text
additional_permissions_display=F("additional_permissions"),
member_display=Case(
# If email is present and not blank, use email
When(Q(user__email__isnull=False) & ~Q(user__email=""), then=F("user__email")),
# If first name or last name is present, use concatenation of first_name + " " + last_name
When(
Q(user__first_name__isnull=False) | Q(user__last_name__isnull=False),
then=Concat(
Coalesce(F("user__first_name"), Value("")),
Value(" "),
Coalesce(F("user__last_name"), Value("")),
),
),
# If neither, use an empty string
default=Value(""),
output_field=CharField(),
),
source=Value("permission", output_field=CharField()),
)
.values(
"id",
"first_name",
"last_name",
"email_display",
"last_active",
"roles",
"additional_permissions_display",
"member_display",
"source",
)
)
return permissions
def apply_search(queryset, request): def initial_invitations_search(portfolio):
search_term = request.GET.get("search_term") """Perform initial invitations search before applying any filters."""
invitations = PortfolioInvitation.objects.filter(portfolio=portfolio)
invitations = invitations.annotate(
first_name=Value(None, output_field=CharField()),
last_name=Value(None, output_field=CharField()),
email_display=F("email"),
last_active=Value("Invited", output_field=TextField()),
additional_permissions_display=F("additional_permissions"),
member_display=F("email"),
source=Value("invitation", output_field=CharField()),
).values(
"id",
"first_name",
"last_name",
"email_display",
"last_active",
"roles",
"additional_permissions_display",
"member_display",
"source",
)
return invitations
def apply_search_term(queryset, request):
"""Apply search term to the queryset."""
search_term = request.GET.get("search_term", "").lower()
if search_term: if search_term:
queryset = queryset.filter( queryset = queryset.filter(
Q(username__icontains=search_term) Q(first_name__icontains=search_term)
| Q(first_name__icontains=search_term)
| Q(last_name__icontains=search_term) | Q(last_name__icontains=search_term)
| Q(email__icontains=search_term) | Q(email_display__icontains=search_term)
) )
return queryset return queryset
def apply_sorting(queryset, request): def apply_sorting(queryset, request):
"""Apply sorting to the queryset."""
sort_by = request.GET.get("sort_by", "id") # Default to 'id' sort_by = request.GET.get("sort_by", "id") # Default to 'id'
order = request.GET.get("order", "asc") # Default to 'asc' order = request.GET.get("order", "asc") # Default to 'asc'
# Adjust sort_by to match the annotated fields in the unioned queryset
if sort_by == "member": if sort_by == "member":
sort_by = ["email", "first_name", "middle_name", "last_name"] sort_by = "member_display"
else:
sort_by = [sort_by]
if order == "desc": if order == "desc":
sort_by = [f"-{field}" for field in sort_by] queryset = queryset.order_by(F(sort_by).desc())
else:
return queryset.order_by(*sort_by) queryset = queryset.order_by(sort_by)
return queryset
def serialize_members(request, portfolio, member, user, admin_ids, portfolio_invitation_emails): def serialize_members(request, portfolio, item, user):
# ------- VIEW ONLY # Check if the user can edit other users
# If not view_only (the user has permissions to edit/manage users), show the gear icon with "Manage" link. user_can_edit_other_users = any(
# If view_only (the user only has view user permissions), show the "View" link (no gear icon). user.has_perm(perm) for perm in ["registrar.full_access_permission", "registrar.change_user"]
# We check on user_group_permision to account for the upcoming "Manage portfolio" button on admin. )
user_can_edit_other_users = False
for user_group_permission in ["registrar.full_access_permission", "registrar.change_user"]:
if user.has_perm(user_group_permission):
user_can_edit_other_users = True
break
view_only = not user.has_edit_members_portfolio_permission(portfolio) or not user_can_edit_other_users view_only = not user.has_edit_members_portfolio_permission(portfolio) or not user_can_edit_other_users
# ------- USER STATUSES is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in (item.get("roles") or [])
is_invited = member.email in portfolio_invitation_emails action_url = reverse("member" if item["source"] == "permission" else "invitedmember", kwargs={"pk": item["id"]})
last_active = "Invited" if is_invited else "Unknown"
if member.last_login:
last_active = member.last_login.strftime("%b. %d, %Y")
is_admin = member.id in admin_ids
# ------- SERIALIZE # Serialize member data
member_json = { member_json = {
"id": member.id, "id": item.get("id", ""),
"name": member.get_formatted_name(), "name": " ".join(filter(None, [item.get("first_name", ""), item.get("last_name", "")])),
"email": member.email, "email": item.get("email_display", ""),
"member_display": item.get("member_display", ""),
"is_admin": is_admin, "is_admin": is_admin,
"last_active": last_active, "last_active": item.get("last_active", ""),
"action_url": "#", # reverse("members", kwargs={"pk": member.id}), # TODO: Future ticket? "action_url": action_url,
"action_label": ("View" if view_only else "Manage"), "action_label": ("View" if view_only else "Manage"),
"svg_icon": ("visibility" if view_only else "settings"), "svg_icon": ("visibility" if view_only else "settings"),
} }

View file

@ -3,20 +3,30 @@ from django.http import Http404
from django.shortcuts import render from django.shortcuts import render
from django.urls import reverse from django.urls import reverse
from django.contrib import messages from django.contrib import messages
from registrar.forms.portfolio import PortfolioOrgAddressForm, PortfolioSeniorOfficialForm from registrar.forms.portfolio import (
PortfolioInvitedMemberForm,
PortfolioMemberForm,
PortfolioOrgAddressForm,
PortfolioSeniorOfficialForm,
)
from registrar.models import Portfolio, User from registrar.models import Portfolio, User
from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.views.utility.permission_views import ( from registrar.views.utility.permission_views import (
PortfolioDomainRequestsPermissionView, PortfolioDomainRequestsPermissionView,
PortfolioDomainsPermissionView, PortfolioDomainsPermissionView,
PortfolioBasePermissionView, PortfolioBasePermissionView,
NoPortfolioDomainsPermissionView, NoPortfolioDomainsPermissionView,
PortfolioInvitedMemberEditPermissionView,
PortfolioInvitedMemberPermissionView,
PortfolioMemberEditPermissionView,
PortfolioMemberPermissionView,
PortfolioMembersPermissionView, PortfolioMembersPermissionView,
) )
from django.views.generic import View from django.views.generic import View
from django.views.generic.edit import FormMixin from django.views.generic.edit import FormMixin
from django.shortcuts import get_object_or_404, redirect
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -51,6 +61,155 @@ class PortfolioMembersView(PortfolioMembersPermissionView, View):
return render(request, "portfolio_members.html") return render(request, "portfolio_members.html")
class PortfolioMemberView(PortfolioMemberPermissionView, View):
template_name = "portfolio_member.html"
def get(self, request, pk):
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
member = portfolio_permission.user
# We have to explicitely name these with member_ otherwise we'll have conflicts with context preprocessors
member_has_view_all_requests_portfolio_permission = member.has_view_all_requests_portfolio_permission(
portfolio_permission.portfolio
)
member_has_edit_request_portfolio_permission = member.has_edit_request_portfolio_permission(
portfolio_permission.portfolio
)
member_has_view_members_portfolio_permission = member.has_view_members_portfolio_permission(
portfolio_permission.portfolio
)
member_has_edit_members_portfolio_permission = member.has_edit_members_portfolio_permission(
portfolio_permission.portfolio
)
return render(
request,
self.template_name,
{
"edit_url": reverse("member-permissions", args=[pk]),
"portfolio_permission": portfolio_permission,
"member": member,
"member_has_view_all_requests_portfolio_permission": member_has_view_all_requests_portfolio_permission,
"member_has_edit_request_portfolio_permission": member_has_edit_request_portfolio_permission,
"member_has_view_members_portfolio_permission": member_has_view_members_portfolio_permission,
"member_has_edit_members_portfolio_permission": member_has_edit_members_portfolio_permission,
},
)
class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View):
template_name = "portfolio_member_permissions.html"
form_class = PortfolioMemberForm
def get(self, request, pk):
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
user = portfolio_permission.user
form = self.form_class(instance=portfolio_permission)
return render(
request,
self.template_name,
{
"form": form,
"member": user,
},
)
def post(self, request, pk):
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
user = portfolio_permission.user
form = self.form_class(request.POST, instance=portfolio_permission)
if form.is_valid():
form.save()
return redirect("member", pk=pk)
return render(
request,
self.template_name,
{
"form": form,
"member": user, # Pass the user object again to the template
},
)
class PortfolioInvitedMemberView(PortfolioInvitedMemberPermissionView, View):
template_name = "portfolio_member.html"
# form_class = PortfolioInvitedMemberForm
def get(self, request, pk):
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
# form = self.form_class(instance=portfolio_invitation)
# We have to explicitely name these with member_ otherwise we'll have conflicts with context preprocessors
member_has_view_all_requests_portfolio_permission = (
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS in portfolio_invitation.get_portfolio_permissions()
)
member_has_edit_request_portfolio_permission = (
UserPortfolioPermissionChoices.EDIT_REQUESTS in portfolio_invitation.get_portfolio_permissions()
)
member_has_view_members_portfolio_permission = (
UserPortfolioPermissionChoices.VIEW_MEMBERS in portfolio_invitation.get_portfolio_permissions()
)
member_has_edit_members_portfolio_permission = (
UserPortfolioPermissionChoices.EDIT_MEMBERS in portfolio_invitation.get_portfolio_permissions()
)
return render(
request,
self.template_name,
{
"edit_url": reverse("invitedmember-permissions", args=[pk]),
"portfolio_invitation": portfolio_invitation,
"member_has_view_all_requests_portfolio_permission": member_has_view_all_requests_portfolio_permission,
"member_has_edit_request_portfolio_permission": member_has_edit_request_portfolio_permission,
"member_has_view_members_portfolio_permission": member_has_view_members_portfolio_permission,
"member_has_edit_members_portfolio_permission": member_has_edit_members_portfolio_permission,
},
)
class PortfolioInvitedMemberEditView(PortfolioInvitedMemberEditPermissionView, View):
template_name = "portfolio_member_permissions.html"
form_class = PortfolioInvitedMemberForm
def get(self, request, pk):
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
form = self.form_class(instance=portfolio_invitation)
return render(
request,
self.template_name,
{
"form": form,
"invitation": portfolio_invitation,
},
)
def post(self, request, pk):
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
form = self.form_class(request.POST, instance=portfolio_invitation)
if form.is_valid():
form.save()
return redirect("invitedmember", pk=pk)
return render(
request,
self.template_name,
{
"form": form,
"invitation": portfolio_invitation, # Pass the user object again to the template
},
)
class PortfolioNoDomainsView(NoPortfolioDomainsPermissionView, View): class PortfolioNoDomainsView(NoPortfolioDomainsPermissionView, View):
"""Some users have access to the underlying portfolio, but not any domains. """Some users have access to the underlying portfolio, but not any domains.
This is a custom view which explains that to the user - and denotes who to contact. This is a custom view which explains that to the user - and denotes who to contact.

View file

@ -169,6 +169,17 @@ class ExportDataTypeUser(View):
return response return response
class ExportDataTypeRequests(View):
"""Returns a domain requests report for a given user on the request"""
def get(self, request, *args, **kwargs):
response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = 'attachment; filename="domain-requests.csv"'
csv_export.DomainRequestsDataType.exporting_dr_data_to_csv(response, request=request)
return response
class ExportDataFull(View): class ExportDataFull(View):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
# Smaller export based on 1 # Smaller export based on 1

View file

@ -4,7 +4,7 @@ from django.forms.models import model_to_dict
from registrar.models import FederalAgency, SeniorOfficial, DomainRequest from registrar.models import FederalAgency, SeniorOfficial, DomainRequest
from django.contrib.admin.views.decorators import staff_member_required from django.contrib.admin.views.decorators import staff_member_required
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from registrar.utility.admin_helpers import get_all_action_needed_reason_emails from registrar.utility.admin_helpers import get_action_needed_reason_default_email, get_rejection_reason_default_email
from registrar.models.portfolio import Portfolio from registrar.models.portfolio import Portfolio
from registrar.utility.constants import BranchChoices from registrar.utility.constants import BranchChoices
@ -88,5 +88,30 @@ def get_action_needed_email_for_user_json(request):
return JsonResponse({"error": "No domain_request_id specified"}, status=404) return JsonResponse({"error": "No domain_request_id specified"}, status=404)
domain_request = DomainRequest.objects.filter(id=domain_request_id).first() domain_request = DomainRequest.objects.filter(id=domain_request_id).first()
emails = get_all_action_needed_reason_emails(request, domain_request)
return JsonResponse({"action_needed_email": emails.get(reason)}, status=200) email = get_action_needed_reason_default_email(domain_request, reason)
return JsonResponse({"email": email}, status=200)
@login_required
@staff_member_required
def get_rejection_email_for_user_json(request):
"""Returns a default rejection email for a given user"""
# This API is only accessible to admins and analysts
superuser_perm = request.user.has_perm("registrar.full_access_permission")
analyst_perm = request.user.has_perm("registrar.analyst_access_permission")
if not request.user.is_authenticated or not any([analyst_perm, superuser_perm]):
return JsonResponse({"error": "You do not have access to this resource"}, status=403)
reason = request.GET.get("reason")
domain_request_id = request.GET.get("domain_request_id")
if not reason:
return JsonResponse({"error": "No reason specified"}, status=404)
if not domain_request_id:
return JsonResponse({"error": "No domain_request_id specified"}, status=404)
domain_request = DomainRequest.objects.filter(id=domain_request_id).first()
email = get_rejection_reason_default_email(domain_request, reason)
return JsonResponse({"email": email}, status=200)

View file

@ -384,10 +384,32 @@ class DomainRequestWizardPermission(PermissionsLoginMixin):
The user is in self.request.user The user is in self.request.user
""" """
if not self.request.user.is_authenticated:
return False
# The user has an ineligible flag # The user has an ineligible flag
if self.request.user.is_restricted(): if self.request.user.is_restricted():
return False return False
# If the user is an org user and doesn't have add/edit perms, forbid this
if self.request.user.is_org_user(self.request):
portfolio = self.request.session.get("portfolio")
if not self.request.user.has_edit_request_portfolio_permission(portfolio):
return False
# user needs to be the creator of the domain request to edit it.
id = self.kwargs.get("id") if hasattr(self, "kwargs") else None
if not id:
domain_request_wizard = self.request.session.get("wizard_domain_request")
if domain_request_wizard:
id = domain_request_wizard.get("domain_request_id")
# If no id is provided, we can assume that the user is starting a new request.
# If one IS provided, check that they are the original creator of it.
if id:
if not DomainRequest.objects.filter(creator=self.request.user, id=id).exists():
return False
return True return True
@ -490,7 +512,81 @@ class PortfolioMembersPermission(PortfolioBasePermission):
up from the portfolio's primary key in self.kwargs["pk"]""" up from the portfolio's primary key in self.kwargs["pk"]"""
portfolio = self.request.session.get("portfolio") portfolio = self.request.session.get("portfolio")
if not self.request.user.has_view_members_portfolio_permission(portfolio): if not self.request.user.has_view_members_portfolio_permission(
portfolio
) and not self.request.user.has_edit_members_portfolio_permission(portfolio):
return False
return super().has_permission()
class PortfolioMemberPermission(PortfolioBasePermission):
"""Permission mixin that allows access to portfolio member pages if user
has access, otherwise 403"""
def has_permission(self):
"""Check if this user has access to members for this portfolio.
The user is in self.request.user and the portfolio can be looked
up from the portfolio's primary key in self.kwargs["pk"]"""
portfolio = self.request.session.get("portfolio")
if not self.request.user.has_view_members_portfolio_permission(
portfolio
) and not self.request.user.has_edit_members_portfolio_permission(portfolio):
return False
return super().has_permission()
class PortfolioMemberEditPermission(PortfolioBasePermission):
"""Permission mixin that allows access to portfolio member pages if user
has access to edit, otherwise 403"""
def has_permission(self):
"""Check if this user has access to members for this portfolio.
The user is in self.request.user and the portfolio can be looked
up from the portfolio's primary key in self.kwargs["pk"]"""
portfolio = self.request.session.get("portfolio")
if not self.request.user.has_edit_members_portfolio_permission(portfolio):
return False
return super().has_permission()
class PortfolioInvitedMemberPermission(PortfolioBasePermission):
"""Permission mixin that allows access to portfolio invited member pages if user
has access, otherwise 403"""
def has_permission(self):
"""Check if this user has access to members for this portfolio.
The user is in self.request.user and the portfolio can be looked
up from the portfolio's primary key in self.kwargs["pk"]"""
portfolio = self.request.session.get("portfolio")
if not self.request.user.has_view_members_portfolio_permission(
portfolio
) and not self.request.user.has_edit_members_portfolio_permission(portfolio):
return False
return super().has_permission()
class PortfolioInvitedMemberEditPermission(PortfolioBasePermission):
"""Permission mixin that allows access to portfolio invited member pages if user
has access to edit, otherwise 403"""
def has_permission(self):
"""Check if this user has access to members for this portfolio.
The user is in self.request.user and the portfolio can be looked
up from the portfolio's primary key in self.kwargs["pk"]"""
portfolio = self.request.session.get("portfolio")
if not self.request.user.has_edit_members_portfolio_permission(portfolio):
return False return False
return super().has_permission() return super().has_permission()

View file

@ -15,10 +15,14 @@ from .mixins import (
DomainRequestWizardPermission, DomainRequestWizardPermission,
PortfolioDomainRequestsPermission, PortfolioDomainRequestsPermission,
PortfolioDomainsPermission, PortfolioDomainsPermission,
PortfolioInvitedMemberEditPermission,
PortfolioInvitedMemberPermission,
PortfolioMemberEditPermission,
UserDeleteDomainRolePermission, UserDeleteDomainRolePermission,
UserProfilePermission, UserProfilePermission,
PortfolioBasePermission, PortfolioBasePermission,
PortfolioMembersPermission, PortfolioMembersPermission,
PortfolioMemberPermission,
DomainRequestPortfolioViewonlyPermission, DomainRequestPortfolioViewonlyPermission,
) )
import logging import logging
@ -253,7 +257,41 @@ class PortfolioDomainRequestsPermissionView(PortfolioDomainRequestsPermission, P
class PortfolioMembersPermissionView(PortfolioMembersPermission, PortfolioBasePermissionView, abc.ABC): class PortfolioMembersPermissionView(PortfolioMembersPermission, PortfolioBasePermissionView, abc.ABC):
"""Abstract base view for portfolio domain request views that enforces permissions. """Abstract base view for portfolio members views that enforces permissions.
This abstract view cannot be instantiated. Actual views must specify
`template_name`.
"""
class PortfolioMemberPermissionView(PortfolioMemberPermission, PortfolioBasePermissionView, abc.ABC):
"""Abstract base view for portfolio member views that enforces permissions.
This abstract view cannot be instantiated. Actual views must specify
`template_name`.
"""
class PortfolioMemberEditPermissionView(PortfolioMemberEditPermission, PortfolioBasePermissionView, abc.ABC):
"""Abstract base view for portfolio member edit views that enforces permissions.
This abstract view cannot be instantiated. Actual views must specify
`template_name`.
"""
class PortfolioInvitedMemberPermissionView(PortfolioInvitedMemberPermission, PortfolioBasePermissionView, abc.ABC):
"""Abstract base view for portfolio member views that enforces permissions.
This abstract view cannot be instantiated. Actual views must specify
`template_name`.
"""
class PortfolioInvitedMemberEditPermissionView(
PortfolioInvitedMemberEditPermission, PortfolioBasePermissionView, abc.ABC
):
"""Abstract base view for portfolio member edit views that enforces permissions.
This abstract view cannot be instantiated. Actual views must specify This abstract view cannot be instantiated. Actual views must specify
`template_name`. `template_name`.

View file

@ -71,6 +71,7 @@
10038 OUTOFSCOPE http://app:8080/domain_requests/ 10038 OUTOFSCOPE http://app:8080/domain_requests/
10038 OUTOFSCOPE http://app:8080/domains/ 10038 OUTOFSCOPE http://app:8080/domains/
10038 OUTOFSCOPE http://app:8080/organization/ 10038 OUTOFSCOPE http://app:8080/organization/
10038 OUTOFSCOPE http://app:8080/permissions
10038 OUTOFSCOPE http://app:8080/suborganization/ 10038 OUTOFSCOPE http://app:8080/suborganization/
10038 OUTOFSCOPE http://app:8080/transfer/ 10038 OUTOFSCOPE http://app:8080/transfer/
# This URL always returns 404, so include it as well. # This URL always returns 404, so include it as well.