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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -137,29 +137,28 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% block field_other %}
{% if field.field.name == "action_needed_reason_email" %}
{{ field.field }}
<div class="margin-top-05 text-faded field-action_needed_reason_email__placeholder">
<div class="margin-top-05 text-faded custom-email-placeholder">
&ndash;
</div>
{{ field.field }}
<button
aria-label="Edit email in textarea"
type="button"
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text margin-left-1 text-no-underline field-action_needed_reason_email__edit flex-align-self-start"
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text margin-left-1 text-no-underline flex-align-self-start edit-email-button"
><img src="/public/admin/img/icon-changelink.svg" alt="Change"> Edit email</button
>
<a
href="#email-already-sent-modal"
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text text-no-underline margin-left-1 field-action_needed_reason_email__modal-trigger flex-align-self-start"
aria-controls="email-already-sent-modal"
href="#action-needed-email-already-sent-modal"
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text text-no-underline margin-left-1 edit-button-modal-trigger flex-align-self-start"
aria-controls="action-needed-email-already-sent-modal"
data-open-modal
><img src="/public/admin/img/icon-changelink.svg" alt="Change"> Edit email</a
>
<div
class="usa-modal"
id="email-already-sent-modal"
id="action-needed-email-already-sent-modal"
aria-labelledby="Are you sure you want to edit this email?"
aria-describedby="The creator of this request already received an email"
>
@ -187,8 +186,8 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
<li class="usa-button-group__item">
<button
type="submit"
id="action-needed-reason__confirm-edit-email"
class="usa-button"
id="confirm-edit-email"
data-close-modal
>
Yes, continue editing
@ -221,11 +220,99 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
</div>
{% if original_object.action_needed_reason_email %}
<input id="last-sent-email-content" class="display-none" value="{{original_object.action_needed_reason_email}}">
<input id="last-sent-action-needed-email-content" class="display-none" value="{{original_object.action_needed_reason_email}}">
{% else %}
<input id="last-sent-email-content" class="display-none" value="None">
<input id="last-sent-action-needed-email-content" class="display-none" value="None">
{% endif %}
{% elif field.field.name == "rejection_reason_email" %}
{{ field.field }}
<div class="margin-top-05 text-faded custom-email-placeholder">
&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 %}
{{ field.field }}
{% endif %}
@ -254,7 +341,7 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
<table>
<thead>
<tr>
<th colspan="4">Other contact information</th>
<th colspan="5">Other contact information</th>
<tr>
</thead>
<tbody>
@ -267,9 +354,20 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
</td>
<td class="padding-left-1">{{ contact.phone }}</td>
<td class="padding-left-1 text-size-small">
{% if contact.email %}
<input aria-hidden="true" class="display-none" value="{{ contact.email }}" />
<button
class="usa-button usa-button--unstyled padding-right-1 usa-button--icon button--clipboard copy-to-clipboard usa-button__small-text text-no-underline"
class="
usa-button--dja
usa-button
usa-button__small-text
usa-button--unstyled
padding-right-1
padding-top-0
padding-bottom-0
usa-button--icon
copy-to-clipboard
text-no-underline"
type="button"
>
<svg
@ -277,8 +375,10 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
>
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
</svg>
<!-- the span is targeted in JS, do not remove -->
<span>Copy email</span>
</button>
{% endif %}
</td>
</tr>
{% endfor %}

View file

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

View file

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

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.
{% 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>
{% endif %}
<p>Note that <strong>only federal agencies can request generic terms</strong> like
vote.gov.</p>

View file

@ -12,7 +12,11 @@
<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>
{% 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>
{% endif %}
<h2>Time to complete the form</h2>
<p>If you have <a href="{% public_site_url 'domains/before/#information-you%E2%80%99ll-need-to-complete-the-domain-request-form' %}" target="_blank" class="usa-link">all the information you need</a>,
completing your domain request might take around 15 minutes.</p>

View file

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

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 %}
{% block form_fields %}
{% if portfolio %}
{% include "includes/portfolio_request_review_steps.html" with is_editable=True %}
{% else %}
{% include "includes/request_review_steps.html" with is_editable=True %}
{% endif %}
{% endblock %}

View file

@ -11,8 +11,13 @@
<p>
The name of your suborganization will be publicly listed as the domain registrant.
This list of suborganizations has been populated the .gov program.
If you believe there is an error please contact <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
</p>
<p>
When this field is blank, the domain registrant will be listed as the overarching organization: {{ portfolio }}.
</p>
<p>
If you 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>
{% 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
----------------------------------------------------------------
{% if domain_request.rejection_reason != 'other' %}
REJECTION REASON{% endif %}{% if domain_request.rejection_reason == 'purpose_not_met' %}
{% if reason != domain_request.RejectionReasons.DOMAIN_PURPOSE.OTHER %}
REJECTION REASON{% endif %}{% if reason == domain_request.RejectionReasons.DOMAIN_PURPOSE %}
Your domain request was rejected because the purpose you provided did not meet our
requirements. You didnt provide enough information about how you intend to use the
domain.
@ -18,7 +18,7 @@ Learn more about:
- Eligibility for a .gov domain <https://get.gov/domains/eligibility>
- What you can and 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
.gov domain on behalf of {{ domain_request.organization_name }}. You must be a government employee, or be
working on behalf of a government organization, to request a .gov domain.
@ -26,7 +26,7 @@ working on behalf of a government organization, to request a .gov domain.
DEMONSTRATE ELIGIBILITY
If you can provide more information that demonstrates your eligibility, or you want to
discuss further, reply to this email.{% elif domain_request.rejection_reason == 'org_has_domain' %}
discuss further, reply to this email.{% elif reason == domain_request.RejectionReasons.DOMAIN_PURPOSE.ORG_HAS_DOMAIN %}
Your domain request was rejected because {{ domain_request.organization_name }} has a .gov domain. Our
practice is to approve one domain per online service per government organization. We
evaluate additional requests on a case-by-case basis. You did not provide sufficient
@ -35,9 +35,9 @@ justification for an additional domain.
Read more about our practice of approving one domain per online service
<https://get.gov/domains/before/#one-domain-per-service>.
If you have questions or comments, reply to this email.{% elif domain_request.rejection_reason == 'contacts_not_verified' %}
If you have questions or comments, reply to this email.{% elif reason == 'contacts_not_verified' %}
Your domain request was rejected because we could not verify the organizational
contacts you provided. If you have questions or comments, reply to this email.{% elif domain_request.rejection_reason == 'org_not_eligible' %}
contacts you provided. If you have questions or comments, reply to this email.{% elif reason == domain_request.RejectionReasons.DOMAIN_PURPOSE.ORG_NOT_ELIGIBLE %}
Your domain request was rejected because we determined that {{ domain_request.organization_name }} is not
eligible for a .gov domain. .Gov domains are only available to official U.S.-based
government organizations.
@ -46,7 +46,7 @@ Learn more about eligibility for .gov domains
<https://get.gov/domains/eligibility/>.
If you have questions or comments, reply to this email.
{% elif domain_request.rejection_reason == 'naming_not_met' %}
{% elif reason == domain_request.RejectionReasons.DOMAIN_PURPOSE.NAMING_REQUIREMENTS %}
Your domain request was rejected because it does not meet our naming requirements.
Domains should uniquely identify a government organization and be clear to the
general public. Learn more about naming requirements for your type of organization
@ -55,7 +55,7 @@ general public. Learn more about naming requirements for your type of organizati
YOU CAN SUBMIT A NEW REQUEST
We encourage you to request a domain that meets our requirements. If you have
questions or want to discuss potential domain names, reply to this email.{% elif domain_request.rejection_reason == 'other' %}
questions or want to discuss potential domain names, reply to this email.{% elif reason == domain_request.RejectionReasons.DOMAIN_PURPOSE.OTHER %}
YOU CAN SUBMIT A NEW REQUEST
If your organization is eligible for a .gov domain and you meet our other requirements, you can submit a new request.

View file

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

View file

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

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">
<h2 class="usa-modal__heading" id="modal-1-heading">
{{ modal_heading }}
{%if domain_name_modal is not None %}
<span class="domain-name-wrap">
{{ domain_name_modal }}
</span>
{%endif%}
{% if heading_value is not None %}
{# Add a breakpoint #}
<div aria-hidden="true"></div>

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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