Merge remote-tracking branch 'origin' into ab/remove-domain-request-endpoint

This commit is contained in:
Alysia Broddrick 2025-02-04 08:39:33 -08:00
commit 65910a5c04
No known key found for this signature in database
GPG key ID: 03917052CD0F06B7
44 changed files with 1598 additions and 506 deletions

View file

@ -21,49 +21,66 @@ class OpenIdConnectBackend(ModelBackend):
""" """
def authenticate(self, request, **kwargs): def authenticate(self, request, **kwargs):
logger.debug("kwargs %s" % kwargs) logger.debug("kwargs %s", kwargs)
user = None
if not kwargs or "sub" not in kwargs.keys(): if not kwargs or "sub" not in kwargs:
return user return None
UserModel = get_user_model() UserModel = get_user_model()
username = self.clean_username(kwargs["sub"]) username = self.clean_username(kwargs["sub"])
openid_data = self.extract_openid_data(kwargs)
# Some OP may actually choose to withhold some information, so we must
# test if it is present
openid_data = {"last_login": timezone.now()}
openid_data["first_name"] = kwargs.get("given_name", "")
openid_data["last_name"] = kwargs.get("family_name", "")
openid_data["email"] = kwargs.get("email", "")
openid_data["phone"] = kwargs.get("phone", "")
# Note that this could be accomplished in one try-except clause, but
# instead we use get_or_create when creating unknown users since it has
# built-in safeguards for multiple threads.
if getattr(settings, "OIDC_CREATE_UNKNOWN_USER", True): if getattr(settings, "OIDC_CREATE_UNKNOWN_USER", True):
args = { user = self.get_or_create_user(UserModel, username, openid_data, kwargs)
UserModel.USERNAME_FIELD: username,
# defaults _will_ be updated, these are not fallbacks
"defaults": openid_data,
}
user, created = UserModel.objects.get_or_create(**args)
if not created:
# If user exists, update existing user
self.update_existing_user(user, args["defaults"])
else:
# If user is created, configure the user
user = self.configure_user(user, **kwargs)
else: else:
try: user = self.get_user_by_username(UserModel, username)
user = UserModel.objects.get_by_natural_key(username)
except UserModel.DoesNotExist: if user:
return None user.on_each_login()
# run this callback for a each login
user.on_each_login()
return user return user
def extract_openid_data(self, kwargs):
"""Extract OpenID data from authentication kwargs."""
return {
"last_login": timezone.now(),
"first_name": kwargs.get("given_name", ""),
"last_name": kwargs.get("family_name", ""),
"email": kwargs.get("email", ""),
"phone": kwargs.get("phone", ""),
}
def get_or_create_user(self, UserModel, username, openid_data, kwargs):
"""Retrieve user by username or email, or create a new user."""
user = self.get_user_by_username(UserModel, username)
if not user and openid_data["email"]:
user = self.get_user_by_email(UserModel, openid_data["email"])
if user:
# if found by email, update the username
setattr(user, UserModel.USERNAME_FIELD, username)
if not user:
user = UserModel.objects.create(**{UserModel.USERNAME_FIELD: username}, **openid_data)
return self.configure_user(user, **kwargs)
self.update_existing_user(user, openid_data)
return user
def get_user_by_username(self, UserModel, username):
"""Retrieve user by username."""
try:
return UserModel.objects.get(**{UserModel.USERNAME_FIELD: username})
except UserModel.DoesNotExist:
return None
def get_user_by_email(self, UserModel, email):
"""Retrieve user by email."""
try:
return UserModel.objects.get(email=email)
except UserModel.DoesNotExist:
return None
def update_existing_user(self, user, kwargs): def update_existing_user(self, user, kwargs):
""" """
Update user fields without overwriting certain fields. Update user fields without overwriting certain fields.

View file

@ -1,5 +1,6 @@
from django.test import TestCase from django.test import TestCase
from registrar.models import User from registrar.models import User
from api.tests.common import less_console_noise_decorator
from ..backends import OpenIdConnectBackend # Adjust the import path based on your project structure from ..backends import OpenIdConnectBackend # Adjust the import path based on your project structure
@ -17,6 +18,7 @@ class OpenIdConnectBackendTestCase(TestCase):
def tearDown(self) -> None: def tearDown(self) -> None:
User.objects.all().delete() User.objects.all().delete()
@less_console_noise_decorator
def test_authenticate_with_create_user(self): def test_authenticate_with_create_user(self):
"""Test that authenticate creates a new user if it does not find """Test that authenticate creates a new user if it does not find
existing user""" existing user"""
@ -32,6 +34,7 @@ class OpenIdConnectBackendTestCase(TestCase):
self.assertEqual(user.email, "john.doe@example.com") self.assertEqual(user.email, "john.doe@example.com")
self.assertEqual(user.phone, "123456789") self.assertEqual(user.phone, "123456789")
@less_console_noise_decorator
def test_authenticate_with_existing_user(self): def test_authenticate_with_existing_user(self):
"""Test that authenticate updates an existing user if it finds one. """Test that authenticate updates an existing user if it finds one.
For this test, given_name and family_name are supplied""" For this test, given_name and family_name are supplied"""
@ -50,6 +53,30 @@ class OpenIdConnectBackendTestCase(TestCase):
self.assertEqual(user.email, "john.doe@example.com") self.assertEqual(user.email, "john.doe@example.com")
self.assertEqual(user.phone, "123456789") self.assertEqual(user.phone, "123456789")
@less_console_noise_decorator
def test_authenticate_with_existing_user_same_email_different_username(self):
"""Test that authenticate updates an existing user if it finds one.
In this case, match is to an existing record with matching email but
a non-matching username. The existing record's username should be udpated.
For this test, given_name and family_name are supplied"""
# Create an existing user with the same username
User.objects.create_user(username="old_username", email="john.doe@example.com")
# Ensure that the authenticate method updates the existing user
user = self.backend.authenticate(request=None, **self.kwargs)
self.assertIsNotNone(user)
self.assertIsInstance(user, User)
# Verify that user fields are correctly updated
self.assertEqual(user.first_name, "John")
self.assertEqual(user.last_name, "Doe")
self.assertEqual(user.email, "john.doe@example.com")
self.assertEqual(user.phone, "123456789")
self.assertEqual(user.username, "test_user")
# Assert that a user no longer exists by the old username
self.assertFalse(User.objects.filter(username="old_username").exists())
@less_console_noise_decorator
def test_authenticate_with_existing_user_with_existing_first_last_phone(self): def test_authenticate_with_existing_user_with_existing_first_last_phone(self):
"""Test that authenticate updates an existing user if it finds one. """Test that authenticate updates an existing user if it finds one.
For this test, given_name and family_name are not supplied. For this test, given_name and family_name are not supplied.
@ -79,6 +106,7 @@ class OpenIdConnectBackendTestCase(TestCase):
self.assertEqual(user.email, "john.doe@example.com") self.assertEqual(user.email, "john.doe@example.com")
self.assertEqual(user.phone, "9999999999") self.assertEqual(user.phone, "9999999999")
@less_console_noise_decorator
def test_authenticate_with_existing_user_different_name_phone(self): def test_authenticate_with_existing_user_different_name_phone(self):
"""Test that authenticate updates an existing user if it finds one. """Test that authenticate updates an existing user if it finds one.
For this test, given_name and family_name are supplied and overwrite""" For this test, given_name and family_name are supplied and overwrite"""
@ -100,6 +128,7 @@ class OpenIdConnectBackendTestCase(TestCase):
self.assertEqual(user.email, "john.doe@example.com") self.assertEqual(user.email, "john.doe@example.com")
self.assertEqual(user.phone, "123456789") self.assertEqual(user.phone, "123456789")
@less_console_noise_decorator
def test_authenticate_with_unknown_user(self): def test_authenticate_with_unknown_user(self):
"""Test that authenticate returns None when no kwargs are supplied""" """Test that authenticate returns None when no kwargs are supplied"""
# Ensure that the authenticate method handles the case when the user is not found # Ensure that the authenticate method handles the case when the user is not found

View file

@ -1329,6 +1329,14 @@ class UserPortfolioPermissionAdmin(ListHeaderAdmin):
get_roles.short_description = "Roles" # type: ignore get_roles.short_description = "Roles" # type: ignore
def delete_queryset(self, request, queryset):
"""We override the delete method in the model.
When deleting in DJA, if you select multiple items in a table using checkboxes and apply a delete action
the model delete does not get called. This method gets called instead.
This override makes sure our code in the model gets executed in these situations."""
for obj in queryset:
obj.delete() # Calls the overridden delete method on each instance
class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin): class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"""Custom user domain role admin class.""" """Custom user domain role admin class."""
@ -1407,10 +1415,13 @@ class BaseInvitationAdmin(ListHeaderAdmin):
Normal flow on successful save_model on add is to redirect to changelist_view. Normal flow on successful save_model on add is to redirect to changelist_view.
If there are errors, flow is modified to instead render change form. If there are errors, flow is modified to instead render change form.
""" """
# store current messages from request so that they are preserved throughout the method # store current messages from request in storage so that they are preserved throughout the
# method, as some flows remove and replace all messages, and so we store here to retrieve
# later
storage = get_messages(request) storage = get_messages(request)
# Check if there are any error or warning messages in the `messages` framework # Check if there are any error messages in the `messages` framework
has_errors = any(message.level_tag in ["error", "warning"] for message in storage) # error messages stop the workflow; other message levels allow flow to continue as normal
has_errors = any(message.level_tag in ["error"] for message in storage)
if has_errors: if has_errors:
# Re-render the change form if there are errors or warnings # Re-render the change form if there are errors or warnings
@ -1552,13 +1563,14 @@ class DomainInvitationAdmin(BaseInvitationAdmin):
portfolio_invitation.save() portfolio_invitation.save()
messages.success(request, f"{requested_email} has been invited to the organization: {domain_org}") messages.success(request, f"{requested_email} has been invited to the organization: {domain_org}")
send_domain_invitation_email( if not send_domain_invitation_email(
email=requested_email, email=requested_email,
requestor=requestor, requestor=requestor,
domains=domain, domains=domain,
is_member_of_different_org=member_of_a_different_org, is_member_of_different_org=member_of_a_different_org,
requested_user=requested_user, requested_user=requested_user,
) ):
messages.warning(request, "Could not send email confirmation to existing domain managers.")
if requested_user is not None: if requested_user is not None:
# Domain Invitation creation for an existing User # Domain Invitation creation for an existing User
obj.retrieve() obj.retrieve()
@ -1657,6 +1669,14 @@ class PortfolioInvitationAdmin(BaseInvitationAdmin):
# Call the parent save method to save the object # Call the parent save method to save the object
super().save_model(request, obj, form, change) super().save_model(request, obj, form, change)
def delete_queryset(self, request, queryset):
"""We override the delete method in the model.
When deleting in DJA, if you select multiple items in a table using checkboxes and apply a delete action,
the model delete does not get called. This method gets called instead.
This override makes sure our code in the model gets executed in these situations."""
for obj in queryset:
obj.delete() # Calls the overridden delete method on each instance
class DomainInformationResource(resources.ModelResource): class DomainInformationResource(resources.ModelResource):
"""defines how each field in the referenced model should be mapped to the corresponding fields in the """defines how each field in the referenced model should be mapped to the corresponding fields in the

View file

@ -87,14 +87,6 @@ export function initAddNewMemberPageListeners() {
}); });
}); });
/*
Helper function to capitalize the first letter in a string (for display purposes)
*/
function capitalizeFirstLetter(text) {
if (!text) return ''; // Return empty string if input is falsy
return text.charAt(0).toUpperCase() + text.slice(1);
}
/* /*
Populates contents of the "Add Member" confirmation modal Populates contents of the "Add Member" confirmation modal
*/ */
@ -102,10 +94,12 @@ export function initAddNewMemberPageListeners() {
const permissionDetailsContainer = document.getElementById("permission_details"); const permissionDetailsContainer = document.getElementById("permission_details");
permissionDetailsContainer.innerHTML = ""; // Clear previous content permissionDetailsContainer.innerHTML = ""; // Clear previous content
// Get all permission sections (divs with h3 and radio inputs) if (permission_details_div_id == 'member-basic-permissions') {
const permissionSections = document.querySelectorAll(`#${permission_details_div_id} > h3`); // for basic users, display values are based on selections in the form
// Get all permission sections (divs with h3 and radio inputs)
const permissionSections = document.querySelectorAll(`#${permission_details_div_id} > h3`);
permissionSections.forEach(section => { permissionSections.forEach(section => {
// Find the <h3> element text // Find the <h3> element text
const sectionTitle = section.textContent; const sectionTitle = section.textContent;
@ -113,31 +107,46 @@ export function initAddNewMemberPageListeners() {
const fieldset = section.nextElementSibling; const fieldset = section.nextElementSibling;
if (fieldset && fieldset.tagName.toLowerCase() === 'fieldset') { if (fieldset && fieldset.tagName.toLowerCase() === 'fieldset') {
// Get the selected radio button within this fieldset // Get the selected radio button within this fieldset
const selectedRadio = fieldset.querySelector('input[type="radio"]:checked'); const selectedRadio = fieldset.querySelector('input[type="radio"]:checked');
// If a radio button is selected, get its label text // If a radio button is selected, get its label text
let selectedPermission = "No permission selected"; let selectedPermission = "No permission selected";
if (selectedRadio) { if (selectedRadio) {
const label = fieldset.querySelector(`label[for="${selectedRadio.id}"]`); const label = fieldset.querySelector(`label[for="${selectedRadio.id}"]`);
selectedPermission = label ? label.textContent : "No permission selected"; if (label) {
// Get only the text node content (excluding subtext in <p>)
const mainText = Array.from(label.childNodes)
.filter(node => node.nodeType === Node.TEXT_NODE)
.map(node => node.textContent.trim())
.join(""); // Combine and trim whitespace
selectedPermission = mainText || "No permission selected";
} }
}
// Create new elements for the modal content appendPermissionInContainer(sectionTitle, selectedPermission, permissionDetailsContainer);
const titleElement = document.createElement("h4");
titleElement.textContent = sectionTitle;
titleElement.classList.add("text-primary");
titleElement.classList.add("margin-bottom-0");
const permissionElement = document.createElement("p");
permissionElement.textContent = selectedPermission;
permissionElement.classList.add("margin-top-0");
// Append to the modal content container
permissionDetailsContainer.appendChild(titleElement);
permissionDetailsContainer.appendChild(permissionElement);
} }
}); });
} else {
// for admin users, the permissions are always the same
appendPermissionInContainer('Domains', 'Viewer, all', permissionDetailsContainer);
appendPermissionInContainer('Domain requests', 'Creator', permissionDetailsContainer);
appendPermissionInContainer('Members', 'Manager', permissionDetailsContainer);
}
}
function appendPermissionInContainer(sectionTitle, permissionDisplay, permissionContainer) {
// Create new elements for the content
const titleElement = document.createElement("h4");
titleElement.textContent = sectionTitle;
titleElement.classList.add("text-primary", "margin-bottom-0");
const permissionElement = document.createElement("p");
permissionElement.textContent = permissionDisplay;
permissionElement.classList.add("margin-top-0");
// Append to the content container
permissionContainer.appendChild(titleElement);
permissionContainer.appendChild(permissionElement);
} }
/* /*
@ -149,18 +158,25 @@ export function initAddNewMemberPageListeners() {
let emailValue = document.getElementById('id_email').value; let emailValue = document.getElementById('id_email').value;
document.getElementById('modalEmail').textContent = emailValue; document.getElementById('modalEmail').textContent = emailValue;
// Get selected radio button for access level // Get selected radio button for member access level
let selectedAccess = document.querySelector('input[name="role"]:checked'); let selectedAccess = document.querySelector('input[name="role"]:checked');
// Set the selected permission text to 'Basic' or 'Admin' (the value of the selected radio button) // Map the access level values to user-friendly labels
// This value does not have the first letter capitalized so let's capitalize it const accessLevelMapping = {
let accessText = selectedAccess ? capitalizeFirstLetter(selectedAccess.value) : "No access level selected"; organization_admin: "Admin",
organization_member: "Basic",
};
// Determine the access text based on the selected value
let accessText = selectedAccess
? accessLevelMapping[selectedAccess.value] || "Unknown access level"
: "No access level selected";
// Update the modal with the appropriate member access level text
document.getElementById('modalAccessLevel').textContent = accessText; document.getElementById('modalAccessLevel').textContent = accessText;
// Populate permission details based on access level // Populate permission details based on access level
if (selectedAccess && selectedAccess.value === 'organization_admin') { if (selectedAccess && selectedAccess.value === 'organization_admin') {
populatePermissionDetails('new-member-admin-permissions'); populatePermissionDetails('admin');
} else { } else {
populatePermissionDetails('new-member-basic-permissions'); populatePermissionDetails('member-basic-permissions');
} }
//------- Show the modal //------- Show the modal
@ -177,22 +193,14 @@ export function initPortfolioMemberPageRadio() {
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
let memberForm = document.getElementById("member_form"); let memberForm = document.getElementById("member_form");
let newMemberForm = document.getElementById("add_member_form") let newMemberForm = document.getElementById("add_member_form")
if (memberForm) { if (memberForm || newMemberForm) {
hookupRadioTogglerListener( hookupRadioTogglerListener(
'role', 'role',
{ {
'organization_admin': 'member-admin-permissions', 'organization_admin': '',
'organization_member': 'member-basic-permissions' 'organization_member': 'member-basic-permissions'
} }
); );
}else if (newMemberForm){
hookupRadioTogglerListener(
'role',
{
'organization_admin': 'new-member-admin-permissions',
'organization_member': 'new-member-basic-permissions'
}
);
} }
}); });
} }

View file

@ -129,7 +129,7 @@ export class BaseTable {
this.displayName = itemName; this.displayName = itemName;
this.sectionSelector = itemName + 's'; this.sectionSelector = itemName + 's';
this.tableWrapper = document.getElementById(`${this.sectionSelector}__table-wrapper`); this.tableWrapper = document.getElementById(`${this.sectionSelector}__table-wrapper`);
this.tableHeaders = document.querySelectorAll(`#${this.sectionSelector} th[data-sortable]`); this.tableHeaderSortButtons = document.querySelectorAll(`#${this.sectionSelector} th[data-sortable] button`);
this.currentSortBy = 'id'; this.currentSortBy = 'id';
this.currentOrder = 'asc'; this.currentOrder = 'asc';
this.currentStatus = []; this.currentStatus = [];
@ -303,13 +303,18 @@ export class BaseTable {
* A helper that resets sortable table headers * A helper that resets sortable table headers
* *
*/ */
unsetHeader = (header) => { unsetHeader = (headerSortButton) => {
header.removeAttribute('aria-sort'); let header = headerSortButton.closest('th');
let headerName = header.innerText; if (header) {
const headerLabel = `${headerName}, sortable column, currently unsorted"`; header.removeAttribute('aria-sort');
const headerButtonLabel = `Click to sort by ascending order.`; let headerName = header.innerText;
header.setAttribute("aria-label", headerLabel); const headerLabel = `${headerName}, sortable column, currently unsorted"`;
header.querySelector('.usa-table__header__button').setAttribute("title", headerButtonLabel); const headerButtonLabel = `Click to sort by ascending order.`;
header.setAttribute("aria-label", headerLabel);
header.querySelector('.usa-table__header__button').setAttribute("title", headerButtonLabel);
} else {
console.warn('Issue with DOM');
}
}; };
/** /**
@ -505,24 +510,21 @@ export class BaseTable {
// Add event listeners to table headers for sorting // Add event listeners to table headers for sorting
initializeTableHeaders() { initializeTableHeaders() {
this.tableHeaders.forEach(header => { this.tableHeaderSortButtons.forEach(tableHeader => {
header.addEventListener('click', event => { tableHeader.addEventListener('click', event => {
let button = header.querySelector('.usa-table__header__button') let header = tableHeader.closest('th');
const sortBy = header.getAttribute('data-sortable'); if (header) {
let order = 'asc'; const sortBy = header.getAttribute('data-sortable');
// sort order will be ascending, unless the currently sorted column is ascending, and the user let order = 'asc';
// is selecting the same column to sort in descending order // sort order will be ascending, unless the currently sorted column is ascending, and the user
if (sortBy === this.currentSortBy) { // is selecting the same column to sort in descending order
order = this.currentOrder === 'asc' ? 'desc' : 'asc'; if (sortBy === this.currentSortBy) {
} order = this.currentOrder === 'asc' ? 'desc' : 'asc';
// load the results with the updated sort }
this.loadTable(1, sortBy, order); // load the results with the updated sort
// If the click occurs outside of the button, need to simulate a button click in order this.loadTable(1, sortBy, order);
// for USWDS listener on the button to execute. } else {
// Check first to see if click occurs outside of the button console.warn('Issue with DOM');
if (!button.contains(event.target)) {
// Simulate a button click
button.click();
} }
}); });
}); });
@ -587,9 +589,9 @@ export class BaseTable {
// Reset UI and accessibility // Reset UI and accessibility
resetHeaders() { resetHeaders() {
this.tableHeaders.forEach(header => { this.tableHeaderSortButtons.forEach(headerSortButton => {
// Unset sort UI in headers // Unset sort UI in headers
this.unsetHeader(header); this.unsetHeader(headerSortButton);
}); });
// Reset the announcement region // Reset the announcement region
this.tableAnnouncementRegion.innerHTML = ''; this.tableAnnouncementRegion.innerHTML = '';

View file

@ -35,16 +35,19 @@ export class MemberDomainsTable extends BaseTable {
showElement(dataWrapper); showElement(dataWrapper);
hideElement(noSearchResultsWrapper); hideElement(noSearchResultsWrapper);
hideElement(noDataWrapper); hideElement(noDataWrapper);
this.tableAnnouncementRegion.innerHTML = '';
} else { } else {
hideElement(dataWrapper); hideElement(dataWrapper);
showElement(noSearchResultsWrapper); showElement(noSearchResultsWrapper);
hideElement(noDataWrapper); hideElement(noDataWrapper);
this.tableAnnouncementRegion.innerHTML = this.noSearchResultsWrapper.innerHTML;
} }
} else { } else {
hideElement(searchSection); hideElement(searchSection);
hideElement(dataWrapper); hideElement(dataWrapper);
hideElement(noSearchResultsWrapper); hideElement(noSearchResultsWrapper);
showElement(noDataWrapper); showElement(noDataWrapper);
this.tableAnnouncementRegion.innerHTML = this.noDataWrapper.innerHTML;
} }
}; };
} }

View file

@ -49,3 +49,30 @@ tr:last-of-type .usa-accordion--more-actions .usa-accordion__content {
bottom: -10px; bottom: -10px;
right: 30px; right: 30px;
} }
// A CSS only show-more/show-less based on usa-accordion
.usa-accordion--show-more {
width: auto;
.usa-accordion__button[aria-expanded=false],
.usa-accordion__button[aria-expanded=false]:hover,
.usa-accordion__button[aria-expanded=true],
.usa-accordion__button[aria-expanded=true]:hover {
background-image: none;
background-color: transparent;
padding-right: 0;
padding-left: 0;
font-weight: normal;
}
.usa-accordion__button[aria-expanded=true] .expand-more {
display: inline-block;
}
.usa-accordion__button[aria-expanded=true] .expand-less {
display: none;
}
.usa-accordion__button[aria-expanded=false] .expand-more {
display: none;
}
.usa-accordion__button[aria-expanded=false] .expand-less {
display: inline-block;
}
}

View file

@ -105,3 +105,25 @@ th {
} }
} }
} }
.dotgov-table--cell-padding-2 {
td, th {
padding: units(2);
}
}
.usa-table--striped tbody tr:nth-child(odd) th,
.usa-table--striped tbody tr:nth-child(odd) td {
background-color: color('primary-lightest');
}
.usa-table--bg-transparent {
td, thead th {
background-color: transparent;
}
}
.usa-table--full-borderless td,
.usa-table--full-borderless th {
border: none !important;
}

View file

@ -29,7 +29,7 @@
font-weight: 400 !important; font-weight: 400 !important;
} }
.domains__table { .domains__table, .usa-table {
/* /*
Trick tooltips in the domains table to do 2 things... Trick tooltips in the domains table to do 2 things...
1 - Shrink itself to a padded viewport window 1 - Shrink itself to a padded viewport window

View file

@ -11,7 +11,8 @@ address,
} }
h1:not(.usa-alert__heading), h1:not(.usa-alert__heading),
h2:not(.usa-alert__heading), // .module h2 excludes headers in DJA
h2:not(.usa-alert__heading, .module h2),
h3:not(.usa-alert__heading), h3:not(.usa-alert__heading),
h4:not(.usa-alert__heading), h4:not(.usa-alert__heading),
h5:not(.usa-alert__heading), h5:not(.usa-alert__heading),
@ -45,3 +46,7 @@ h4, .h4 {
padding-left: units(1); padding-left: units(1);
border-left: 2px solid color('base-lighter'); border-left: 2px solid color('base-lighter');
} }
.font-body-1 {
font-size: size('body', 1);
}

View file

@ -25,6 +25,7 @@ from typing import Final
from botocore.config import Config from botocore.config import Config
import json import json
import logging import logging
import traceback
from django.utils.log import ServerFormatter from django.utils.log import ServerFormatter
# # # ### # # # ###
@ -471,7 +472,11 @@ class JsonFormatter(logging.Formatter):
"lineno": record.lineno, "lineno": record.lineno,
"message": record.getMessage(), "message": record.getMessage(),
} }
return json.dumps(log_record) # Capture exception info if it exists
if record.exc_info:
log_record["exception"] = "".join(traceback.format_exception(*record.exc_info))
return json.dumps(log_record, ensure_ascii=False)
class JsonServerFormatter(ServerFormatter): class JsonServerFormatter(ServerFormatter):

View file

@ -352,12 +352,37 @@ class UserFixture:
@staticmethod @staticmethod
def _get_existing_users(users): def _get_existing_users(users):
# if users match existing users in db by email address, update the users with the username
# from the db. this will prevent duplicate users (with same email) from being added to db.
# it is ok to keep the old username in the db because the username will be updated by oidc process during login
# Extract email addresses from users
emails = [user.get("email") for user in users]
# Fetch existing users by email
existing_users_by_email = User.objects.filter(email__in=emails).values_list("email", "username", "id")
# Create a dictionary to map emails to existing usernames
email_to_existing_user = {user[0]: user[1] for user in existing_users_by_email}
# Update the users list with the usernames from existing users by email
for user in users:
email = user.get("email")
if email and email in email_to_existing_user:
user["username"] = email_to_existing_user[email] # Update username with the existing one
# Get the user identifiers (username, id) for the existing users to query the database
user_identifiers = [(user.get("username"), user.get("id")) for user in users] user_identifiers = [(user.get("username"), user.get("id")) for user in users]
# Fetch existing users by username or id
existing_users = User.objects.filter( existing_users = User.objects.filter(
username__in=[user[0] for user in user_identifiers] + [user[1] for user in user_identifiers] username__in=[user[0] for user in user_identifiers] + [user[1] for user in user_identifiers]
).values_list("username", "id") ).values_list("username", "id")
# Create sets for usernames and ids that exist
existing_usernames = set(user[0] for user in existing_users) existing_usernames = set(user[0] for user in existing_users)
existing_user_ids = set(user[1] for user in existing_users) existing_user_ids = set(user[1] for user in existing_users)
return existing_usernames, existing_user_ids return existing_usernames, existing_user_ids
@staticmethod @staticmethod

View file

@ -4,7 +4,6 @@ import logging
from django import forms from django import forms
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from django.core.validators import MaxLengthValidator from django.core.validators import MaxLengthValidator
from django.utils.safestring import mark_safe
from registrar.forms.utility.combobox import ComboboxWidget from registrar.forms.utility.combobox import ComboboxWidget
from registrar.models import ( from registrar.models import (
@ -121,47 +120,47 @@ class BasePortfolioMemberForm(forms.ModelForm):
widget=forms.RadioSelect, widget=forms.RadioSelect,
required=True, required=True,
error_messages={ error_messages={
"required": "Member access level is required", "required": "Select the level of access you would like to grant this member.",
}, },
) )
domain_request_permission_admin = forms.ChoiceField( domain_permissions = forms.ChoiceField(
label=mark_safe(f"Select permission {required_star}"), # nosec
choices=[ choices=[
(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "View all requests"), (UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value, "Viewer, limited"),
(UserPortfolioPermissionChoices.EDIT_REQUESTS.value, "View all requests plus create requests"), (UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value, "Viewer, all"),
], ],
widget=forms.RadioSelect, widget=forms.RadioSelect,
required=False, required=False,
initial=UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value,
error_messages={ error_messages={
"required": "Admin domain request permission is required", "required": "Domain permission is required.",
}, },
) )
member_permission_admin = forms.ChoiceField( domain_request_permissions = forms.ChoiceField(
label=mark_safe(f"Select permission {required_star}"), # nosec
choices=[ choices=[
(UserPortfolioPermissionChoices.VIEW_MEMBERS.value, "View all members"),
(UserPortfolioPermissionChoices.EDIT_MEMBERS.value, "View all members plus manage members"),
],
widget=forms.RadioSelect,
required=False,
error_messages={
"required": "Admin member permission is required",
},
)
domain_request_permission_member = forms.ChoiceField(
label=mark_safe(f"Select permission {required_star}"), # nosec
choices=[
(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "View all requests"),
(UserPortfolioPermissionChoices.EDIT_REQUESTS.value, "View all requests plus create requests"),
("no_access", "No access"), ("no_access", "No access"),
(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "Viewer"),
(UserPortfolioPermissionChoices.EDIT_REQUESTS.value, "Creator"),
], ],
widget=forms.RadioSelect, widget=forms.RadioSelect,
required=False, required=False,
initial="no_access",
error_messages={ error_messages={
"required": "Basic member permission is required", "required": "Domain request permission is required.",
},
)
member_permissions = forms.ChoiceField(
choices=[
("no_access", "No access"),
(UserPortfolioPermissionChoices.VIEW_MEMBERS.value, "Viewer"),
],
widget=forms.RadioSelect,
required=False,
initial="no_access",
error_messages={
"required": "Member permission is required.",
}, },
) )
@ -169,12 +168,11 @@ class BasePortfolioMemberForm(forms.ModelForm):
# All of the fields included here have "required=False" by default as they are conditionally required. # All of the fields included here have "required=False" by default as they are conditionally required.
# see def clean() for more details. # see def clean() for more details.
ROLE_REQUIRED_FIELDS = { ROLE_REQUIRED_FIELDS = {
UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [ UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [],
"domain_request_permission_admin",
"member_permission_admin",
],
UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [ UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [
"domain_request_permission_member", "domain_permissions",
"member_permissions",
"domain_request_permissions",
], ],
} }
@ -190,15 +188,24 @@ class BasePortfolioMemberForm(forms.ModelForm):
Update field descriptions. Update field descriptions.
""" """
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Adds a <p> description beneath each role option
self.fields["role"].descriptions = { # Adds a <p> description beneath each option
"organization_admin": UserPortfolioRoleChoices.get_role_description( self.fields["domain_permissions"].descriptions = {
UserPortfolioRoleChoices.ORGANIZATION_ADMIN UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value: "Can view only the domains they manage",
), UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value: "Can view all domains for the organization",
"organization_member": UserPortfolioRoleChoices.get_role_description(
UserPortfolioRoleChoices.ORGANIZATION_MEMBER
),
} }
self.fields["domain_request_permissions"].descriptions = {
UserPortfolioPermissionChoices.EDIT_REQUESTS.value: (
"Can view all domain requests for the organization and create requests"
),
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value: "Can view all domain requests for the organization",
"no_access": "Cannot view or create domain requests",
}
self.fields["member_permissions"].descriptions = {
UserPortfolioPermissionChoices.VIEW_MEMBERS.value: "Can view all members permissions",
"no_access": "Cannot view member permissions",
}
# Map model instance values to custom form fields # Map model instance values to custom form fields
if self.instance: if self.instance:
self.map_instance_to_initial() self.map_instance_to_initial()
@ -222,8 +229,12 @@ class BasePortfolioMemberForm(forms.ModelForm):
self.add_error(field_name, self.fields.get(field_name).error_messages.get("required")) self.add_error(field_name, self.fields.get(field_name).error_messages.get("required"))
# Edgecase: Member uses a special form value for None called "no_access". # Edgecase: Member uses a special form value for None called "no_access".
if cleaned_data.get("domain_request_permission_member") == "no_access": if cleaned_data.get("domain_request_permissions") == "no_access":
cleaned_data["domain_request_permission_member"] = None cleaned_data["domain_request_permissions"] = None
# Edgecase: Member uses a special form value for None called "no_access".
if cleaned_data.get("member_permissions") == "no_access":
cleaned_data["member_permissions"] = None
# Handle roles # Handle roles
cleaned_data["roles"] = [role] cleaned_data["roles"] = [role]
@ -253,7 +264,7 @@ class BasePortfolioMemberForm(forms.ModelForm):
"role": "organization_admin" or "organization_member", "role": "organization_admin" or "organization_member",
"member_permission_admin": permission level if admin, "member_permission_admin": permission level if admin,
"domain_request_permission_admin": permission level if admin, "domain_request_permission_admin": permission level if admin,
"domain_request_permission_member": permission level if member "domain_request_permissions": permission level if member
} }
""" """
if self.initial is None: if self.initial is None:
@ -267,12 +278,15 @@ class BasePortfolioMemberForm(forms.ModelForm):
UserPortfolioRoleChoices.ORGANIZATION_ADMIN, UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
UserPortfolioRoleChoices.ORGANIZATION_MEMBER, UserPortfolioRoleChoices.ORGANIZATION_MEMBER,
] ]
domain_perms = [ domain_request_perms = [
UserPortfolioPermissionChoices.EDIT_REQUESTS, UserPortfolioPermissionChoices.EDIT_REQUESTS,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
] ]
domain_perms = [
UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS,
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
]
member_perms = [ member_perms = [
UserPortfolioPermissionChoices.EDIT_MEMBERS,
UserPortfolioPermissionChoices.VIEW_MEMBERS, UserPortfolioPermissionChoices.VIEW_MEMBERS,
] ]
@ -282,16 +296,21 @@ class BasePortfolioMemberForm(forms.ModelForm):
roles = self.instance.roles or [] roles = self.instance.roles or []
selected_role = next((role for role in roles if role in roles), None) selected_role = next((role for role in roles if role in roles), None)
self.initial["role"] = selected_role self.initial["role"] = selected_role
is_admin = selected_role == UserPortfolioRoleChoices.ORGANIZATION_ADMIN is_member = selected_role == UserPortfolioRoleChoices.ORGANIZATION_MEMBER
if is_admin: if is_member:
selected_domain_permission = next((perm for perm in domain_perms if perm in perms), None) # Edgecase: Member and domain request use a special form value for None called "no_access".
selected_member_permission = next((perm for perm in member_perms if perm in perms), None) # This ensures a form selection.
self.initial["domain_request_permission_admin"] = selected_domain_permission selected_domain_permission = next(
self.initial["member_permission_admin"] = selected_member_permission (perm for perm in domain_perms if perm in perms),
else: UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value,
# Edgecase: Member uses a special form value for None called "no_access". This ensures a form selection. )
selected_domain_permission = next((perm for perm in domain_perms if perm in perms), "no_access") selected_domain_request_permission = next(
self.initial["domain_request_permission_member"] = selected_domain_permission (perm for perm in domain_request_perms if perm in perms), "no_access"
)
selected_member_permission = next((perm for perm in member_perms if perm in perms), "no_access")
self.initial["domain_request_permissions"] = selected_domain_request_permission
self.initial["domain_permissions"] = selected_domain_permission
self.initial["member_permissions"] = selected_member_permission
class PortfolioMemberForm(BasePortfolioMemberForm): class PortfolioMemberForm(BasePortfolioMemberForm):
@ -320,7 +339,7 @@ class PortfolioNewMemberForm(BasePortfolioMemberForm):
""" """
email = forms.EmailField( email = forms.EmailField(
label="Enter the email of the member you'd like to invite", label="Email",
max_length=None, max_length=None,
error_messages={ error_messages={
"invalid": ("Enter an email address in the required format, like name@example.com."), "invalid": ("Enter an email address in the required format, like name@example.com."),

View file

@ -1582,11 +1582,9 @@ class Domain(TimeStampedModel, DomainHelper):
if self.is_expired() and self.state != self.State.UNKNOWN: if self.is_expired() and self.state != self.State.UNKNOWN:
# Given expired is not a physical state, but it is displayed as such, # Given expired is not a physical state, but it is displayed as such,
# We need custom logic to determine this message. # We need custom logic to determine this message.
help_text = ( help_text = "This domain has expired. Complete the online renewal process to maintain access."
"This domain has expired, but it is still online. " "To renew this domain, contact help@get.gov."
)
elif flag_is_active(request, "domain_renewal") and self.is_expiring(): elif flag_is_active(request, "domain_renewal") and self.is_expiring():
help_text = "This domain will expire soon. Contact one of the listed domain managers to renew the domain." help_text = "This domain is expiring soon. Complete the online renewal process to maintain access."
else: else:
help_text = Domain.State.get_help_text(self.state) help_text = Domain.State.get_help_text(self.state)

View file

@ -8,6 +8,7 @@ from registrar.models import DomainInvitation, UserPortfolioPermission
from .utility.portfolio_helper import ( from .utility.portfolio_helper import (
UserPortfolioPermissionChoices, UserPortfolioPermissionChoices,
UserPortfolioRoleChoices, UserPortfolioRoleChoices,
cleanup_after_portfolio_member_deletion,
validate_portfolio_invitation, validate_portfolio_invitation,
) # type: ignore ) # type: ignore
from .utility.time_stamped_model import TimeStampedModel from .utility.time_stamped_model import TimeStampedModel
@ -115,3 +116,27 @@ class PortfolioInvitation(TimeStampedModel):
"""Extends clean method to perform additional validation, which can raise errors in django admin.""" """Extends clean method to perform additional validation, which can raise errors in django admin."""
super().clean() super().clean()
validate_portfolio_invitation(self) validate_portfolio_invitation(self)
def delete(self, *args, **kwargs):
User = get_user_model()
email = self.email # Capture the email before the instance is deleted
portfolio = self.portfolio # Capture the portfolio before the instance is deleted
# Call the superclass delete method to actually delete the instance
super().delete(*args, **kwargs)
if self.status == self.PortfolioInvitationStatus.INVITED:
# Query the user by email
users = User.objects.filter(email=email)
if users.count() > 1:
# This should never happen, log an error if more than one object is returned
logger.error(f"Multiple users found with the same email: {email}")
# Retrieve the first user, or None if no users are found
user = users.first()
cleanup_after_portfolio_member_deletion(portfolio=portfolio, email=email, user=user)

View file

@ -171,11 +171,14 @@ class User(AbstractUser):
now = timezone.now().date() now = timezone.now().date()
expiration_window = 60 expiration_window = 60
threshold_date = now + timedelta(days=expiration_window) threshold_date = now + timedelta(days=expiration_window)
acceptable_statuses = [Domain.State.UNKNOWN, Domain.State.DNS_NEEDED, Domain.State.READY]
num_of_expiring_domains = Domain.objects.filter( num_of_expiring_domains = Domain.objects.filter(
id__in=domain_ids, id__in=domain_ids,
expiration_date__isnull=False, expiration_date__isnull=False,
expiration_date__lte=threshold_date, expiration_date__lte=threshold_date,
expiration_date__gt=now, expiration_date__gt=now,
state__in=acceptable_statuses,
).count() ).count()
return num_of_expiring_domains return num_of_expiring_domains

View file

@ -5,6 +5,7 @@ from registrar.models.utility.portfolio_helper import (
UserPortfolioRoleChoices, UserPortfolioRoleChoices,
DomainRequestPermissionDisplay, DomainRequestPermissionDisplay,
MemberPermissionDisplay, MemberPermissionDisplay,
cleanup_after_portfolio_member_deletion,
validate_user_portfolio_permission, validate_user_portfolio_permission,
) )
from .utility.time_stamped_model import TimeStampedModel from .utility.time_stamped_model import TimeStampedModel
@ -21,16 +22,18 @@ class UserPortfolioPermission(TimeStampedModel):
UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [ UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.EDIT_REQUESTS,
UserPortfolioPermissionChoices.VIEW_MEMBERS, UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
UserPortfolioPermissionChoices.VIEW_PORTFOLIO, UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.EDIT_PORTFOLIO, UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
# Domain: field specific permissions
UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION, UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION,
UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION, UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION,
], ],
# NOTE: Check FORBIDDEN_PORTFOLIO_ROLE_PERMISSIONS before adding roles here. # NOTE: Check FORBIDDEN_PORTFOLIO_ROLE_PERMISSIONS before adding roles here.
UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [ UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [
UserPortfolioPermissionChoices.VIEW_PORTFOLIO, UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION,
], ],
} }
@ -38,9 +41,9 @@ class UserPortfolioPermission(TimeStampedModel):
# Used to throw a ValidationError on clean() for UserPortfolioPermission and PortfolioInvitation. # Used to throw a ValidationError on clean() for UserPortfolioPermission and PortfolioInvitation.
FORBIDDEN_PORTFOLIO_ROLE_PERMISSIONS = { FORBIDDEN_PORTFOLIO_ROLE_PERMISSIONS = {
UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [ UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [
UserPortfolioPermissionChoices.VIEW_MEMBERS, UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
UserPortfolioPermissionChoices.EDIT_MEMBERS, UserPortfolioPermissionChoices.EDIT_MEMBERS,
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION,
], ],
} }
@ -186,3 +189,13 @@ class UserPortfolioPermission(TimeStampedModel):
"""Extends clean method to perform additional validation, which can raise errors in django admin.""" """Extends clean method to perform additional validation, which can raise errors in django admin."""
super().clean() super().clean()
validate_user_portfolio_permission(self) validate_user_portfolio_permission(self)
def delete(self, *args, **kwargs):
user = self.user # Capture the user before the instance is deleted
portfolio = self.portfolio # Capture the portfolio before the instance is deleted
# Call the superclass delete method to actually delete the instance
super().delete(*args, **kwargs)
cleanup_after_portfolio_member_deletion(portfolio=portfolio, email=user.email, user=user)

View file

@ -25,23 +25,6 @@ class UserPortfolioRoleChoices(models.TextChoices):
logger.warning(f"Invalid portfolio role: {user_portfolio_role}") logger.warning(f"Invalid portfolio role: {user_portfolio_role}")
return f"Unknown ({user_portfolio_role})" return f"Unknown ({user_portfolio_role})"
@classmethod
def get_role_description(cls, user_portfolio_role):
"""Returns a detailed description for a given role."""
descriptions = {
cls.ORGANIZATION_ADMIN: (
"Grants this member access to the organization-wide information "
"on domains, domain requests, and members. Domain management can be assigned separately."
),
cls.ORGANIZATION_MEMBER: (
"Grants this member access to the organization. They can be given extra permissions to view all "
"organization domain requests and submit domain requests on behalf of the organization. Basic access "
"members cant view all members of an organization or manage them. "
"Domain management can be assigned separately."
),
}
return descriptions.get(user_portfolio_role)
class UserPortfolioPermissionChoices(models.TextChoices): class UserPortfolioPermissionChoices(models.TextChoices):
""" """ """ """
@ -227,3 +210,32 @@ def validate_portfolio_invitation(portfolio_invitation):
"This user is already assigned to a portfolio invitation. " "This user is already assigned to a portfolio invitation. "
"Based on current waffle flag settings, users cannot be assigned to multiple portfolios." "Based on current waffle flag settings, users cannot be assigned to multiple portfolios."
) )
def cleanup_after_portfolio_member_deletion(portfolio, email, user=None):
"""
Cleans up after removing a portfolio member or a portfolio invitation.
Args:
portfolio: portfolio
user: passed when removing a portfolio member.
email: passed when removing a portfolio invitation, or passed as user.email
when removing a portfolio member.
"""
DomainInvitation = apps.get_model("registrar.DomainInvitation")
UserDomainRole = apps.get_model("registrar.UserDomainRole")
# Fetch domain invitations matching the criteria
invitations = DomainInvitation.objects.filter(
email=email, domain__domain_info__portfolio=portfolio, status=DomainInvitation.DomainInvitationStatus.INVITED
)
# Call `cancel_invitation` on each invitation
for invitation in invitations:
invitation.cancel_invitation()
invitation.save()
if user:
# Remove user's domain roles for the current portfolio
UserDomainRole.objects.filter(user=user, domain__domain_info__portfolio=portfolio).delete()

View file

@ -21,7 +21,7 @@
{% if field and field.field and field.field.descriptions %} {% if field and field.field and field.field.descriptions %}
{% with description=field.field.descriptions|get_dict_value:option.value %} {% with description=field.field.descriptions|get_dict_value:option.value %}
{% if description %} {% if description %}
<p class="margin-0 margin-top-1">{{ description }}</p> <p class="margin-0 margin-top-1 font-body-2xs">{{ description }}</p>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% endif %} {% endif %}

View file

@ -49,11 +49,11 @@
{% if has_domain_renewal_flag and domain.is_expired and is_domain_manager %} {% if has_domain_renewal_flag and domain.is_expired and is_domain_manager %}
This domain has expired, but it is still online. This domain has expired, but it is still online.
{% url 'domain-renewal' pk=domain.id as url %} {% url 'domain-renewal' pk=domain.id as url %}
<a href="{{ url }}">Renew to maintain access.</a> <a href="{{ url }}" class="usa-link">Renew to maintain access.</a>
{% elif has_domain_renewal_flag and domain.is_expiring and is_domain_manager %} {% elif has_domain_renewal_flag and domain.is_expiring and is_domain_manager %}
This domain will expire soon. This domain will expire soon.
{% url 'domain-renewal' pk=domain.id as url %} {% url 'domain-renewal' pk=domain.id as url %}
<a href="{{ url }}">Renew to maintain access.</a> <a href="{{ url }}" class="usa-link">Renew to maintain access.</a>
{% elif has_domain_renewal_flag and domain.is_expiring and is_portfolio_user %} {% elif has_domain_renewal_flag and domain.is_expiring and is_portfolio_user %}
This domain will expire soon. Contact one of the listed domain managers to renew the domain. This domain will expire soon. Contact one of the listed domain managers to renew the domain.
{% elif has_domain_renewal_flag and domain.is_expired and is_portfolio_user %} {% elif has_domain_renewal_flag and domain.is_expired and is_portfolio_user %}

View file

@ -38,11 +38,11 @@
{{ block.super }} {{ block.super }}
<div class="margin-top-4 tablet:grid-col-10"> <div class="margin-top-4 tablet:grid-col-10">
<h2 class="domain-name-wrap">Confirm the following information for accuracy</h2> <h2 class="domain-name-wrap">Confirm the following information for accuracy</h2>
<p>Review these details below. We <a href="https://get.gov/domains/requirements/#what-.gov-domain-registrants-must-do" class="usa-link"> <p>Review the details below. We <a href="https://get.gov/domains/requirements/#what-.gov-domain-registrants-must-do" class="usa-link" target="_blank">
require</a> that you maintain accurate information for the domain. require</a> that you maintain accurate information for the domain.
The details you provide will only be used to support the administration of .gov and won't be made public. The details you provide will only be used to support the administration of .gov and won't be made public.
</p> </p>
<p>If you would like to retire your domain instead, please <a href="https://get.gov/contact/" class="usa-link"> <p>If you would like to retire your domain instead, please <a href="https://get.gov/contact/" class="usa-link" target="_blank">
contact us</a>. </p> contact us</a>. </p>
<p><em>Required fields are marked with an asterisk (<abbr class="usa-hint usa-hint--required" title="required">*</abbr>).</em> <p><em>Required fields are marked with an asterisk (<abbr class="usa-hint usa-hint--required" title="required">*</abbr>).</em>
</p> </p>
@ -98,7 +98,7 @@
{% if form.is_policy_acknowledged.errors %} {% if form.is_policy_acknowledged.errors %}
{% for error in form.is_policy_acknowledged.errors %} {% for error in form.is_policy_acknowledged.errors %}
<div class="usa-error-message display-flex" role="alert"> <div class="usa-error-message display-flex" role="alert">
<svg class="usa-icon usa-icon--large" focusable="true" role="img" aria-label="Error"> <svg class="usa-icon usa-icon--large" focusable="true" role="img" aria-label="Error: Check the box if you read and agree to the requirements for operating a .gov domain.">
<use xlink:href="{%static 'img/sprite.svg'%}#error"></use> <use xlink:href="{%static 'img/sprite.svg'%}#error"></use>
</svg> </svg>
<span class="margin-left-05">{{ error }}</span> <span class="margin-left-05">{{ error }}</span>
@ -119,10 +119,8 @@
> >
<label class="usa-checkbox__label" for="renewal-checkbox"> <label class="usa-checkbox__label" for="renewal-checkbox">
I read and agree to the I read and agree to the
<a href="https://get.gov/domains/requirements/" class="usa-link"> <a href="https://get.gov/domains/requirements/" class="usa-link" target="_blank">
requirements for operating a .gov domain requirements for operating a .gov domain</a>.<abbr class="usa-hint usa-hint--required" title="required">*</abbr>
</a>.
<abbr class="usa-hint usa-hint--required" title="required">*</abbr>
</label> </label>
</div> </div>
@ -131,7 +129,7 @@
name="submit_button" name="submit_button"
value="next" value="next"
class="usa-button margin-top-3" class="usa-button margin-top-3"
> Submit > Submit and renew
</button> </button>
</form> </form>
</fieldset> </fieldset>

View file

@ -1,7 +1,7 @@
{% if domain.expiration_date or domain.created_at %} {% if domain.expiration_date or domain.created_at %}
<p> <p>
{% if domain.expiration_date %} {% if domain.expiration_date %}
<strong class="text-primary-dark">Expires:</strong> <strong class="text-primary-dark">Date of expiration:</strong>
{{ domain.expiration_date|date }} {{ domain.expiration_date|date }}
{% if domain.is_expired %} <span class="text-error"><strong>(expired)</strong></span>{% endif %} {% if domain.is_expired %} <span class="text-error"><strong>(expired)</strong></span>{% endif %}
<br/> <br/>

View file

@ -65,6 +65,7 @@
id="domain-requests__usa-button--filter" id="domain-requests__usa-button--filter"
aria-expanded="false" aria-expanded="false"
aria-controls="filter-status" aria-controls="filter-status"
aria-label="Status, list 7 items"
> >
<span class="text-bold display-none" id="domain-requests__filter-indicator"></span> Status <span class="text-bold display-none" id="domain-requests__filter-indicator"></span> Status
<svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24"> <svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24">

View file

@ -10,14 +10,14 @@
<!-- Org model banner (org manager can view, domain manager can edit) --> <!-- Org model banner (org manager can view, domain manager can edit) -->
{% if has_domain_renewal_flag and num_expiring_domains > 0 and has_any_domains_portfolio_permission %} {% if has_domain_renewal_flag and num_expiring_domains > 0 and has_any_domains_portfolio_permission %}
<section class="usa-site-alert usa-site-alert--info margin-bottom-2 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert"> <section class="usa-site-alert--slim usa-site-alert--info margin-bottom-2 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
<div class="usa-alert"> <div class="usa-alert">
<div class="usa-alert__body usa-alert__body--widescreen"> <div class="usa-alert__body">
<p class="usa-alert__text maxw-none"> <p class="usa-alert__text maxw-none">
{% if num_expiring_domains == 1%} {% if num_expiring_domains == 1%}
One domain will expire soon. Go to "Manage" to renew the domain. <a href="#" id="link-expiring-domains" class="usa-link">Show expiring domain.</a> One domain will expire soon. Go to "Manage" to renew the domain. <a href="#" id="link-expiring-domains" class="usa-link" tabindex="0" aria-label="Show expiring domains. This will filter the Domains table to only show the expiring domain.">Show expiring domain.</a>
{% else%} {% else%}
Multiple domains will expire soon. Go to "Manage" to renew the domains. <a href="#" id="link-expiring-domains" class="usa-link">Show expiring domains.</a> Multiple domains will expire soon. Go to "Manage" to renew the domains. <a href="#" id="link-expiring-domains" class="usa-link" tabindex="0" aria-label="Show expiring domains. This will filter the Domains table to only show the expiring domains.">Show expiring domains.</a>
{% endif %} {% endif %}
</p> </p>
</div> </div>
@ -64,7 +64,7 @@
{% if user_domain_count and user_domain_count > 0 %} {% if user_domain_count and user_domain_count > 0 %}
<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 %}"> <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 %}">
<section aria-label="Domains report component" class="margin-top-205"> <section aria-label="Domains report component" class="margin-top-205">
<a href="{% url 'export_data_type_user' %}" class="usa-button usa-button--unstyled usa-button--with-icon usa-button--justify-right" role="button"> <a href="{% url 'export_data_type_user' %}" class="usa-button usa-button--unstyled usa-button--with-icon usa-button--justify-right">
<svg class="usa-icon usa-icon--large" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <svg class="usa-icon usa-icon--large" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use> <use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
</svg>Export as CSV </svg>Export as CSV
@ -76,14 +76,14 @@
<!-- Non org model banner --> <!-- Non org model banner -->
{% if has_domain_renewal_flag and num_expiring_domains > 0 and not portfolio %} {% if has_domain_renewal_flag and num_expiring_domains > 0 and not portfolio %}
<section class="usa-site-alert usa-site-alert--info margin-bottom-2 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert"> <section class="usa-site-alert--slim usa-site-alert--info margin-bottom-2 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
<div class="usa-alert"> <div class="usa-alert">
<div class="usa-alert__body usa-alert__body--widescreen"> <div class="usa-alert__body">
<p class="usa-alert__text maxw-none"> <p class="usa-alert__text maxw-none">
{% if num_expiring_domains == 1%} {% if num_expiring_domains == 1%}
One domain will expire soon. Go to "Manage" to renew the domain. <a href="#" id="link-expiring-domains" class="usa-link">Show expiring domain.</a> One domain will expire soon. Go to "Manage" to renew the domain. <a href="#" id="link-expiring-domains" class="usa-link" tabindex="0" aria-label="Show expiring domains. This will filter the Domains table to only show the expiring domain.">Show expiring domain.</a>
{% else%} {% else%}
Multiple domains will expire soon. Go to "Manage" to renew the domains. <a href="#" id="link-expiring-domains" class="usa-link">Show expiring domains.</a> Multiple domains will expire soon. Go to "Manage" to renew the domains. <a href="#" id="link-expiring-domains" class="usa-link" tabindex="0" aria-label="Show expiring domains. This will filter the Domains table to only show the expiring domains.">Show expiring domains.</a>
{% endif %} {% endif %}
</p> </p>
</div> </div>
@ -101,6 +101,7 @@
id="domains__usa-button--filter" id="domains__usa-button--filter"
aria-expanded="false" aria-expanded="false"
aria-controls="filter-status" aria-controls="filter-status"
aria-label="Status, list 5 items"
> >
<span class="text-bold display-none" id="domains__filter-indicator"></span> Status <span class="text-bold display-none" id="domains__filter-indicator"></span> Status
<svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24"> <svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24">

View file

@ -0,0 +1,20 @@
{% load field_helpers %}
<div id="member-basic-permissions" class="margin-top-2">
<h2>What permissions do you want to add?</h2>
<p>Configure the permissions for this member. Basic members cannot manage member permissions or organization metadata.</p>
<h3 class="margin-bottom-0">Domains <abbr class="usa-hint usa-hint--required" title="required">*</abbr></h3>
{% with group_classes="bg-gray-1 border-bottom-2px border-base-lighter padding-bottom-2 margin-top-0" add_legend_class="usa-sr-only" %}
{% input_with_errors form.domain_permissions %}
{% endwith %}
<h3 class="margin-bottom-0">Domain requests <abbr class="usa-hint usa-hint--required" title="required">*</abbr></h3>
{% with group_classes="bg-gray-1 border-bottom-2px border-base-lighter padding-bottom-2 margin-top-0" add_legend_class="usa-sr-only" %}
{% input_with_errors form.domain_request_permissions %}
{% endwith %}
<h3 class="margin-bottom-0">Members <abbr class="usa-hint usa-hint--required" title="required">*</abbr></h3>
{% with group_classes="bg-gray-1 border-bottom-2px border-base-lighter padding-bottom-2 margin-top-0" add_legend_class="usa-sr-only" %}
{% input_with_errors form.member_permissions %}
{% endwith %}
</div>

View file

@ -0,0 +1,131 @@
<div class="usa-accordion usa-accordion--show-more">
<h4 class="usa-accordion__heading">
<button
type="button"
class="usa-accordion__button"
aria-expanded="false"
aria-controls="admin-vs-basic-matrix"
>
<svg class="usa-icon font-body-xl text-primary-darker text-middle" aria-hidden="true" focusable="false" role="img">
<use xlink:href="/public/img/sprite.svg#help_outline"></use>
</svg>
<span class="text-middle">
How are admins and basic members different?
</span>
<svg class="usa-icon font-body-xl text-primary text-middle expand-less" aria-hidden="true" focusable="false" role="img">
<use xlink:href="/public/img/sprite.svg#expand_less"></use>
</svg>
<svg class="usa-icon font-body-xl text-primary text-middle expand-more" aria-hidden="true" focusable="false" role="img">
<use xlink:href="/public/img/sprite.svg#expand_more"></use>
</svg>
</button>
</h4>
<div id="admin-vs-basic-matrix" class="usa-accordion__content bg-transparent padding-top-0 padding-left-0 padding-right-0">
<table class="usa-table dotgov-table dotgov-table--cell-padding-2 usa-table--bg-transparent usa-table--full-borderless usa-table--striped font-body-2xs line-height-sans-1 border-top-2px border-base-lighter">
<thead>
<tr>
<th scope="col" role="columnheader">Member actions available</th>
<th scope="col" role="columnheader" class="text-center">Admin</th>
<th scope="col" role="columnheader" class="text-center">Basic</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row" class="text-middle">
View domains they manage
<svg
class="usa-icon usa-tooltip text-primary text-middle no-click-outline-and-cursor-help"
data-position="top"
title="Domains can be assigned after invitation."
focusable="true"
aria-label="Domains can be assigned after invitation."
role="tooltip"
>
<use aria-hidden="true" xlink:href="/public/img/sprite.svg#info_outline"></use>
</svg>
</th>
<td class="text-center">
<svg class="usa-icon font-body-xl text-primary-darker" aria-hidden="true" focusable="false" role="img">
<use xlink:href="/public/img/sprite.svg#check"></use>
</svg>
</td>
<td class="text-center">
<svg class="usa-icon font-body-xl text-primary-darker" aria-hidden="true" focusable="false" role="img">
<use xlink:href="/public/img/sprite.svg#check"></use>
</svg>
</td>
</tr>
<tr>
<th scope="row" class="text-middle">View all domains for the organization</th>
<td class="text-center">
<svg class="usa-icon font-body-xl text-primary-darker" aria-hidden="true" focusable="false" role="img">
<use xlink:href="/public/img/sprite.svg#check"></use>
</svg>
</td>
<td class="text-center text-middle">
<span class="font-body-1 text-primary-darker">Optional</span>
</td>
</tr>
<tr>
<th scope="row" class="text-middle">View all domain requests</th>
<td class="text-center">
<svg class="usa-icon font-body-xl text-primary-darker" aria-hidden="true" focusable="false" role="img">
<use xlink:href="/public/img/sprite.svg#check"></use>
</svg>
</td>
<td class="text-center text-middle">
<span class="font-body-1 text-primary-darker">Optional</span>
</td>
</tr>
<tr>
<th scope="row" class="text-middle">Create domain requests</th>
<td class="text-center">
<svg class="usa-icon font-body-xl text-primary-darker" aria-hidden="true" focusable="false" role="img">
<use xlink:href="/public/img/sprite.svg#check"></use>
</svg>
</td>
<td class="text-center text-middle">
<span class="font-body-1 text-primary-darker">Optional</span>
</td>
</tr>
<tr>
<th scope="row" class="text-middle">View all member permissions</th>
<td class="text-center">
<svg class="usa-icon font-body-xl text-primary-darker" aria-hidden="true" focusable="false" role="img">
<use xlink:href="/public/img/sprite.svg#check"></use>
</svg>
</td>
<td class="text-center text-middle">
<span class="font-body-1 text-primary-darker">Optional</span>
</td>
</tr>
<tr>
<th scope="row" class="text-middle">Manage member permissions</th>
<td class="text-center">
<svg class="usa-icon font-body-xl text-primary-darker" aria-hidden="true" focusable="false" role="img">
<use xlink:href="/public/img/sprite.svg#check"></use>
</svg>
</td>
<td class="text-center">
<svg class="usa-icon font-body-xl text-primary-darker" aria-hidden="true" focusable="false" role="img">
<use xlink:href="/public/img/sprite.svg#cancel"></use>
</svg>
</td>
</tr>
<tr>
<th scope="row" class="text-middle">Manage organization metadata (address)</th>
<td class="text-center">
<svg class="usa-icon font-body-xl text-primary-darker" aria-hidden="true" focusable="false" role="img">
<use xlink:href="/public/img/sprite.svg#check"></use>
</svg>
</td>
<td class="text-center">
<svg class="usa-icon font-body-xl text-primary-darker" aria-hidden="true" focusable="false" role="img">
<use xlink:href="/public/img/sprite.svg#cancel"></use>
</svg>
</td>
</tr>
</tbody>
</table>
</div>
</div>

View file

@ -1,26 +1,33 @@
<h4 class="margin-bottom-0">Member access</h4> <h4 class="margin-bottom-0">Member access</h4>
{% if permissions.roles and 'organization_admin' in permissions.roles %} {% if permissions.roles and 'organization_admin' in permissions.roles %}
<p class="margin-top-0">Admin access</p> <p class="margin-top-0">Admin</p>
{% elif permissions.roles and 'organization_member' in permissions.roles %} {% elif permissions.roles and 'organization_member' in permissions.roles %}
<p class="margin-top-0">Basic access</p> <p class="margin-top-0">Basic</p>
{% else %} {% else %}
<p class="margin-top-0"></p> <p class="margin-top-0"></p>
{% endif %} {% endif %}
<h4 class="margin-bottom-0">Organization domain requests</h4> <h4 class="margin-bottom-0 text-primary">Domains</h4>
{% if member_has_view_all_domains_portfolio_permission %}
<p class="margin-top-0">Viewer, all</p>
{% else %}
<p class="margin-top-0">Viewer, limited</p>
{% endif %}
<h4 class="margin-bottom-0 text-primary">Domain requests</h4>
{% if member_has_edit_request_portfolio_permission %} {% if member_has_edit_request_portfolio_permission %}
<p class="margin-top-0">View all requests plus create requests</p> <p class="margin-top-0">Creator</p>
{% elif member_has_view_all_requests_portfolio_permission %} {% elif member_has_view_all_requests_portfolio_permission %}
<p class="margin-top-0">View all requests</p> <p class="margin-top-0">Viewer</p>
{% else %} {% else %}
<p class="margin-top-0">No access</p> <p class="margin-top-0">No access</p>
{% endif %} {% endif %}
<h4 class="margin-bottom-0">Organization members</h4> <h4 class="margin-bottom-0 text-primary">Members</h4>
{% if member_has_edit_members_portfolio_permission %} {% if member_has_edit_members_portfolio_permission %}
<p class="margin-top-0">View all members plus manage members</p> <p class="margin-top-0">Manager</p>
{% elif member_has_view_members_portfolio_permission %} {% elif member_has_view_members_portfolio_permission %}
<p class="margin-top-0">View all members</p> <p class="margin-top-0">Viewer</p>
{% else %} {% else %}
<p class="margin-top-0">No access</p> <p class="margin-top-0">No access</p>
{% endif %} {% endif %}

View file

@ -38,7 +38,7 @@
</div> </div>
<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 %}"> <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 %}">
<section aria-label="Domains report component" class="margin-top-205"> <section aria-label="Domains report component" class="margin-top-205">
<a href="{% url 'export_members_portfolio' %}" class="usa-button usa-button--unstyled usa-button--with-icon usa-button--justify-right" role="button"> <a href="{% url 'export_members_portfolio' %}" class="usa-button usa-button--unstyled usa-button--with-icon usa-button--justify-right">
<svg class="usa-icon usa-icon--large" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <svg class="usa-icon usa-icon--large" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use> <use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
</svg>Export as CSV </svg>Export as CSV

View file

@ -22,7 +22,7 @@
<h4 class="margin-bottom-0">{{ sub_header_text }}</h4> <h4 class="margin-bottom-0">{{ sub_header_text }}</h4>
{% endif %} {% endif %}
{% if permissions %} {% if permissions %}
{% include "includes/member_permissions.html" with permissions=value %} {% include "includes/member_permissions_summary.html" with permissions=value %}
{% elif domain_mgmt %} {% elif domain_mgmt %}
{% include "includes/member_domain_management.html" with domain_count=value %} {% include "includes/member_domain_management.html" with domain_count=value %}
{% elif address %} {% elif address %}

View file

@ -89,35 +89,10 @@
</fieldset> </fieldset>
<!-- Admin access form --> {% include "includes/member_permissions_matrix.html" %}
<div id="member-admin-permissions" class="margin-top-2">
<h2>Admin access permissions</h2>
<p>Member permissions available for admin-level acccess.</p>
<h3 class=" <!-- Basic access form -->
margin-bottom-0">Organization domain requests</h3> {% include "includes/member_basic_permissions.html" %}
{% with group_classes="usa-form-editable usa-form-editable--no-border bg-gray-1 padding-top-0" %}
{% input_with_errors form.domain_request_permission_admin %}
{% endwith %}
<h3 class="
margin-bottom-0
margin-top-4">Organization members</h3>
{% with group_classes="usa-form-editable usa-form-editable--no-border bg-gray-1 padding-top-0" %}
{% input_with_errors form.member_permission_admin %}
{% endwith %}
</div>
<!-- Basic access form -->
<div id="member-basic-permissions" class="margin-top-2">
<h2>Basic member permissions</h2>
<p>Member permissions available for basic-level acccess.</p>
<h3 class="margin-bottom-0">Organization domain requests</h3>
{% with group_classes="usa-form-editable usa-form-editable--no-border bg-gray-1 padding-top-0" %}
{% input_with_errors form.domain_request_permission_member %}
{% endwith %}
</div>
<!-- Submit/cancel buttons --> <!-- Submit/cancel buttons -->
<div class="margin-top-3"> <div class="margin-top-3">

View file

@ -30,88 +30,67 @@
</nav> </nav>
<!-- Page header --> <!-- Page header -->
{% block new_member_header %}
<h1>Add a new member</h1> <h1>Add a new member</h1>
{% endblock new_member_header %}
<p>After adding a new member, an email invitation will be sent to that user with instructions on how to set up an account. All members must keep their contact information updated and be responsive if contacted by the .gov team.</p>
{% include "includes/required_fields.html" %} {% include "includes/required_fields.html" %}
<form class="usa-form usa-form--large" method="post" id="add_member_form" novalidate> <form class="usa-form usa-form--large" method="post" id="add_member_form" novalidate>
{% csrf_token %}
<fieldset class="usa-fieldset margin-top-2"> <fieldset class="usa-fieldset margin-top-2">
<legend> <legend>
<h2>Email</h2> <h2>Who would you like to add to the organization?</h2>
</legend> </legend>
<!-- Member email --> <!-- Member email -->
{% csrf_token %}
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %} {% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
{% input_with_errors form.email %} {% input_with_errors form.email %}
{% endwith %} {% endwith %}
</fieldset> </fieldset>
<!-- Member access radio buttons (Toggles other sections) --> <!-- Member access radio buttons (Toggles other sections) -->
<fieldset class="usa-fieldset margin-top-2"> <fieldset class="usa-fieldset margin-top-2">
<legend> <legend>
<h2>Member Access</h2> <h2>What level of access would you like to grant this member?</h2>
</legend> </legend>
<em>Select the level of access for this member. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></em> <p class="margin-y-0">Select one <abbr class="usa-hint usa-hint--required" title="required">*</abbr></p>
{% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
{% input_with_errors form.role %} {% input_with_errors form.role %}
{% endwith %} {% endwith %}
</fieldset>
</fieldset> {% include "includes/member_permissions_matrix.html" %}
<!-- Admin access form --> <!-- Basic access form -->
<div id="new-member-admin-permissions" class="margin-top-2"> {% include "includes/member_basic_permissions.html" %}
<h2>Admin access permissions</h2>
<p>Member permissions available for admin-level acccess.</p> <h3 class="margin-bottom-1">Domain management</h3>
<h3 class=" <p class="margin-top-0">After you invite this person to your organization, you can assign domain management permissions on their member profile.</p>
margin-bottom-0">Organization domain requests</h3>
{% with group_classes="usa-form-editable usa-form-editable--no-border bg-gray-1 padding-top-0" %}
{% input_with_errors form.domain_request_permission_admin %}
{% endwith %}
<h3 class=" <!-- Submit/cancel buttons -->
margin-bottom-0 <div class="margin-top-3">
margin-top-4">Organization members</h3> <a
{% with group_classes="usa-form-editable usa-form-editable--no-border bg-gray-1 padding-top-0" %} type="button"
{% input_with_errors form.member_permission_admin %} href="{% url 'members' %}"
{% endwith %} class="usa-button usa-button--outline"
</div> name="btn-cancel-click"
aria-label="Cancel adding new member"
>Cancel
</a>
<a
id="invite_member_trigger"
href="#invite-member-modal"
class="usa-button usa-button--outline margin-top-1 display-none"
aria-controls="invite-member-modal"
data-open-modal
>Trigger invite member modal</a>
<button id="invite_new_member_submit" type="submit" class="usa-button">Invite Member</button>
</div>
<!-- Basic access form -->
<div id="new-member-basic-permissions" class="margin-top-2">
<h2>Basic member permissions</h2>
<p>Member permissions available for basic-level acccess.</p>
<h3 class="margin-bottom-0">Organization domain requests</h3>
{% with group_classes="usa-form-editable usa-form-editable--no-border bg-gray-1 padding-top-0" %}
{% input_with_errors form.domain_request_permission_member %}
{% endwith %}
</div>
<!-- Submit/cancel buttons -->
<div class="margin-top-3">
<a
type="button"
href="{% url 'members' %}"
class="usa-button usa-button--outline"
name="btn-cancel-click"
aria-label="Cancel adding new member"
>Cancel
</a>
<a
id="invite_member_trigger"
href="#invite-member-modal"
class="usa-button usa-button--outline margin-top-1 display-none"
aria-controls="invite-member-modal"
data-open-modal
>Trigger invite member modal</a>
<button id="invite_new_member_submit" type="submit" class="usa-button">Invite Member</button>
</div>
</form> </form>
<div <div

View file

@ -1427,7 +1427,7 @@ class TestPortfolioInvitationAdmin(TestCase):
@less_console_noise_decorator @less_console_noise_decorator
@patch("registrar.admin.send_portfolio_invitation_email") @patch("registrar.admin.send_portfolio_invitation_email")
@patch("django.contrib.messages.warning") # Mock the `messages.error` call @patch("django.contrib.messages.error") # Mock the `messages.error` call
def test_save_exception_generic_error(self, mock_messages_error, mock_send_email): def test_save_exception_generic_error(self, mock_messages_error, mock_send_email):
"""Handle generic exceptions correctly during portfolio invitation.""" """Handle generic exceptions correctly during portfolio invitation."""
self.client.force_login(self.superuser) self.client.force_login(self.superuser)

View file

@ -1,8 +1,11 @@
import unittest import unittest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from datetime import date from datetime import date
from registrar.models.domain import Domain
from registrar.models.user import User
from registrar.models.user_domain_role import UserDomainRole
from registrar.utility.email import EmailSendingError from registrar.utility.email import EmailSendingError
from registrar.utility.email_invitations import send_domain_invitation_email from registrar.utility.email_invitations import send_domain_invitation_email, send_emails_to_domain_managers
from api.tests.common import less_console_noise_decorator from api.tests.common import less_console_noise_decorator
@ -290,16 +293,16 @@ class DomainInvitationEmail(unittest.TestCase):
email = "invitee@example.com" email = "invitee@example.com"
is_member_of_different_org = False is_member_of_different_org = False
mock_send_domain_manager_emails.side_effect = EmailSendingError("Error sending email") # Change the return value to False for mock_send_domain_manager_emails
mock_send_domain_manager_emails.return_value = False
# Call and assert exception # Call and assert that send_domain_invitation_email returns False
with self.assertRaises(EmailSendingError) as context: result = send_domain_invitation_email(
send_domain_invitation_email( email=email,
email=email, requestor=mock_requestor,
requestor=mock_requestor, domains=mock_domain,
domains=mock_domain, is_member_of_different_org=is_member_of_different_org,
is_member_of_different_org=is_member_of_different_org, )
)
# Assertions # Assertions
mock_normalize_domains.assert_called_once_with(mock_domain) mock_normalize_domains.assert_called_once_with(mock_domain)
@ -308,4 +311,161 @@ class DomainInvitationEmail(unittest.TestCase):
email, None, [mock_domain], mock_requestor, is_member_of_different_org email, None, [mock_domain], mock_requestor, is_member_of_different_org
) )
mock_send_invitation_email.assert_called_once_with(email, mock_requestor_email, [mock_domain], None) mock_send_invitation_email.assert_called_once_with(email, mock_requestor_email, [mock_domain], None)
self.assertEqual(str(context.exception), "Error sending email")
# Assert that the result is False
self.assertFalse(result)
@less_console_noise_decorator
@patch("registrar.utility.email_invitations.send_templated_email")
@patch("registrar.models.UserDomainRole.objects.filter")
def test_send_emails_to_domain_managers_all_emails_sent_successfully(self, mock_filter, mock_send_templated_email):
"""Test when all emails are sent successfully."""
# Setup mocks
mock_domain = MagicMock(spec=Domain)
mock_requestor_email = "requestor@example.com"
mock_email = "invitee@example.com"
# Create mock user and UserDomainRole
mock_user = MagicMock(spec=User)
mock_user.email = "manager@example.com"
mock_user_domain_role = MagicMock(spec=UserDomainRole, user=mock_user)
# Mock the filter method to return a list of mock UserDomainRole objects
mock_filter.return_value = [mock_user_domain_role]
# Mock successful email sending
mock_send_templated_email.return_value = None # No exception means success
# Call function
result = send_emails_to_domain_managers(mock_email, mock_requestor_email, mock_domain)
# Assertions
self.assertTrue(result) # All emails should be successfully sent
mock_send_templated_email.assert_called_once_with(
"emails/domain_manager_notification.txt",
"emails/domain_manager_notification_subject.txt",
to_address="manager@example.com",
context={
"domain": mock_domain,
"requestor_email": mock_requestor_email,
"invited_email_address": mock_email,
"domain_manager": mock_user,
"date": date.today(),
},
)
@less_console_noise_decorator
@patch("registrar.utility.email_invitations.send_templated_email")
@patch("registrar.models.UserDomainRole.objects.filter")
def test_send_emails_to_domain_managers_email_send_fails(self, mock_filter, mock_send_templated_email):
"""Test when sending an email fails (raises EmailSendingError)."""
# Setup mocks
mock_domain = MagicMock(spec=Domain)
mock_requestor_email = "requestor@example.com"
mock_email = "invitee@example.com"
# Create mock user and UserDomainRole
mock_user = MagicMock(spec=User)
mock_user.email = "manager@example.com"
mock_user_domain_role = MagicMock(spec=UserDomainRole, user=mock_user)
# Mock the filter method to return a list of mock UserDomainRole objects
mock_filter.return_value = [mock_user_domain_role]
# Mock sending email to raise an EmailSendingError
mock_send_templated_email.side_effect = EmailSendingError("Email sending failed")
# Call function
result = send_emails_to_domain_managers(mock_email, mock_requestor_email, mock_domain)
# Assertions
self.assertFalse(result) # The result should be False as email sending failed
mock_send_templated_email.assert_called_once_with(
"emails/domain_manager_notification.txt",
"emails/domain_manager_notification_subject.txt",
to_address="manager@example.com",
context={
"domain": mock_domain,
"requestor_email": mock_requestor_email,
"invited_email_address": mock_email,
"domain_manager": mock_user,
"date": date.today(),
},
)
@less_console_noise_decorator
@patch("registrar.utility.email_invitations.send_templated_email")
@patch("registrar.models.UserDomainRole.objects.filter")
def test_send_emails_to_domain_managers_no_domain_managers(self, mock_filter, mock_send_templated_email):
"""Test when there are no domain managers."""
# Setup mocks
mock_domain = MagicMock(spec=Domain)
mock_requestor_email = "requestor@example.com"
mock_email = "invitee@example.com"
# Mock no domain managers (empty UserDomainRole queryset)
mock_filter.return_value = []
# Call function
result = send_emails_to_domain_managers(mock_email, mock_requestor_email, mock_domain)
# Assertions
self.assertTrue(result) # No emails to send, so it should return True
mock_send_templated_email.assert_not_called() # No emails should be sent
@less_console_noise_decorator
@patch("registrar.utility.email_invitations.send_templated_email")
@patch("registrar.models.UserDomainRole.objects.filter")
def test_send_emails_to_domain_managers_some_emails_fail(self, mock_filter, mock_send_templated_email):
"""Test when some emails fail to send."""
# Setup mocks
mock_domain = MagicMock(spec=Domain)
mock_requestor_email = "requestor@example.com"
mock_email = "invitee@example.com"
# Create mock users and UserDomainRoles
mock_user_1 = MagicMock(spec=User)
mock_user_1.email = "manager1@example.com"
mock_user_2 = MagicMock(spec=User)
mock_user_2.email = "manager2@example.com"
mock_user_domain_role_1 = MagicMock(spec=UserDomainRole, user=mock_user_1)
mock_user_domain_role_2 = MagicMock(spec=UserDomainRole, user=mock_user_2)
mock_filter.return_value = [mock_user_domain_role_1, mock_user_domain_role_2]
# Mock first email success and second email failure
mock_send_templated_email.side_effect = [None, EmailSendingError("Failed to send email")]
# Call function
result = send_emails_to_domain_managers(mock_email, mock_requestor_email, mock_domain)
# Assertions
self.assertFalse(result) # One email failed, so result should be False
mock_send_templated_email.assert_any_call(
"emails/domain_manager_notification.txt",
"emails/domain_manager_notification_subject.txt",
to_address="manager1@example.com",
context={
"domain": mock_domain,
"requestor_email": mock_requestor_email,
"invited_email_address": mock_email,
"domain_manager": mock_user_1,
"date": date.today(),
},
)
mock_send_templated_email.assert_any_call(
"emails/domain_manager_notification.txt",
"emails/domain_manager_notification_subject.txt",
to_address="manager2@example.com",
context={
"domain": mock_domain,
"requestor_email": mock_requestor_email,
"invited_email_address": mock_email,
"domain_manager": mock_user_2,
"date": date.today(),
},
)

View file

@ -3,6 +3,7 @@
import json import json
from django.test import TestCase, RequestFactory from django.test import TestCase, RequestFactory
from api.views import available from api.views import available
from api.tests.common import less_console_noise_decorator
from registrar.forms.domain_request_wizard import ( from registrar.forms.domain_request_wizard import (
AlternativeDomainForm, AlternativeDomainForm,
@ -39,6 +40,7 @@ class TestFormValidation(MockEppLib):
self.user = get_user_model().objects.create(username="username") self.user = get_user_model().objects.create(username="username")
self.factory = RequestFactory() self.factory = RequestFactory()
@less_console_noise_decorator
def test_org_contact_zip_invalid(self): def test_org_contact_zip_invalid(self):
form = OrganizationContactForm(data={"zipcode": "nah"}) form = OrganizationContactForm(data={"zipcode": "nah"})
self.assertEqual( self.assertEqual(
@ -46,11 +48,13 @@ class TestFormValidation(MockEppLib):
["Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789."], ["Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789."],
) )
@less_console_noise_decorator
def test_org_contact_zip_valid(self): def test_org_contact_zip_valid(self):
for zipcode in ["12345", "12345-6789"]: for zipcode in ["12345", "12345-6789"]:
form = OrganizationContactForm(data={"zipcode": zipcode}) form = OrganizationContactForm(data={"zipcode": zipcode})
self.assertNotIn("zipcode", form.errors) self.assertNotIn("zipcode", form.errors)
@less_console_noise_decorator
def test_website_invalid(self): def test_website_invalid(self):
form = CurrentSitesForm(data={"website": "nah"}) form = CurrentSitesForm(data={"website": "nah"})
self.assertEqual( self.assertEqual(
@ -58,33 +62,39 @@ class TestFormValidation(MockEppLib):
["Enter your organization's current website in the required format, like example.com."], ["Enter your organization's current website in the required format, like example.com."],
) )
@less_console_noise_decorator
def test_website_valid(self): def test_website_valid(self):
form = CurrentSitesForm(data={"website": "hyphens-rule.gov.uk"}) form = CurrentSitesForm(data={"website": "hyphens-rule.gov.uk"})
self.assertEqual(len(form.errors), 0) self.assertEqual(len(form.errors), 0)
@less_console_noise_decorator
def test_website_scheme_valid(self): def test_website_scheme_valid(self):
form = CurrentSitesForm(data={"website": "http://hyphens-rule.gov.uk"}) form = CurrentSitesForm(data={"website": "http://hyphens-rule.gov.uk"})
self.assertEqual(len(form.errors), 0) self.assertEqual(len(form.errors), 0)
form = CurrentSitesForm(data={"website": "https://hyphens-rule.gov.uk"}) form = CurrentSitesForm(data={"website": "https://hyphens-rule.gov.uk"})
self.assertEqual(len(form.errors), 0) self.assertEqual(len(form.errors), 0)
@less_console_noise_decorator
def test_requested_domain_valid(self): def test_requested_domain_valid(self):
"""Just a valid domain name with no .gov at the end.""" """Just a valid domain name with no .gov at the end."""
form = DotGovDomainForm(data={"requested_domain": "top-level-agency"}) form = DotGovDomainForm(data={"requested_domain": "top-level-agency"})
self.assertEqual(len(form.errors), 0) self.assertEqual(len(form.errors), 0)
@less_console_noise_decorator
def test_requested_domain_starting_www(self): def test_requested_domain_starting_www(self):
"""Test a valid domain name with .www at the beginning.""" """Test a valid domain name with .www at the beginning."""
form = DotGovDomainForm(data={"requested_domain": "www.top-level-agency"}) form = DotGovDomainForm(data={"requested_domain": "www.top-level-agency"})
self.assertEqual(len(form.errors), 0) self.assertEqual(len(form.errors), 0)
self.assertEqual(form.cleaned_data["requested_domain"], "top-level-agency") self.assertEqual(form.cleaned_data["requested_domain"], "top-level-agency")
@less_console_noise_decorator
def test_requested_domain_ending_dotgov(self): def test_requested_domain_ending_dotgov(self):
"""Just a valid domain name with .gov at the end.""" """Just a valid domain name with .gov at the end."""
form = DotGovDomainForm(data={"requested_domain": "top-level-agency.gov"}) form = DotGovDomainForm(data={"requested_domain": "top-level-agency.gov"})
self.assertEqual(len(form.errors), 0) self.assertEqual(len(form.errors), 0)
self.assertEqual(form.cleaned_data["requested_domain"], "top-level-agency") self.assertEqual(form.cleaned_data["requested_domain"], "top-level-agency")
@less_console_noise_decorator
def test_requested_domain_ending_dotcom_invalid(self): def test_requested_domain_ending_dotcom_invalid(self):
"""don't accept domains ending other than .gov.""" """don't accept domains ending other than .gov."""
form = DotGovDomainForm(data={"requested_domain": "top-level-agency.com"}) form = DotGovDomainForm(data={"requested_domain": "top-level-agency.com"})
@ -93,6 +103,7 @@ class TestFormValidation(MockEppLib):
["Enter the .gov domain you want without any periods."], ["Enter the .gov domain you want without any periods."],
) )
@less_console_noise_decorator
def test_requested_domain_errors_consistent(self): def test_requested_domain_errors_consistent(self):
"""Tests if the errors on submit and with the check availability buttons are consistent """Tests if the errors on submit and with the check availability buttons are consistent
for requested_domains for requested_domains
@ -150,6 +161,7 @@ class TestFormValidation(MockEppLib):
# for good measure, test if the two objects are equal anyway # for good measure, test if the two objects are equal anyway
self.assertEqual([json_error], form_error) self.assertEqual([json_error], form_error)
@less_console_noise_decorator
def test_alternate_domain_errors_consistent(self): def test_alternate_domain_errors_consistent(self):
"""Tests if the errors on submit and with the check availability buttons are consistent """Tests if the errors on submit and with the check availability buttons are consistent
for alternative_domains for alternative_domains
@ -200,6 +212,7 @@ class TestFormValidation(MockEppLib):
# for good measure, test if the two objects are equal anyway # for good measure, test if the two objects are equal anyway
self.assertEqual([json_error], form_error) self.assertEqual([json_error], form_error)
@less_console_noise_decorator
def test_requested_domain_two_dots_invalid(self): def test_requested_domain_two_dots_invalid(self):
"""don't accept domains that are subdomains""" """don't accept domains that are subdomains"""
form = DotGovDomainForm(data={"requested_domain": "sub.top-level-agency.gov"}) form = DotGovDomainForm(data={"requested_domain": "sub.top-level-agency.gov"})
@ -218,6 +231,7 @@ class TestFormValidation(MockEppLib):
["Enter the .gov domain you want without any periods."], ["Enter the .gov domain you want without any periods."],
) )
@less_console_noise_decorator
def test_requested_domain_invalid_characters(self): def test_requested_domain_invalid_characters(self):
"""must be a valid .gov domain name.""" """must be a valid .gov domain name."""
form = DotGovDomainForm(data={"requested_domain": "underscores_forever"}) form = DotGovDomainForm(data={"requested_domain": "underscores_forever"})
@ -226,6 +240,7 @@ class TestFormValidation(MockEppLib):
["Enter a domain using only letters, numbers, or hyphens (though we don't recommend using hyphens)."], ["Enter a domain using only letters, numbers, or hyphens (though we don't recommend using hyphens)."],
) )
@less_console_noise_decorator
def test_senior_official_email_invalid(self): def test_senior_official_email_invalid(self):
"""must be a valid email address.""" """must be a valid email address."""
form = SeniorOfficialForm(data={"email": "boss@boss"}) form = SeniorOfficialForm(data={"email": "boss@boss"})
@ -234,6 +249,7 @@ class TestFormValidation(MockEppLib):
["Enter an email address in the required format, like name@example.com."], ["Enter an email address in the required format, like name@example.com."],
) )
@less_console_noise_decorator
def test_purpose_form_character_count_invalid(self): def test_purpose_form_character_count_invalid(self):
"""Response must be less than 2000 characters.""" """Response must be less than 2000 characters."""
form = PurposeForm( form = PurposeForm(
@ -281,6 +297,7 @@ class TestFormValidation(MockEppLib):
["Response must be less than 2000 characters."], ["Response must be less than 2000 characters."],
) )
@less_console_noise_decorator
def test_anything_else_form_about_your_organization_character_count_invalid(self): def test_anything_else_form_about_your_organization_character_count_invalid(self):
"""Response must be less than 2000 characters.""" """Response must be less than 2000 characters."""
form = AnythingElseForm( form = AnythingElseForm(
@ -327,6 +344,7 @@ class TestFormValidation(MockEppLib):
["Response must be less than 2000 characters."], ["Response must be less than 2000 characters."],
) )
@less_console_noise_decorator
def test_anything_else_form_character_count_invalid(self): def test_anything_else_form_character_count_invalid(self):
"""Response must be less than 2000 characters.""" """Response must be less than 2000 characters."""
form = AboutYourOrganizationForm( form = AboutYourOrganizationForm(
@ -375,6 +393,7 @@ class TestFormValidation(MockEppLib):
["Response must be less than 2000 characters."], ["Response must be less than 2000 characters."],
) )
@less_console_noise_decorator
def test_other_contact_email_invalid(self): def test_other_contact_email_invalid(self):
"""must be a valid email address.""" """must be a valid email address."""
form = OtherContactsForm(data={"email": "splendid@boss"}) form = OtherContactsForm(data={"email": "splendid@boss"})
@ -383,11 +402,13 @@ class TestFormValidation(MockEppLib):
["Enter an email address in the required format, like name@example.com."], ["Enter an email address in the required format, like name@example.com."],
) )
@less_console_noise_decorator
def test_other_contact_phone_invalid(self): def test_other_contact_phone_invalid(self):
"""Must be a valid phone number.""" """Must be a valid phone number."""
form = OtherContactsForm(data={"phone": "super@boss"}) form = OtherContactsForm(data={"phone": "super@boss"})
self.assertTrue(form.errors["phone"][0].startswith("Enter a valid 10-digit phone number.")) self.assertTrue(form.errors["phone"][0].startswith("Enter a valid 10-digit phone number."))
@less_console_noise_decorator
def test_requirements_form_blank(self): def test_requirements_form_blank(self):
"""Requirements box unchecked is an error.""" """Requirements box unchecked is an error."""
form = RequirementsForm(data={}) form = RequirementsForm(data={})
@ -396,6 +417,7 @@ class TestFormValidation(MockEppLib):
["Check the box if you read and agree to the requirements for operating a .gov domain."], ["Check the box if you read and agree to the requirements for operating a .gov domain."],
) )
@less_console_noise_decorator
def test_requirements_form_unchecked(self): def test_requirements_form_unchecked(self):
"""Requirements box unchecked is an error.""" """Requirements box unchecked is an error."""
form = RequirementsForm(data={"is_policy_acknowledged": False}) form = RequirementsForm(data={"is_policy_acknowledged": False})
@ -404,6 +426,7 @@ class TestFormValidation(MockEppLib):
["Check the box if you read and agree to the requirements for operating a .gov domain."], ["Check the box if you read and agree to the requirements for operating a .gov domain."],
) )
@less_console_noise_decorator
def test_tribal_government_unrecognized(self): def test_tribal_government_unrecognized(self):
"""Not state or federally recognized is an error.""" """Not state or federally recognized is an error."""
form = TribalGovernmentForm(data={"state_recognized": False, "federally_recognized": False}) form = TribalGovernmentForm(data={"state_recognized": False, "federally_recognized": False})
@ -411,10 +434,12 @@ class TestFormValidation(MockEppLib):
class TestContactForm(TestCase): class TestContactForm(TestCase):
@less_console_noise_decorator
def test_contact_form_email_invalid(self): def test_contact_form_email_invalid(self):
form = ContactForm(data={"email": "example.net"}) form = ContactForm(data={"email": "example.net"})
self.assertEqual(form.errors["email"], ["Enter a valid email address."]) self.assertEqual(form.errors["email"], ["Enter a valid email address."])
@less_console_noise_decorator
def test_contact_form_email_invalid2(self): def test_contact_form_email_invalid2(self):
form = ContactForm(data={"email": "@"}) form = ContactForm(data={"email": "@"})
self.assertEqual(form.errors["email"], ["Enter a valid email address."]) self.assertEqual(form.errors["email"], ["Enter a valid email address."])
@ -442,7 +467,6 @@ class TestBasePortfolioMemberForms(TestCase):
if instance is not None: if instance is not None:
form = form_class(data=data, instance=instance) form = form_class(data=data, instance=instance)
else: else:
print("no instance")
form = form_class(data=data) form = form_class(data=data)
self.assertTrue(form.is_valid(), f"Form {form_class.__name__} failed validation with data: {data}") self.assertTrue(form.is_valid(), f"Form {form_class.__name__} failed validation with data: {data}")
return form return form
@ -465,38 +489,30 @@ class TestBasePortfolioMemberForms(TestCase):
for permission in expected_permissions: for permission in expected_permissions:
self.assertIn(permission, cleaned_data["additional_permissions"]) self.assertIn(permission, cleaned_data["additional_permissions"])
def test_required_field_for_admin(self): @less_console_noise_decorator
"""Test that required fields are validated for an admin role."""
data = {
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value,
"domain_request_permission_admin": "", # Simulate missing field
"member_permission_admin": "", # Simulate missing field
}
# Check required fields for all forms
self._assert_form_has_error(PortfolioMemberForm, data, "domain_request_permission_admin")
self._assert_form_has_error(PortfolioMemberForm, data, "member_permission_admin")
self._assert_form_has_error(PortfolioInvitedMemberForm, data, "domain_request_permission_admin")
self._assert_form_has_error(PortfolioInvitedMemberForm, data, "member_permission_admin")
self._assert_form_has_error(PortfolioNewMemberForm, data, "domain_request_permission_admin")
self._assert_form_has_error(PortfolioNewMemberForm, data, "member_permission_admin")
def test_required_field_for_member(self): def test_required_field_for_member(self):
"""Test that required fields are validated for a member role.""" """Test that required fields are validated for a member role."""
data = { data = {
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
"domain_request_permission_member": "", # Simulate missing field "domain_request_permissions": "", # Simulate missing field
"domain_permissions": "", # Simulate missing field
"member_permissions": "", # Simulate missing field
} }
# Check required fields for all forms # Check required fields for all forms
self._assert_form_has_error(PortfolioMemberForm, data, "domain_request_permission_member") self._assert_form_has_error(PortfolioMemberForm, data, "domain_request_permissions")
self._assert_form_has_error(PortfolioInvitedMemberForm, data, "domain_request_permission_member") self._assert_form_has_error(PortfolioMemberForm, data, "domain_permissions")
self._assert_form_has_error(PortfolioNewMemberForm, data, "domain_request_permission_member") self._assert_form_has_error(PortfolioMemberForm, data, "member_permissions")
self._assert_form_has_error(PortfolioInvitedMemberForm, data, "domain_request_permissions")
self._assert_form_has_error(PortfolioInvitedMemberForm, data, "domain_permissions")
self._assert_form_has_error(PortfolioInvitedMemberForm, data, "member_permissions")
self._assert_form_has_error(PortfolioNewMemberForm, data, "domain_request_permissions")
self._assert_form_has_error(PortfolioNewMemberForm, data, "domain_permissions")
self._assert_form_has_error(PortfolioNewMemberForm, data, "member_permissions")
def test_clean_validates_required_fields_for_role(self): @less_console_noise_decorator
"""Test that the `clean` method validates the correct fields for each role. def test_clean_validates_required_fields_for_admin_role(self):
"""Test that the `clean` method validates the correct fields for admin role.
For PortfolioMemberForm and PortfolioInvitedMemberForm, we pass an object as the instance to the form. For PortfolioMemberForm and PortfolioInvitedMemberForm, we pass an object as the instance to the form.
For UserPortfolioPermissionChoices, we add a portfolio and an email to the POST data. For UserPortfolioPermissionChoices, we add a portfolio and an email to the POST data.
@ -510,34 +526,86 @@ class TestBasePortfolioMemberForms(TestCase):
data = { data = {
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value, "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value,
"domain_request_permission_admin": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
"member_permission_admin": UserPortfolioPermissionChoices.EDIT_MEMBERS.value,
} }
# Check form validity for all forms # Check form validity for all forms
form = self._assert_form_is_valid(PortfolioMemberForm, data, user_portfolio_permission) form = self._assert_form_is_valid(PortfolioMemberForm, data, user_portfolio_permission)
cleaned_data = form.cleaned_data cleaned_data = form.cleaned_data
self.assertEqual(cleaned_data["roles"], [UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value]) self.assertEqual(cleaned_data["roles"], [UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value])
self.assertEqual(cleaned_data["additional_permissions"], [UserPortfolioPermissionChoices.EDIT_MEMBERS])
form = self._assert_form_is_valid(PortfolioInvitedMemberForm, data, portfolio_invitation) form = self._assert_form_is_valid(PortfolioInvitedMemberForm, data, portfolio_invitation)
cleaned_data = form.cleaned_data cleaned_data = form.cleaned_data
self.assertEqual(cleaned_data["roles"], [UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value]) self.assertEqual(cleaned_data["roles"], [UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value])
self.assertEqual(cleaned_data["additional_permissions"], [UserPortfolioPermissionChoices.EDIT_MEMBERS])
data = { data = {
"email": "hi@ho.com", "email": "hi@ho.com",
"portfolio": self.portfolio.id, "portfolio": self.portfolio.id,
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value, "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value,
"domain_request_permission_admin": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
"member_permission_admin": UserPortfolioPermissionChoices.EDIT_MEMBERS.value,
} }
form = self._assert_form_is_valid(PortfolioNewMemberForm, data) form = self._assert_form_is_valid(PortfolioNewMemberForm, data)
cleaned_data = form.cleaned_data cleaned_data = form.cleaned_data
self.assertEqual(cleaned_data["roles"], [UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value]) self.assertEqual(cleaned_data["roles"], [UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value])
self.assertEqual(cleaned_data["additional_permissions"], [UserPortfolioPermissionChoices.EDIT_MEMBERS])
@less_console_noise_decorator
def test_clean_validates_required_fields_for_basic_role(self):
"""Test that the `clean` method validates the correct fields for basic role.
For PortfolioMemberForm and PortfolioInvitedMemberForm, we pass an object as the instance to the form.
For UserPortfolioPermissionChoices, we add a portfolio and an email to the POST data.
These things are handled in the views."""
user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
portfolio=self.portfolio, user=self.user
)
portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(portfolio=self.portfolio, email="hi@ho")
data = {
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
"domain_request_permissions": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
"domain_permissions": UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value,
"member_permissions": UserPortfolioPermissionChoices.VIEW_MEMBERS.value,
}
# Check form validity for all forms
form = self._assert_form_is_valid(PortfolioMemberForm, data, user_portfolio_permission)
cleaned_data = form.cleaned_data
self.assertEqual(cleaned_data["roles"], [UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value])
self.assertEqual(
cleaned_data["domain_request_permissions"], UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value
)
self.assertEqual(cleaned_data["domain_permissions"], UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value)
self.assertEqual(cleaned_data["member_permissions"], UserPortfolioPermissionChoices.VIEW_MEMBERS.value)
form = self._assert_form_is_valid(PortfolioInvitedMemberForm, data, portfolio_invitation)
cleaned_data = form.cleaned_data
self.assertEqual(cleaned_data["roles"], [UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value])
self.assertEqual(
cleaned_data["domain_request_permissions"], UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value
)
self.assertEqual(cleaned_data["domain_permissions"], UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value)
self.assertEqual(cleaned_data["member_permissions"], UserPortfolioPermissionChoices.VIEW_MEMBERS.value)
data = {
"email": "hi@ho.com",
"portfolio": self.portfolio.id,
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
"domain_request_permissions": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
"domain_permissions": UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value,
"member_permissions": UserPortfolioPermissionChoices.VIEW_MEMBERS.value,
}
form = self._assert_form_is_valid(PortfolioNewMemberForm, data)
cleaned_data = form.cleaned_data
self.assertEqual(cleaned_data["roles"], [UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value])
self.assertEqual(
cleaned_data["domain_request_permissions"], UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value
)
self.assertEqual(cleaned_data["domain_permissions"], UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value)
self.assertEqual(cleaned_data["member_permissions"], UserPortfolioPermissionChoices.VIEW_MEMBERS.value)
@less_console_noise_decorator
def test_clean_member_permission_edgecase(self): def test_clean_member_permission_edgecase(self):
"""Test that the clean method correctly handles the special "no_access" value for members. """Test that the clean method correctly handles the special "no_access" value for members.
We'll need to add a portfolio, which in the app is handled by the view post.""" We'll need to add a portfolio, which in the app is handled by the view post."""
@ -549,38 +617,38 @@ class TestBasePortfolioMemberForms(TestCase):
data = { data = {
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
"domain_request_permission_member": "no_access", # Simulate no access permission "domain_request_permissions": "no_access", # Simulate no access permission
"domain_permissions": UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value,
"member_permissions": UserPortfolioPermissionChoices.VIEW_MEMBERS.value,
} }
form = self._assert_form_is_valid(PortfolioMemberForm, data, user_portfolio_permission) form = self._assert_form_is_valid(PortfolioMemberForm, data, user_portfolio_permission)
cleaned_data = form.cleaned_data cleaned_data = form.cleaned_data
self.assertEqual(cleaned_data["domain_request_permission_member"], None) self.assertEqual(cleaned_data["domain_request_permissions"], None)
form = self._assert_form_is_valid(PortfolioInvitedMemberForm, data, portfolio_invitation) form = self._assert_form_is_valid(PortfolioInvitedMemberForm, data, portfolio_invitation)
cleaned_data = form.cleaned_data cleaned_data = form.cleaned_data
self.assertEqual(cleaned_data["domain_request_permission_member"], None) self.assertEqual(cleaned_data["domain_request_permissions"], None)
@less_console_noise_decorator
def test_map_instance_to_initial_admin_role(self): def test_map_instance_to_initial_admin_role(self):
"""Test that instance data is correctly mapped to the initial form values for an admin role.""" """Test that instance data is correctly mapped to the initial form values for an admin role."""
user_portfolio_permission = UserPortfolioPermission( user_portfolio_permission = UserPortfolioPermission(
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[UserPortfolioPermissionChoices.VIEW_MEMBERS],
) )
portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create( portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(
portfolio=self.portfolio, portfolio=self.portfolio,
email="hi@ho", email="hi@ho",
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[UserPortfolioPermissionChoices.VIEW_MEMBERS],
) )
expected_initial_data = { expected_initial_data = {
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN, "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
"domain_request_permission_admin": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
"member_permission_admin": UserPortfolioPermissionChoices.VIEW_MEMBERS,
} }
self._assert_initial_data(PortfolioMemberForm, user_portfolio_permission, expected_initial_data) self._assert_initial_data(PortfolioMemberForm, user_portfolio_permission, expected_initial_data)
self._assert_initial_data(PortfolioInvitedMemberForm, portfolio_invitation, expected_initial_data) self._assert_initial_data(PortfolioInvitedMemberForm, portfolio_invitation, expected_initial_data)
@less_console_noise_decorator
def test_map_instance_to_initial_member_role(self): def test_map_instance_to_initial_member_role(self):
"""Test that instance data is correctly mapped to the initial form values for a member role.""" """Test that instance data is correctly mapped to the initial form values for a member role."""
user_portfolio_permission = UserPortfolioPermission( user_portfolio_permission = UserPortfolioPermission(
@ -595,19 +663,21 @@ class TestBasePortfolioMemberForms(TestCase):
) )
expected_initial_data = { expected_initial_data = {
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER, "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER,
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, "domain_request_permissions": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
} }
self._assert_initial_data(PortfolioMemberForm, user_portfolio_permission, expected_initial_data) self._assert_initial_data(PortfolioMemberForm, user_portfolio_permission, expected_initial_data)
self._assert_initial_data(PortfolioInvitedMemberForm, portfolio_invitation, expected_initial_data) self._assert_initial_data(PortfolioInvitedMemberForm, portfolio_invitation, expected_initial_data)
def test_invalid_data_for_admin(self): @less_console_noise_decorator
"""Test invalid form submission for an admin role with missing permissions.""" def test_invalid_data_for_member(self):
"""Test invalid form submission for a member role with missing permissions."""
data = { data = {
"email": "hi@ho.com", "email": "hi@ho.com",
"portfolio": self.portfolio.id, "portfolio": self.portfolio.id,
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value, "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
"domain_request_permission_admin": "", # Missing field "domain_request_permissions": "", # Missing field
"member_permission_admin": "", # Missing field "member_permissions": "", # Missing field
"domain_permissions": "", # Missing field
} }
self._assert_form_has_error(PortfolioMemberForm, data, "domain_request_permission_admin") self._assert_form_has_error(PortfolioMemberForm, data, "domain_request_permissions")
self._assert_form_has_error(PortfolioInvitedMemberForm, data, "member_permission_admin") self._assert_form_has_error(PortfolioInvitedMemberForm, data, "member_permissions")

View file

@ -164,6 +164,7 @@ class TestPortfolioInvitations(TestCase):
DomainInformation.objects.all().delete() DomainInformation.objects.all().delete()
Domain.objects.all().delete() Domain.objects.all().delete()
UserPortfolioPermission.objects.all().delete() UserPortfolioPermission.objects.all().delete()
UserDomainRole.objects.all().delete()
Portfolio.objects.all().delete() Portfolio.objects.all().delete()
PortfolioInvitation.objects.all().delete() PortfolioInvitation.objects.all().delete()
User.objects.all().delete() User.objects.all().delete()
@ -442,6 +443,294 @@ class TestPortfolioInvitations(TestCase):
pass pass
@less_console_noise_decorator
def test_delete_portfolio_invitation_deletes_portfolio_domain_invitations(self):
"""Deleting a portfolio invitation causes domain invitations for the same email on the same
portfolio to be canceled."""
email_with_no_user = "email-with-no-user@email.gov"
domain_in_portfolio_1, _ = Domain.objects.get_or_create(
name="domain_in_portfolio_1.gov", state=Domain.State.READY
)
DomainInformation.objects.get_or_create(
creator=self.user, domain=domain_in_portfolio_1, portfolio=self.portfolio
)
invite_1, _ = DomainInvitation.objects.get_or_create(email=email_with_no_user, domain=domain_in_portfolio_1)
domain_in_portfolio_2, _ = Domain.objects.get_or_create(
name="domain_in_portfolio_and_invited_2.gov", state=Domain.State.READY
)
DomainInformation.objects.get_or_create(
creator=self.user, domain=domain_in_portfolio_2, portfolio=self.portfolio
)
invite_2, _ = DomainInvitation.objects.get_or_create(email=email_with_no_user, domain=domain_in_portfolio_2)
domain_not_in_portfolio, _ = Domain.objects.get_or_create(
name="domain_not_in_portfolio.gov", state=Domain.State.READY
)
DomainInformation.objects.get_or_create(creator=self.user, domain=domain_not_in_portfolio)
invite_3, _ = DomainInvitation.objects.get_or_create(email=email_with_no_user, domain=domain_not_in_portfolio)
invitation_of_email_with_no_user, _ = PortfolioInvitation.objects.get_or_create(
email=email_with_no_user,
portfolio=self.portfolio,
roles=[self.portfolio_role_base, self.portfolio_role_admin],
additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
)
# The domain invitations start off as INVITED
self.assertEqual(invite_1.status, DomainInvitation.DomainInvitationStatus.INVITED)
self.assertEqual(invite_2.status, DomainInvitation.DomainInvitationStatus.INVITED)
self.assertEqual(invite_3.status, DomainInvitation.DomainInvitationStatus.INVITED)
# Delete member (invite)
invitation_of_email_with_no_user.delete()
# Reload the objects from the database
invite_1 = DomainInvitation.objects.get(pk=invite_1.pk)
invite_2 = DomainInvitation.objects.get(pk=invite_2.pk)
invite_3 = DomainInvitation.objects.get(pk=invite_3.pk)
# The domain invitations to the portfolio domains have been canceled
self.assertEqual(invite_1.status, DomainInvitation.DomainInvitationStatus.CANCELED)
self.assertEqual(invite_2.status, DomainInvitation.DomainInvitationStatus.CANCELED)
# Invite 3 is unaffected
self.assertEqual(invite_3.status, DomainInvitation.DomainInvitationStatus.INVITED)
@less_console_noise_decorator
def test_deleting_a_retrieved_invitation_has_no_side_effects(self):
"""Deleting a retrieved portfolio invitation causes no side effects."""
domain_in_portfolio_1, _ = Domain.objects.get_or_create(
name="domain_in_portfolio_1.gov", state=Domain.State.READY
)
DomainInformation.objects.get_or_create(
creator=self.user, domain=domain_in_portfolio_1, portfolio=self.portfolio
)
invite_1, _ = DomainInvitation.objects.get_or_create(email=self.email, domain=domain_in_portfolio_1)
domain_in_portfolio_2, _ = Domain.objects.get_or_create(
name="domain_in_portfolio_and_invited_2.gov", state=Domain.State.READY
)
DomainInformation.objects.get_or_create(
creator=self.user, domain=domain_in_portfolio_2, portfolio=self.portfolio
)
invite_2, _ = DomainInvitation.objects.get_or_create(email=self.email, domain=domain_in_portfolio_2)
domain_in_portfolio_3, _ = Domain.objects.get_or_create(
name="domain_in_portfolio_3.gov", state=Domain.State.READY
)
DomainInformation.objects.get_or_create(
creator=self.user, domain=domain_in_portfolio_3, portfolio=self.portfolio
)
UserDomainRole.objects.get_or_create(
user=self.user, domain=domain_in_portfolio_3, role=UserDomainRole.Roles.MANAGER
)
domain_in_portfolio_4, _ = Domain.objects.get_or_create(
name="domain_in_portfolio_and_invited_4.gov", state=Domain.State.READY
)
DomainInformation.objects.get_or_create(
creator=self.user, domain=domain_in_portfolio_4, portfolio=self.portfolio
)
UserDomainRole.objects.get_or_create(
user=self.user, domain=domain_in_portfolio_4, role=UserDomainRole.Roles.MANAGER
)
domain_not_in_portfolio_1, _ = Domain.objects.get_or_create(
name="domain_not_in_portfolio.gov", state=Domain.State.READY
)
DomainInformation.objects.get_or_create(creator=self.user, domain=domain_not_in_portfolio_1)
invite_3, _ = DomainInvitation.objects.get_or_create(email=self.email, domain=domain_not_in_portfolio_1)
domain_not_in_portfolio_2, _ = Domain.objects.get_or_create(
name="domain_not_in_portfolio_2.gov", state=Domain.State.READY
)
DomainInformation.objects.get_or_create(creator=self.user, domain=domain_not_in_portfolio_2)
UserDomainRole.objects.get_or_create(
user=self.user, domain=domain_not_in_portfolio_2, role=UserDomainRole.Roles.MANAGER
)
# The domain invitations start off as INVITED
self.assertEqual(invite_1.status, DomainInvitation.DomainInvitationStatus.INVITED)
self.assertEqual(invite_2.status, DomainInvitation.DomainInvitationStatus.INVITED)
self.assertEqual(invite_3.status, DomainInvitation.DomainInvitationStatus.INVITED)
# The user domain roles exist
self.assertTrue(
UserDomainRole.objects.filter(
user=self.user,
domain=domain_in_portfolio_3,
).exists()
)
self.assertTrue(
UserDomainRole.objects.filter(
user=self.user,
domain=domain_in_portfolio_4,
).exists()
)
self.assertTrue(
UserDomainRole.objects.filter(
user=self.user,
domain=domain_not_in_portfolio_2,
).exists()
)
# retrieve the invitation
self.invitation.retrieve()
self.invitation.save()
# Delete member (invite)
self.invitation.delete()
# Reload the objects from the database
invite_1 = DomainInvitation.objects.get(pk=invite_1.pk)
invite_2 = DomainInvitation.objects.get(pk=invite_2.pk)
invite_3 = DomainInvitation.objects.get(pk=invite_3.pk)
# Test that no side effects have been triggered
self.assertEqual(invite_1.status, DomainInvitation.DomainInvitationStatus.INVITED)
self.assertEqual(invite_2.status, DomainInvitation.DomainInvitationStatus.INVITED)
self.assertEqual(invite_3.status, DomainInvitation.DomainInvitationStatus.INVITED)
self.assertTrue(
UserDomainRole.objects.filter(
user=self.user,
domain=domain_in_portfolio_3,
).exists()
)
self.assertTrue(
UserDomainRole.objects.filter(
user=self.user,
domain=domain_in_portfolio_4,
).exists()
)
self.assertTrue(
UserDomainRole.objects.filter(
user=self.user,
domain=domain_not_in_portfolio_2,
).exists()
)
@less_console_noise_decorator
def test_delete_portfolio_invitation_deletes_user_domain_roles(self):
"""Deleting a portfolio invitation causes domain invitations for the same email on the same
portfolio to be canceled, also deletes any exiting user domain roles on the portfolio for the
user if the user exists."""
domain_in_portfolio_1, _ = Domain.objects.get_or_create(
name="domain_in_portfolio_1.gov", state=Domain.State.READY
)
DomainInformation.objects.get_or_create(
creator=self.user, domain=domain_in_portfolio_1, portfolio=self.portfolio
)
invite_1, _ = DomainInvitation.objects.get_or_create(email=self.email, domain=domain_in_portfolio_1)
domain_in_portfolio_2, _ = Domain.objects.get_or_create(
name="domain_in_portfolio_and_invited_2.gov", state=Domain.State.READY
)
DomainInformation.objects.get_or_create(
creator=self.user, domain=domain_in_portfolio_2, portfolio=self.portfolio
)
invite_2, _ = DomainInvitation.objects.get_or_create(email=self.email, domain=domain_in_portfolio_2)
domain_in_portfolio_3, _ = Domain.objects.get_or_create(
name="domain_in_portfolio_3.gov", state=Domain.State.READY
)
DomainInformation.objects.get_or_create(
creator=self.user, domain=domain_in_portfolio_3, portfolio=self.portfolio
)
UserDomainRole.objects.get_or_create(
user=self.user, domain=domain_in_portfolio_3, role=UserDomainRole.Roles.MANAGER
)
domain_in_portfolio_4, _ = Domain.objects.get_or_create(
name="domain_in_portfolio_and_invited_4.gov", state=Domain.State.READY
)
DomainInformation.objects.get_or_create(
creator=self.user, domain=domain_in_portfolio_4, portfolio=self.portfolio
)
UserDomainRole.objects.get_or_create(
user=self.user, domain=domain_in_portfolio_4, role=UserDomainRole.Roles.MANAGER
)
domain_not_in_portfolio_1, _ = Domain.objects.get_or_create(
name="domain_not_in_portfolio.gov", state=Domain.State.READY
)
DomainInformation.objects.get_or_create(creator=self.user, domain=domain_not_in_portfolio_1)
invite_3, _ = DomainInvitation.objects.get_or_create(email=self.email, domain=domain_not_in_portfolio_1)
domain_not_in_portfolio_2, _ = Domain.objects.get_or_create(
name="domain_not_in_portfolio_2.gov", state=Domain.State.READY
)
DomainInformation.objects.get_or_create(creator=self.user, domain=domain_not_in_portfolio_2)
UserDomainRole.objects.get_or_create(
user=self.user, domain=domain_not_in_portfolio_2, role=UserDomainRole.Roles.MANAGER
)
# The domain invitations start off as INVITED
self.assertEqual(invite_1.status, DomainInvitation.DomainInvitationStatus.INVITED)
self.assertEqual(invite_2.status, DomainInvitation.DomainInvitationStatus.INVITED)
self.assertEqual(invite_3.status, DomainInvitation.DomainInvitationStatus.INVITED)
# The user domain roles exist
self.assertTrue(
UserDomainRole.objects.filter(
user=self.user,
domain=domain_in_portfolio_3,
).exists()
)
self.assertTrue(
UserDomainRole.objects.filter(
user=self.user,
domain=domain_in_portfolio_4,
).exists()
)
self.assertTrue(
UserDomainRole.objects.filter(
user=self.user,
domain=domain_not_in_portfolio_2,
).exists()
)
# Delete member (invite)
self.invitation.delete()
# Reload the objects from the database
invite_1 = DomainInvitation.objects.get(pk=invite_1.pk)
invite_2 = DomainInvitation.objects.get(pk=invite_2.pk)
invite_3 = DomainInvitation.objects.get(pk=invite_3.pk)
# The domain invitations to the portfolio domains have been canceled
self.assertEqual(invite_1.status, DomainInvitation.DomainInvitationStatus.CANCELED)
self.assertEqual(invite_2.status, DomainInvitation.DomainInvitationStatus.CANCELED)
# Invite 3 is unaffected
self.assertEqual(invite_3.status, DomainInvitation.DomainInvitationStatus.INVITED)
# The user domain roles have been deleted for the domains in portfolio
self.assertFalse(
UserDomainRole.objects.filter(
user=self.user,
domain=domain_in_portfolio_3,
).exists()
)
self.assertFalse(
UserDomainRole.objects.filter(
user=self.user,
domain=domain_in_portfolio_4,
).exists()
)
# The user domain role on the domain not in portfolio still exists
self.assertTrue(
UserDomainRole.objects.filter(
user=self.user,
domain=domain_not_in_portfolio_2,
).exists()
)
class TestUserPortfolioPermission(TestCase): class TestUserPortfolioPermission(TestCase):
@less_console_noise_decorator @less_console_noise_decorator
@ -457,6 +746,7 @@ class TestUserPortfolioPermission(TestCase):
Domain.objects.all().delete() Domain.objects.all().delete()
DomainInformation.objects.all().delete() DomainInformation.objects.all().delete()
DomainRequest.objects.all().delete() DomainRequest.objects.all().delete()
DomainInvitation.objects.all().delete()
UserPortfolioPermission.objects.all().delete() UserPortfolioPermission.objects.all().delete()
Portfolio.objects.all().delete() Portfolio.objects.all().delete()
User.objects.all().delete() User.objects.all().delete()
@ -750,6 +1040,129 @@ class TestUserPortfolioPermission(TestCase):
# Should return the forbidden permissions for member role # Should return the forbidden permissions for member role
self.assertEqual(member_only_permissions, set(member_forbidden)) self.assertEqual(member_only_permissions, set(member_forbidden))
@less_console_noise_decorator
def test_delete_portfolio_permission_deletes_user_domain_roles(self):
"""Deleting a user portfolio permission causes domain invitations for the same email on the same
portfolio to be canceled, also deletes any exiting user domain roles on the portfolio for the
user if the user exists."""
domain_in_portfolio_1, _ = Domain.objects.get_or_create(
name="domain_in_portfolio_1.gov", state=Domain.State.READY
)
DomainInformation.objects.get_or_create(
creator=self.user, domain=domain_in_portfolio_1, portfolio=self.portfolio
)
invite_1, _ = DomainInvitation.objects.get_or_create(email=self.user.email, domain=domain_in_portfolio_1)
domain_in_portfolio_2, _ = Domain.objects.get_or_create(
name="domain_in_portfolio_and_invited_2.gov", state=Domain.State.READY
)
DomainInformation.objects.get_or_create(
creator=self.user, domain=domain_in_portfolio_2, portfolio=self.portfolio
)
invite_2, _ = DomainInvitation.objects.get_or_create(email=self.user.email, domain=domain_in_portfolio_2)
domain_in_portfolio_3, _ = Domain.objects.get_or_create(
name="domain_in_portfolio_3.gov", state=Domain.State.READY
)
DomainInformation.objects.get_or_create(
creator=self.user, domain=domain_in_portfolio_3, portfolio=self.portfolio
)
UserDomainRole.objects.get_or_create(
user=self.user, domain=domain_in_portfolio_3, role=UserDomainRole.Roles.MANAGER
)
domain_in_portfolio_4, _ = Domain.objects.get_or_create(
name="domain_in_portfolio_and_invited_4.gov", state=Domain.State.READY
)
DomainInformation.objects.get_or_create(
creator=self.user, domain=domain_in_portfolio_4, portfolio=self.portfolio
)
UserDomainRole.objects.get_or_create(
user=self.user, domain=domain_in_portfolio_4, role=UserDomainRole.Roles.MANAGER
)
domain_not_in_portfolio_1, _ = Domain.objects.get_or_create(
name="domain_not_in_portfolio.gov", state=Domain.State.READY
)
DomainInformation.objects.get_or_create(creator=self.user, domain=domain_not_in_portfolio_1)
invite_3, _ = DomainInvitation.objects.get_or_create(email=self.user.email, domain=domain_not_in_portfolio_1)
domain_not_in_portfolio_2, _ = Domain.objects.get_or_create(
name="domain_not_in_portfolio_2.gov", state=Domain.State.READY
)
DomainInformation.objects.get_or_create(creator=self.user, domain=domain_not_in_portfolio_2)
UserDomainRole.objects.get_or_create(
user=self.user, domain=domain_not_in_portfolio_2, role=UserDomainRole.Roles.MANAGER
)
# Create portfolio permission
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
portfolio=self.portfolio, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
# The domain invitations start off as INVITED
self.assertEqual(invite_1.status, DomainInvitation.DomainInvitationStatus.INVITED)
self.assertEqual(invite_2.status, DomainInvitation.DomainInvitationStatus.INVITED)
self.assertEqual(invite_3.status, DomainInvitation.DomainInvitationStatus.INVITED)
# The user domain roles exist
self.assertTrue(
UserDomainRole.objects.filter(
user=self.user,
domain=domain_in_portfolio_3,
).exists()
)
self.assertTrue(
UserDomainRole.objects.filter(
user=self.user,
domain=domain_in_portfolio_4,
).exists()
)
self.assertTrue(
UserDomainRole.objects.filter(
user=self.user,
domain=domain_not_in_portfolio_2,
).exists()
)
# Delete member (user portfolio permission)
portfolio_permission.delete()
# Reload the objects from the database
invite_1 = DomainInvitation.objects.get(pk=invite_1.pk)
invite_2 = DomainInvitation.objects.get(pk=invite_2.pk)
invite_3 = DomainInvitation.objects.get(pk=invite_3.pk)
# The domain invitations to the portfolio domains have been canceled
self.assertEqual(invite_1.status, DomainInvitation.DomainInvitationStatus.CANCELED)
self.assertEqual(invite_2.status, DomainInvitation.DomainInvitationStatus.CANCELED)
# Invite 3 is unaffected
self.assertEqual(invite_3.status, DomainInvitation.DomainInvitationStatus.INVITED)
# The user domain roles have been deleted for the domains in portfolio
self.assertFalse(
UserDomainRole.objects.filter(
user=self.user,
domain=domain_in_portfolio_3,
).exists()
)
self.assertFalse(
UserDomainRole.objects.filter(
user=self.user,
domain=domain_in_portfolio_4,
).exists()
)
# The user domain role on the domain not in portfolio still exists
self.assertTrue(
UserDomainRole.objects.filter(
user=self.user,
domain=domain_not_in_portfolio_2,
).exists()
)
class TestUser(TestCase): class TestUser(TestCase):
"""Test actions that occur on user login, """Test actions that occur on user login,

View file

@ -892,7 +892,7 @@ class MemberExportTest(MockDbForIndividualTests, MockEppLib):
"big_lebowski@dude.co,False,help@get.gov,2022-04-01,Invalid date,None," "big_lebowski@dude.co,False,help@get.gov,2022-04-01,Invalid date,None,"
"Viewer,True,1,cdomain1.gov\n" "Viewer,True,1,cdomain1.gov\n"
"cozy_staffuser@igorville.gov,True,help@get.gov,2022-04-01,2024-02-01," "cozy_staffuser@igorville.gov,True,help@get.gov,2022-04-01,2024-02-01,"
"Viewer,Viewer,False,0,\n" "Viewer Requester,Manager,False,0,\n"
"icy_superuser@igorville.gov,True,help@get.gov,2022-04-01,2024-02-01," "icy_superuser@igorville.gov,True,help@get.gov,2022-04-01,2024-02-01,"
"Viewer Requester,Manager,False,0,\n" "Viewer Requester,Manager,False,0,\n"
"meoward@rocks.com,False,big_lebowski@dude.co,2022-04-01,Invalid date,None," "meoward@rocks.com,False,big_lebowski@dude.co,2022-04-01,Invalid date,None,"
@ -906,7 +906,7 @@ class MemberExportTest(MockDbForIndividualTests, MockEppLib):
"nonexistentmember_4@igorville.gov,True,help@get.gov,Unretrieved,Invited," "nonexistentmember_4@igorville.gov,True,help@get.gov,Unretrieved,Invited,"
"Viewer Requester,Manager,False,0,\n" "Viewer Requester,Manager,False,0,\n"
"nonexistentmember_5@igorville.gov,True,help@get.gov,Unretrieved,Invited," "nonexistentmember_5@igorville.gov,True,help@get.gov,Unretrieved,Invited,"
"Viewer,Viewer,False,0,\n" "Viewer Requester,Manager,False,0,\n"
"tired_sleepy@igorville.gov,False,System,2022-04-01,Invalid date,Viewer," "tired_sleepy@igorville.gov,False,System,2022-04-01,Invalid date,Viewer,"
"None,False,0,\n" "None,False,0,\n"
) )

View file

@ -214,7 +214,7 @@ class HomeTests(TestWithUser):
@less_console_noise_decorator @less_console_noise_decorator
def test_state_help_text_expired(self): def test_state_help_text_expired(self):
"""Tests if each domain state has help text when expired""" """Tests if each domain state has help text when expired"""
expired_text = "This domain has expired, but it is still online. " expired_text = "This domain has expired. "
test_domain, _ = Domain.objects.get_or_create(name="expired.gov", state=Domain.State.READY) test_domain, _ = Domain.objects.get_or_create(name="expired.gov", state=Domain.State.READY)
test_domain.expiration_date = date(2011, 10, 10) test_domain.expiration_date = date(2011, 10, 10)
test_domain.save() test_domain.save()
@ -240,7 +240,7 @@ class HomeTests(TestWithUser):
"""Tests if each domain state has help text when expiration date is None""" """Tests if each domain state has help text when expiration date is None"""
# == Test a expiration of None for state ready. This should be expired. == # # == Test a expiration of None for state ready. This should be expired. == #
expired_text = "This domain has expired, but it is still online. " expired_text = "This domain has expired. "
test_domain, _ = Domain.objects.get_or_create(name="imexpired.gov", state=Domain.State.READY) test_domain, _ = Domain.objects.get_or_create(name="imexpired.gov", state=Domain.State.READY)
test_domain.expiration_date = None test_domain.expiration_date = None
test_domain.save() test_domain.save()

View file

@ -439,15 +439,21 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
username="usertest", username="usertest",
) )
self.domaintorenew, _ = Domain.objects.get_or_create( self.domain_to_renew, _ = Domain.objects.get_or_create(
name="domainrenewal.gov", name="domainrenewal.gov",
) )
UserDomainRole.objects.get_or_create( self.domain_not_expiring, _ = Domain.objects.get_or_create(
user=self.user, domain=self.domaintorenew, role=UserDomainRole.Roles.MANAGER name="domainnotexpiring.gov", expiration_date=timezone.now().date() + timedelta(days=65)
) )
DomainInformation.objects.get_or_create(creator=self.user, domain=self.domaintorenew) self.domain_no_domain_manager, _ = Domain.objects.get_or_create(name="domainnodomainmanager.gov")
UserDomainRole.objects.get_or_create(
user=self.user, domain=self.domain_to_renew, role=UserDomainRole.Roles.MANAGER
)
DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_to_renew)
self.portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test org", creator=self.user) self.portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test org", creator=self.user)
@ -473,13 +479,15 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
@override_flag("domain_renewal", active=True) @override_flag("domain_renewal", active=True)
def test_expiring_domain_on_detail_page_as_domain_manager(self): def test_expiring_domain_on_detail_page_as_domain_manager(self):
"""If a user is a domain manager and their domain is expiring soon,
user should be able to see the "Renew to maintain access" link domain overview detail box."""
self.client.force_login(self.user) self.client.force_login(self.user)
with patch.object(Domain, "is_expiring", self.custom_is_expiring), patch.object( with patch.object(Domain, "is_expiring", self.custom_is_expiring), patch.object(
Domain, "is_expired", self.custom_is_expired_false Domain, "is_expired", self.custom_is_expired_false
): ):
self.assertEquals(self.domaintorenew.state, Domain.State.UNKNOWN) self.assertEquals(self.domain_to_renew.state, Domain.State.UNKNOWN)
detail_page = self.client.get( detail_page = self.client.get(
reverse("domain", kwargs={"pk": self.domaintorenew.id}), reverse("domain", kwargs={"pk": self.domain_to_renew.id}),
) )
self.assertContains(detail_page, "Expiring soon") self.assertContains(detail_page, "Expiring soon")
@ -491,6 +499,8 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
@override_flag("domain_renewal", active=True) @override_flag("domain_renewal", active=True)
@override_flag("organization_feature", active=True) @override_flag("organization_feature", active=True)
def test_expiring_domain_on_detail_page_in_org_model_as_a_non_domain_manager(self): def test_expiring_domain_on_detail_page_in_org_model_as_a_non_domain_manager(self):
"""In org model: If a user is NOT a domain manager and their domain is expiring soon,
user be notified to contact a domain manager in the domain overview detail box."""
portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test org", creator=self.user) portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test org", creator=self.user)
non_dom_manage_user = get_user_model().objects.create( non_dom_manage_user = get_user_model().objects.create(
first_name="Non Domain", first_name="Non Domain",
@ -510,9 +520,9 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
], ],
) )
domaintorenew2, _ = Domain.objects.get_or_create(name="bogusdomain2.gov") domain_to_renew2, _ = Domain.objects.get_or_create(name="bogusdomain2.gov")
DomainInformation.objects.get_or_create( DomainInformation.objects.get_or_create(
creator=non_dom_manage_user, domain=domaintorenew2, portfolio=self.portfolio creator=non_dom_manage_user, domain=domain_to_renew2, portfolio=self.portfolio
) )
non_dom_manage_user.refresh_from_db() non_dom_manage_user.refresh_from_db()
self.client.force_login(non_dom_manage_user) self.client.force_login(non_dom_manage_user)
@ -520,38 +530,42 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
Domain, "is_expired", self.custom_is_expired_false Domain, "is_expired", self.custom_is_expired_false
): ):
detail_page = self.client.get( detail_page = self.client.get(
reverse("domain", kwargs={"pk": domaintorenew2.id}), reverse("domain", kwargs={"pk": domain_to_renew2.id}),
) )
self.assertContains(detail_page, "Contact one of the listed domain managers to renew the domain.") self.assertContains(detail_page, "Contact one of the listed domain managers to renew the domain.")
@override_flag("domain_renewal", active=True) @override_flag("domain_renewal", active=True)
@override_flag("organization_feature", active=True) @override_flag("organization_feature", active=True)
def test_expiring_domain_on_detail_page_in_org_model_as_a_domain_manager(self): def test_expiring_domain_on_detail_page_in_org_model_as_a_domain_manager(self):
"""Inorg model: If a user is a domain manager and their domain is expiring soon,
user should be able to see the "Renew to maintain access" link domain overview detail box."""
portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test org2", creator=self.user) portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test org2", creator=self.user)
domaintorenew3, _ = Domain.objects.get_or_create(name="bogusdomain3.gov") domain_to_renew3, _ = Domain.objects.get_or_create(name="bogusdomain3.gov")
UserDomainRole.objects.get_or_create(user=self.user, domain=domaintorenew3, role=UserDomainRole.Roles.MANAGER) UserDomainRole.objects.get_or_create(user=self.user, domain=domain_to_renew3, role=UserDomainRole.Roles.MANAGER)
DomainInformation.objects.get_or_create(creator=self.user, domain=domaintorenew3, portfolio=portfolio) DomainInformation.objects.get_or_create(creator=self.user, domain=domain_to_renew3, portfolio=portfolio)
self.user.refresh_from_db() self.user.refresh_from_db()
self.client.force_login(self.user) self.client.force_login(self.user)
with patch.object(Domain, "is_expiring", self.custom_is_expiring), patch.object( with patch.object(Domain, "is_expiring", self.custom_is_expiring), patch.object(
Domain, "is_expired", self.custom_is_expired_false Domain, "is_expired", self.custom_is_expired_false
): ):
detail_page = self.client.get( detail_page = self.client.get(
reverse("domain", kwargs={"pk": domaintorenew3.id}), reverse("domain", kwargs={"pk": domain_to_renew3.id}),
) )
self.assertContains(detail_page, "Renew to maintain access") self.assertContains(detail_page, "Renew to maintain access")
@override_flag("domain_renewal", active=True) @override_flag("domain_renewal", active=True)
def test_domain_renewal_form_and_sidebar_expiring(self): def test_domain_renewal_form_and_sidebar_expiring(self):
"""If a user is a domain manager and their domain is expiring soon,
user should be able to see Renewal Form on the sidebar."""
self.client.force_login(self.user) self.client.force_login(self.user)
with patch.object(Domain, "is_expiring", self.custom_is_expiring), patch.object( with patch.object(Domain, "is_expiring", self.custom_is_expiring), patch.object(
Domain, "is_expiring", self.custom_is_expiring Domain, "is_expiring", self.custom_is_expiring
): ):
# Grab the detail page # Grab the detail page
detail_page = self.client.get( detail_page = self.client.get(
reverse("domain", kwargs={"pk": self.domaintorenew.id}), reverse("domain", kwargs={"pk": self.domain_to_renew.id}),
) )
# Make sure we see the link as a domain manager # Make sure we see the link as a domain manager
@ -561,18 +575,19 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
self.assertContains(detail_page, "Renewal form") self.assertContains(detail_page, "Renewal form")
# Grab link to the renewal page # Grab link to the renewal page
renewal_form_url = reverse("domain-renewal", kwargs={"pk": self.domaintorenew.id}) renewal_form_url = reverse("domain-renewal", kwargs={"pk": self.domain_to_renew.id})
self.assertContains(detail_page, f'href="{renewal_form_url}"') self.assertContains(detail_page, f'href="{renewal_form_url}"')
# Simulate clicking the link # Simulate clicking the link
response = self.client.get(renewal_form_url) response = self.client.get(renewal_form_url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, f"Renew {self.domaintorenew.name}") self.assertContains(response, f"Renew {self.domain_to_renew.name}")
@override_flag("domain_renewal", active=True) @override_flag("domain_renewal", active=True)
def test_domain_renewal_form_and_sidebar_expired(self): def test_domain_renewal_form_and_sidebar_expired(self):
"""If a user is a domain manager and their domain is expired,
user should be able to see Renewal Form on the sidebar."""
self.client.force_login(self.user) self.client.force_login(self.user)
with patch.object(Domain, "is_expired", self.custom_is_expired_true), patch.object( with patch.object(Domain, "is_expired", self.custom_is_expired_true), patch.object(
@ -580,10 +595,9 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
): ):
# Grab the detail page # Grab the detail page
detail_page = self.client.get( detail_page = self.client.get(
reverse("domain", kwargs={"pk": self.domaintorenew.id}), reverse("domain", kwargs={"pk": self.domain_to_renew.id}),
) )
print("puglesss", self.domaintorenew.is_expired)
# Make sure we see the link as a domain manager # Make sure we see the link as a domain manager
self.assertContains(detail_page, "Renew to maintain access") self.assertContains(detail_page, "Renew to maintain access")
@ -591,17 +605,19 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
self.assertContains(detail_page, "Renewal form") self.assertContains(detail_page, "Renewal form")
# Grab link to the renewal page # Grab link to the renewal page
renewal_form_url = reverse("domain-renewal", kwargs={"pk": self.domaintorenew.id}) renewal_form_url = reverse("domain-renewal", kwargs={"pk": self.domain_to_renew.id})
self.assertContains(detail_page, f'href="{renewal_form_url}"') self.assertContains(detail_page, f'href="{renewal_form_url}"')
# Simulate clicking the link # Simulate clicking the link
response = self.client.get(renewal_form_url) response = self.client.get(renewal_form_url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, f"Renew {self.domaintorenew.name}") self.assertContains(response, f"Renew {self.domain_to_renew.name}")
@override_flag("domain_renewal", active=True) @override_flag("domain_renewal", active=True)
def test_domain_renewal_form_your_contact_info_edit(self): def test_domain_renewal_form_your_contact_info_edit(self):
"""Checking that if a user is a domain manager they can edit the
Your Profile portion of the Renewal Form."""
with less_console_noise(): with less_console_noise():
# Start on the Renewal page for the domain # Start on the Renewal page for the domain
renewal_page = self.app.get(reverse("domain-renewal", kwargs={"pk": self.domain_with_ip.id})) renewal_page = self.app.get(reverse("domain-renewal", kwargs={"pk": self.domain_with_ip.id}))
@ -620,6 +636,8 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
@override_flag("domain_renewal", active=True) @override_flag("domain_renewal", active=True)
def test_domain_renewal_form_security_email_edit(self): def test_domain_renewal_form_security_email_edit(self):
"""Checking that if a user is a domain manager they can edit the
Security Email portion of the Renewal Form."""
with less_console_noise(): with less_console_noise():
# Start on the Renewal page for the domain # Start on the Renewal page for the domain
renewal_page = self.app.get(reverse("domain-renewal", kwargs={"pk": self.domain_with_ip.id})) renewal_page = self.app.get(reverse("domain-renewal", kwargs={"pk": self.domain_with_ip.id}))
@ -641,6 +659,8 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
@override_flag("domain_renewal", active=True) @override_flag("domain_renewal", active=True)
def test_domain_renewal_form_domain_manager_edit(self): def test_domain_renewal_form_domain_manager_edit(self):
"""Checking that if a user is a domain manager they can edit the
Domain Manager portion of the Renewal Form."""
with less_console_noise(): with less_console_noise():
# Start on the Renewal page for the domain # Start on the Renewal page for the domain
renewal_page = self.app.get(reverse("domain-renewal", kwargs={"pk": self.domain_with_ip.id})) renewal_page = self.app.get(reverse("domain-renewal", kwargs={"pk": self.domain_with_ip.id}))
@ -658,8 +678,26 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
self.assertContains(edit_page, "Domain managers can update all information related to a domain") self.assertContains(edit_page, "Domain managers can update all information related to a domain")
@override_flag("domain_renewal", active=True) @override_flag("domain_renewal", active=True)
def test_ack_checkbox_not_checked(self): def test_domain_renewal_form_not_expired_or_expiring(self):
"""Checking that if the user's domain is not expired or expiring that user should not be able
to access /renewal and that it should receive a 403."""
with less_console_noise():
# Start on the Renewal page for the domain
renewal_page = self.client.get(reverse("domain-renewal", kwargs={"pk": self.domain_not_expiring.id}))
self.assertEqual(renewal_page.status_code, 403)
@override_flag("domain_renewal", active=True)
def test_domain_renewal_form_does_not_appear_if_not_domain_manager(self):
"""If user is not a domain manager and tries to access /renewal, user should receive a 403."""
with patch.object(Domain, "is_expired", self.custom_is_expired_true), patch.object(
Domain, "is_expired", self.custom_is_expired_true
):
renewal_page = self.client.get(reverse("domain-renewal", kwargs={"pk": self.domain_no_domain_manager.id}))
self.assertEqual(renewal_page.status_code, 403)
@override_flag("domain_renewal", active=True)
def test_ack_checkbox_not_checked(self):
"""If user don't check the checkbox, user should receive an error message."""
# Grab the renewal URL # Grab the renewal URL
renewal_url = reverse("domain-renewal", kwargs={"pk": self.domain_with_ip.id}) renewal_url = reverse("domain-renewal", kwargs={"pk": self.domain_with_ip.id})
@ -671,7 +709,8 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
@override_flag("domain_renewal", active=True) @override_flag("domain_renewal", active=True)
def test_ack_checkbox_checked(self): def test_ack_checkbox_checked(self):
"""If user check the checkbox and submits the form,
user should be redirected Domain Over page with an updated by 1 year expiration date"""
# Grab the renewal URL # Grab the renewal URL
with patch.object(Domain, "renew_domain", self.custom_renew_domain): with patch.object(Domain, "renew_domain", self.custom_renew_domain):
renewal_url = reverse("domain-renewal", kwargs={"pk": self.domain_with_ip.id}) renewal_url = reverse("domain-renewal", kwargs={"pk": self.domain_with_ip.id})
@ -886,6 +925,40 @@ class TestDomainManagers(TestDomainOverview):
success_page = success_result.follow() success_page = success_result.follow()
self.assertContains(success_page, "notauser@igorville.gov") self.assertContains(success_page, "notauser@igorville.gov")
@override_flag("organization_feature", active=True)
@less_console_noise_decorator
@patch("registrar.views.domain.send_portfolio_invitation_email")
@patch("registrar.views.domain.send_domain_invitation_email")
def test_domain_user_add_form_fails_to_send_to_some_managers(
self, mock_send_domain_email, mock_send_portfolio_email
):
"""Adding an email not associated with a user works and sends portfolio invitation,
and when domain managers email(s) fail to send, assert proper warning displayed."""
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = "notauser@igorville.gov"
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
mock_send_domain_email.return_value = False
success_result = add_page.form.submit()
self.assertEqual(success_result.status_code, 302)
self.assertEqual(
success_result["Location"],
reverse("domain-users", kwargs={"pk": self.domain.id}),
)
# Verify that the invitation emails were sent
mock_send_portfolio_email.assert_called_once()
mock_send_domain_email.assert_called_once()
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
success_page = success_result.follow()
self.assertContains(success_page, "Could not send email confirmation to existing domain managers.")
@boto3_mocking.patching @boto3_mocking.patching
@override_flag("organization_feature", active=True) @override_flag("organization_feature", active=True)
@less_console_noise_decorator @less_console_noise_decorator
@ -2866,11 +2939,11 @@ class TestDomainRenewal(TestWithUser):
name="igorville.gov", expiration_date=expiring_date name="igorville.gov", expiration_date=expiring_date
) )
self.domain_with_expired_date, _ = Domain.objects.get_or_create( self.domain_with_expired_date, _ = Domain.objects.get_or_create(
name="domainwithexpireddate.com", expiration_date=expired_date name="domainwithexpireddate.gov", expiration_date=expired_date
) )
self.domain_with_current_date, _ = Domain.objects.get_or_create( self.domain_with_current_date, _ = Domain.objects.get_or_create(
name="domainwithfarexpireddate.com", expiration_date=expiring_date_current name="domainwithfarexpireddate.gov", expiration_date=expiring_date_current
) )
UserDomainRole.objects.get_or_create( UserDomainRole.objects.get_or_create(
@ -2916,7 +2989,7 @@ class TestDomainRenewal(TestWithUser):
today = datetime.now() today = datetime.now()
expiring_date = (today + timedelta(days=30)).strftime("%Y-%m-%d") expiring_date = (today + timedelta(days=30)).strftime("%Y-%m-%d")
self.domain_with_another_expiring, _ = Domain.objects.get_or_create( self.domain_with_another_expiring, _ = Domain.objects.get_or_create(
name="domainwithanotherexpiringdate.com", expiration_date=expiring_date name="domainwithanotherexpiringdate.gov", expiration_date=expiring_date
) )
UserDomainRole.objects.get_or_create( UserDomainRole.objects.get_or_create(
@ -2952,7 +3025,7 @@ class TestDomainRenewal(TestWithUser):
today = datetime.now() today = datetime.now()
expiring_date = (today + timedelta(days=31)).strftime("%Y-%m-%d") expiring_date = (today + timedelta(days=31)).strftime("%Y-%m-%d")
self.domain_with_another_expiring_org_model, _ = Domain.objects.get_or_create( self.domain_with_another_expiring_org_model, _ = Domain.objects.get_or_create(
name="domainwithanotherexpiringdate_orgmodel.com", expiration_date=expiring_date name="domainwithanotherexpiringdate_orgmodel.gov", expiration_date=expiring_date
) )
UserDomainRole.objects.get_or_create( UserDomainRole.objects.get_or_create(

View file

@ -915,9 +915,9 @@ class TestPortfolio(WebTest):
# Assert text within the page is correct # Assert text within the page is correct
self.assertContains(response, "First Last") self.assertContains(response, "First Last")
self.assertContains(response, self.user.email) self.assertContains(response, self.user.email)
self.assertContains(response, "Basic access") self.assertContains(response, "Basic")
self.assertContains(response, "No access") self.assertContains(response, "No access")
self.assertContains(response, "View all members") self.assertContains(response, "Viewer")
self.assertContains(response, "This member does not manage any domains.") self.assertContains(response, "This member does not manage any domains.")
# Assert buttons and links within the page are correct # Assert buttons and links within the page are correct
@ -933,15 +933,11 @@ class TestPortfolio(WebTest):
"""Test that user can access the member page with edit_members permission""" """Test that user can access the member page with edit_members permission"""
# Arrange # Arrange
# give user permissions to view AND manage members # give user admin role, which includes edit_members
permission_obj, _ = UserPortfolioPermission.objects.get_or_create( permission_obj, _ = UserPortfolioPermission.objects.get_or_create(
user=self.user, user=self.user,
portfolio=self.portfolio, portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.EDIT_REQUESTS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
) )
# Verify the page can be accessed # Verify the page can be accessed
@ -952,9 +948,9 @@ class TestPortfolio(WebTest):
# Assert text within the page is correct # Assert text within the page is correct
self.assertContains(response, "First Last") self.assertContains(response, "First Last")
self.assertContains(response, self.user.email) self.assertContains(response, self.user.email)
self.assertContains(response, "Admin access") self.assertContains(response, "Admin")
self.assertContains(response, "View all requests plus create requests") self.assertContains(response, "Creator")
self.assertContains(response, "View all members plus manage members") self.assertContains(response, "Manager")
self.assertContains( self.assertContains(
response, 'This member does not manage any domains. To assign this member a domain, click "Manage"' response, 'This member does not manage any domains. To assign this member a domain, click "Manage"'
) )
@ -1028,9 +1024,9 @@ class TestPortfolio(WebTest):
# Assert text within the page is correct # Assert text within the page is correct
self.assertContains(response, "Invited") self.assertContains(response, "Invited")
self.assertContains(response, portfolio_invitation.email) self.assertContains(response, portfolio_invitation.email)
self.assertContains(response, "Basic access") self.assertContains(response, "Basic")
self.assertContains(response, "No access") self.assertContains(response, "No access")
self.assertContains(response, "View all members") self.assertContains(response, "Viewer")
self.assertContains(response, "This member does not manage any domains.") self.assertContains(response, "This member does not manage any domains.")
# Assert buttons and links within the page are correct # Assert buttons and links within the page are correct
@ -1043,27 +1039,19 @@ class TestPortfolio(WebTest):
@override_flag("organization_feature", active=True) @override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True) @override_flag("organization_members", active=True)
def test_can_view_invitedmember_page_when_user_has_edit_members(self): def test_can_view_invitedmember_page_when_user_has_edit_members(self):
"""Test that user can access the invitedmember page with edit_members permission""" """Test that user can access the invitedmember page with org admin role"""
# Arrange # Arrange
# give user permissions to view AND manage members # give user admin role
permission_obj, _ = UserPortfolioPermission.objects.get_or_create( permission_obj, _ = UserPortfolioPermission.objects.get_or_create(
user=self.user, user=self.user,
portfolio=self.portfolio, portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.EDIT_REQUESTS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
) )
portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create( portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(
email="info@example.com", email="info@example.com",
portfolio=self.portfolio, portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.EDIT_REQUESTS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
) )
# Verify the page can be accessed # Verify the page can be accessed
@ -1074,9 +1062,10 @@ class TestPortfolio(WebTest):
# Assert text within the page is correct # Assert text within the page is correct
self.assertContains(response, "Invited") self.assertContains(response, "Invited")
self.assertContains(response, portfolio_invitation.email) self.assertContains(response, portfolio_invitation.email)
self.assertContains(response, "Admin access") self.assertContains(response, "Admin")
self.assertContains(response, "View all requests plus create requests") self.assertContains(response, "Viewer, all")
self.assertContains(response, "View all members plus manage members") self.assertContains(response, "Creator")
self.assertContains(response, "Manager")
self.assertContains( self.assertContains(
response, 'This member does not manage any domains. To assign this member a domain, click "Manage"' response, 'This member does not manage any domains. To assign this member a domain, click "Manage"'
) )
@ -1404,15 +1393,11 @@ class TestPortfolio(WebTest):
# In the members_table.html we use data-has-edit-permission as a boolean # In the members_table.html we use data-has-edit-permission as a boolean
# to indicate if a user has permission to edit members in the specific portfolio # to indicate if a user has permission to edit members in the specific portfolio
# 1. User w/ edit permission # 1. User w/ edit permission. This permission is included in Organization admin role
UserPortfolioPermission.objects.get_or_create( UserPortfolioPermission.objects.get_or_create(
user=self.user, user=self.user,
portfolio=self.portfolio, portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
) )
# Create a member under same portfolio # Create a member under same portfolio
@ -1433,12 +1418,13 @@ class TestPortfolio(WebTest):
self.assertContains(response, 'data-has-edit-permission="True"') self.assertContains(response, 'data-has-edit-permission="True"')
# 2. User w/o edit permission (additional permission of EDIT_MEMBERS removed) # 2. User w/o edit permission.
permission = UserPortfolioPermission.objects.get(user=self.user, portfolio=self.portfolio) permission = UserPortfolioPermission.objects.get(user=self.user, portfolio=self.portfolio)
# Remove the EDIT_MEMBERS additional permission # Update to basic member with view members permission
permission.roles = [UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
permission.additional_permissions = [ permission.additional_permissions = [
perm for perm in permission.additional_permissions if perm != UserPortfolioPermissionChoices.EDIT_MEMBERS UserPortfolioPermissionChoices.VIEW_MEMBERS,
] ]
# Save the updated permissions list # Save the updated permissions list
@ -3128,7 +3114,9 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
reverse("new-member"), reverse("new-member"),
{ {
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "domain_request_permissions": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
"domain_permissions": UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value,
"member_permissions": "no_access",
"email": self.new_member_email, "email": self.new_member_email,
}, },
) )
@ -3169,7 +3157,9 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
reverse("new-member"), reverse("new-member"),
{ {
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "domain_request_permissions": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
"domain_permissions": UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value,
"member_permissions": "no_access",
"email": self.new_member_email, "email": self.new_member_email,
}, },
HTTP_X_REQUESTED_WITH="XMLHttpRequest", HTTP_X_REQUESTED_WITH="XMLHttpRequest",
@ -3246,7 +3236,9 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
form_data = { form_data = {
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "domain_request_permissions": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
"domain_permissions": UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value,
"member_permissions": "no_access",
"email": self.new_member_email, "email": self.new_member_email,
} }
@ -3262,7 +3254,7 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
# assert that response is a redirect to reverse("members") # assert that response is a redirect to reverse("members")
self.assertRedirects(response, reverse("members")) self.assertRedirects(response, reverse("members"))
# assert that messages contains message, "Could not send email invitation" # assert that messages contains message, "Could not send email invitation"
mock_warning.assert_called_once_with(response.wsgi_request, "Could not send email invitation.") mock_warning.assert_called_once_with(response.wsgi_request, "Could not send portfolio email invitation.")
# assert that portfolio invitation is not created # assert that portfolio invitation is not created
self.assertFalse( self.assertFalse(
PortfolioInvitation.objects.filter(email=self.new_member_email, portfolio=self.portfolio).exists(), PortfolioInvitation.objects.filter(email=self.new_member_email, portfolio=self.portfolio).exists(),
@ -3285,7 +3277,9 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
form_data = { form_data = {
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "domain_request_permissions": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
"domain_permissions": UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value,
"member_permissions": "no_access",
"email": self.new_member_email, "email": self.new_member_email,
} }
@ -3327,7 +3321,9 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
form_data = { form_data = {
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "domain_request_permissions": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
"domain_permissions": UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value,
"member_permissions": "no_access",
"email": self.new_member_email, "email": self.new_member_email,
} }
@ -3343,7 +3339,7 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
# assert that response is a redirect to reverse("members") # assert that response is a redirect to reverse("members")
self.assertRedirects(response, reverse("members")) self.assertRedirects(response, reverse("members"))
# assert that messages contains message, "Could not send email invitation" # assert that messages contains message, "Could not send email invitation"
mock_warning.assert_called_once_with(response.wsgi_request, "Could not send email invitation.") mock_warning.assert_called_once_with(response.wsgi_request, "Could not send portfolio email invitation.")
# assert that portfolio invitation is not created # assert that portfolio invitation is not created
self.assertFalse( self.assertFalse(
PortfolioInvitation.objects.filter(email=self.new_member_email, portfolio=self.portfolio).exists(), PortfolioInvitation.objects.filter(email=self.new_member_email, portfolio=self.portfolio).exists(),
@ -3453,7 +3449,9 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
reverse("new-member"), reverse("new-member"),
{ {
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "domain_request_permissions": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
"domain_permissions": UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value,
"member_permissions": "no_access",
"email": "newuser@example.com", "email": "newuser@example.com",
}, },
) )
@ -3537,8 +3535,6 @@ class TestEditPortfolioMemberView(WebTest):
reverse("member-permissions", kwargs={"pk": basic_permission.id}), reverse("member-permissions", kwargs={"pk": basic_permission.id}),
{ {
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN, "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
"domain_request_permission_admin": UserPortfolioPermissionChoices.EDIT_REQUESTS,
"member_permission_admin": UserPortfolioPermissionChoices.EDIT_MEMBERS,
}, },
) )
@ -3548,13 +3544,6 @@ class TestEditPortfolioMemberView(WebTest):
# Verify database changes # Verify database changes
basic_permission.refresh_from_db() basic_permission.refresh_from_db()
self.assertEqual(basic_permission.roles, [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]) self.assertEqual(basic_permission.roles, [UserPortfolioRoleChoices.ORGANIZATION_ADMIN])
self.assertEqual(
set(basic_permission.additional_permissions),
{
UserPortfolioPermissionChoices.EDIT_REQUESTS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
},
)
@less_console_noise_decorator @less_console_noise_decorator
@override_flag("organization_feature", active=True) @override_flag("organization_feature", active=True)
@ -3572,19 +3561,18 @@ class TestEditPortfolioMemberView(WebTest):
response = self.client.post( response = self.client.post(
reverse("member-permissions", kwargs={"pk": permission.id}), reverse("member-permissions", kwargs={"pk": permission.id}),
{ {
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN, "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER,
# Missing required admin fields # Missing required admin fields
}, },
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual( self.assertEqual(
response.context["form"].errors["domain_request_permission_admin"][0], response.context["form"].errors["domain_request_permissions"][0],
"Admin domain request permission is required", "Domain request permission is required.",
)
self.assertEqual(
response.context["form"].errors["member_permission_admin"][0], "Admin member permission is required"
) )
self.assertEqual(response.context["form"].errors["member_permissions"][0], "Member permission is required.")
self.assertEqual(response.context["form"].errors["domain_permissions"][0], "Domain permission is required.")
@less_console_noise_decorator @less_console_noise_decorator
@override_flag("organization_feature", active=True) @override_flag("organization_feature", active=True)
@ -3598,8 +3586,6 @@ class TestEditPortfolioMemberView(WebTest):
reverse("invitedmember-permissions", kwargs={"pk": self.invitation.id}), reverse("invitedmember-permissions", kwargs={"pk": self.invitation.id}),
{ {
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN, "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
"domain_request_permission_admin": UserPortfolioPermissionChoices.EDIT_REQUESTS,
"member_permission_admin": UserPortfolioPermissionChoices.EDIT_MEMBERS,
}, },
) )
@ -3608,13 +3594,6 @@ class TestEditPortfolioMemberView(WebTest):
# Verify invitation was updated # Verify invitation was updated
updated_invitation = PortfolioInvitation.objects.get(pk=self.invitation.id) updated_invitation = PortfolioInvitation.objects.get(pk=self.invitation.id)
self.assertEqual(updated_invitation.roles, [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]) self.assertEqual(updated_invitation.roles, [UserPortfolioRoleChoices.ORGANIZATION_ADMIN])
self.assertEqual(
set(updated_invitation.additional_permissions),
{
UserPortfolioPermissionChoices.EDIT_REQUESTS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
},
)
@less_console_noise_decorator @less_console_noise_decorator
@override_flag("organization_feature", active=True) @override_flag("organization_feature", active=True)
@ -3636,7 +3615,9 @@ class TestEditPortfolioMemberView(WebTest):
reverse("member-permissions", kwargs={"pk": admin_permission.id}), reverse("member-permissions", kwargs={"pk": admin_permission.id}),
{ {
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER, "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER,
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, "domain_permissions": UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS,
"member_permissions": "no_access",
"domain_request_permissions": "no_access",
}, },
) )

View file

@ -27,6 +27,9 @@ def send_domain_invitation_email(
is_member_of_different_org (bool): if an email belongs to a different org is_member_of_different_org (bool): if an email belongs to a different org
requested_user (User | None): The recipient if the email belongs to a user in the registrar requested_user (User | None): The recipient if the email belongs to a user in the registrar
Returns:
Boolean indicating if all messages were sent successfully.
Raises: Raises:
MissingEmailError: If the requestor has no email associated with their account. MissingEmailError: If the requestor has no email associated with their account.
AlreadyDomainManagerError: If the email corresponds to an existing domain manager. AlreadyDomainManagerError: If the email corresponds to an existing domain manager.
@ -41,22 +44,28 @@ def send_domain_invitation_email(
send_invitation_email(email, requestor_email, domains, requested_user) send_invitation_email(email, requestor_email, domains, requested_user)
all_manager_emails_sent = True
# send emails to domain managers # send emails to domain managers
for domain in domains: for domain in domains:
send_emails_to_domain_managers( if not send_emails_to_domain_managers(
email=email, email=email,
requestor_email=requestor_email, requestor_email=requestor_email,
domain=domain, domain=domain,
requested_user=requested_user, requested_user=requested_user,
) ):
all_manager_emails_sent = False
return all_manager_emails_sent
def send_emails_to_domain_managers(email: str, requestor_email, domain: Domain, requested_user=None): def send_emails_to_domain_managers(email: str, requestor_email, domain: Domain, requested_user=None):
""" """
Notifies all domain managers of the provided domain of a change Notifies all domain managers of the provided domain of a change
Raises:
EmailSendingError Returns:
Boolean indicating if all messages were sent successfully.
""" """
all_emails_sent = True
# Get each domain manager from list # Get each domain manager from list
user_domain_roles = UserDomainRole.objects.filter(domain=domain) user_domain_roles = UserDomainRole.objects.filter(domain=domain)
for user_domain_role in user_domain_roles: for user_domain_role in user_domain_roles:
@ -75,10 +84,12 @@ def send_emails_to_domain_managers(email: str, requestor_email, domain: Domain,
"date": date.today(), "date": date.today(),
}, },
) )
except EmailSendingError as err: except EmailSendingError:
raise EmailSendingError( logger.warning(
f"Could not send email manager notification to {user.email} for domain: {domain.name}" f"Could not send email manager notification to {user.email} for domain: {domain.name}", exc_info=True
) from err )
all_emails_sent = False
return all_emails_sent
def normalize_domains(domains: Domain | list[Domain]) -> list[Domain]: def normalize_domains(domains: Domain | list[Domain]) -> list[Domain]:

View file

@ -311,11 +311,39 @@ class DomainView(DomainBaseView):
self._update_session_with_domain() self._update_session_with_domain()
class DomainRenewalView(DomainView): class DomainRenewalView(DomainBaseView):
"""Domain detail overview page.""" """Domain detail overview page."""
template_name = "domain_renewal.html" template_name = "domain_renewal.html"
def get_context_data(self, **kwargs):
"""Grabs the security email information and adds security_email to the renewal form context
sets it to None if it uses a default email"""
context = super().get_context_data(**kwargs)
default_emails = [DefaultEmail.PUBLIC_CONTACT_DEFAULT.value, DefaultEmail.LEGACY_DEFAULT.value]
context["hidden_security_emails"] = default_emails
security_email = self.object.get_security_email()
context["security_email"] = security_email
return context
def in_editable_state(self, pk):
"""Override in_editable_state from DomainPermission
Allow renewal form to be accessed
returns boolean"""
requested_domain = None
if Domain.objects.filter(id=pk).exists():
requested_domain = Domain.objects.get(id=pk)
return (
requested_domain
and requested_domain.is_editable()
and (requested_domain.is_expiring() or requested_domain.is_expired())
)
def post(self, request, pk): def post(self, request, pk):
domain = get_object_or_404(Domain, id=pk) domain = get_object_or_404(Domain, id=pk)
@ -1227,24 +1255,26 @@ class DomainAddUserView(DomainFormBaseView):
def _handle_new_user_invitation(self, email, requestor, member_of_different_org): def _handle_new_user_invitation(self, email, requestor, member_of_different_org):
"""Handle invitation for a new user who does not exist in the system.""" """Handle invitation for a new user who does not exist in the system."""
send_domain_invitation_email( if not send_domain_invitation_email(
email=email, email=email,
requestor=requestor, requestor=requestor,
domains=self.object, domains=self.object,
is_member_of_different_org=member_of_different_org, is_member_of_different_org=member_of_different_org,
) ):
messages.warning(self.request, "Could not send email confirmation to existing domain managers.")
DomainInvitation.objects.get_or_create(email=email, domain=self.object) DomainInvitation.objects.get_or_create(email=email, domain=self.object)
messages.success(self.request, f"{email} has been invited to the domain: {self.object}") messages.success(self.request, f"{email} has been invited to the domain: {self.object}")
def _handle_existing_user(self, email, requestor, requested_user, member_of_different_org): def _handle_existing_user(self, email, requestor, requested_user, member_of_different_org):
"""Handle adding an existing user to the domain.""" """Handle adding an existing user to the domain."""
send_domain_invitation_email( if not send_domain_invitation_email(
email=email, email=email,
requestor=requestor, requestor=requestor,
domains=self.object, domains=self.object,
is_member_of_different_org=member_of_different_org, is_member_of_different_org=member_of_different_org,
requested_user=requested_user, requested_user=requested_user,
) ):
messages.warning(self.request, "Could not send email confirmation to existing domain managers.")
UserDomainRole.objects.create( UserDomainRole.objects.create(
user=requested_user, user=requested_user,
domain=self.object, domain=self.object,

View file

@ -82,6 +82,9 @@ class PortfolioMemberView(PortfolioMemberPermissionView, View):
member_has_edit_members_portfolio_permission = member.has_edit_members_portfolio_permission( member_has_edit_members_portfolio_permission = member.has_edit_members_portfolio_permission(
portfolio_permission.portfolio portfolio_permission.portfolio
) )
member_has_view_all_domains_portfolio_permission = member.has_view_all_domains_portfolio_permission(
portfolio_permission.portfolio
)
return render( return render(
request, request,
@ -95,6 +98,7 @@ class PortfolioMemberView(PortfolioMemberPermissionView, View):
"member_has_edit_request_portfolio_permission": member_has_edit_request_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_view_members_portfolio_permission": member_has_view_members_portfolio_permission,
"member_has_edit_members_portfolio_permission": member_has_edit_members_portfolio_permission, "member_has_edit_members_portfolio_permission": member_has_edit_members_portfolio_permission,
"member_has_view_all_domains_portfolio_permission": member_has_view_all_domains_portfolio_permission,
}, },
) )
@ -299,13 +303,14 @@ class PortfolioMemberDomainsEditView(PortfolioMemberDomainsEditPermissionView, V
# get added_domains from ids to pass to send email method and bulk create # get added_domains from ids to pass to send email method and bulk create
added_domains = Domain.objects.filter(id__in=added_domain_ids) added_domains = Domain.objects.filter(id__in=added_domain_ids)
member_of_a_different_org, _ = get_org_membership(portfolio, member.email, member) member_of_a_different_org, _ = get_org_membership(portfolio, member.email, member)
send_domain_invitation_email( if not send_domain_invitation_email(
email=member.email, email=member.email,
requestor=requestor, requestor=requestor,
domains=added_domains, domains=added_domains,
is_member_of_different_org=member_of_a_different_org, is_member_of_different_org=member_of_a_different_org,
requested_user=member, requested_user=member,
) ):
messages.warning(self.request, "Could not send email confirmation to existing domain managers.")
# Bulk create UserDomainRole instances for added domains # Bulk create UserDomainRole instances for added domains
UserDomainRole.objects.bulk_create( UserDomainRole.objects.bulk_create(
[ [
@ -346,6 +351,9 @@ class PortfolioInvitedMemberView(PortfolioMemberPermissionView, View):
member_has_edit_members_portfolio_permission = ( member_has_edit_members_portfolio_permission = (
UserPortfolioPermissionChoices.EDIT_MEMBERS in portfolio_invitation.get_portfolio_permissions() UserPortfolioPermissionChoices.EDIT_MEMBERS in portfolio_invitation.get_portfolio_permissions()
) )
member_has_view_all_domains_portfolio_permission = (
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS in portfolio_invitation.get_portfolio_permissions()
)
return render( return render(
request, request,
@ -358,6 +366,7 @@ class PortfolioInvitedMemberView(PortfolioMemberPermissionView, View):
"member_has_edit_request_portfolio_permission": member_has_edit_request_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_view_members_portfolio_permission": member_has_view_members_portfolio_permission,
"member_has_edit_members_portfolio_permission": member_has_edit_members_portfolio_permission, "member_has_edit_members_portfolio_permission": member_has_edit_members_portfolio_permission,
"member_has_view_all_domains_portfolio_permission": member_has_view_all_domains_portfolio_permission,
}, },
) )
@ -517,12 +526,13 @@ class PortfolioInvitedMemberDomainsEditView(PortfolioMemberDomainsEditPermission
# get added_domains from ids to pass to send email method and bulk create # get added_domains from ids to pass to send email method and bulk create
added_domains = Domain.objects.filter(id__in=added_domain_ids) added_domains = Domain.objects.filter(id__in=added_domain_ids)
member_of_a_different_org, _ = get_org_membership(portfolio, email, None) member_of_a_different_org, _ = get_org_membership(portfolio, email, None)
send_domain_invitation_email( if not send_domain_invitation_email(
email=email, email=email,
requestor=requestor, requestor=requestor,
domains=added_domains, domains=added_domains,
is_member_of_different_org=member_of_a_different_org, is_member_of_different_org=member_of_a_different_org,
) ):
messages.warning(self.request, "Could not send email confirmation to existing domain managers.")
# Update existing invitations from CANCELED to INVITED # Update existing invitations from CANCELED to INVITED
existing_invitations = DomainInvitation.objects.filter(domain__in=added_domains, email=email) existing_invitations = DomainInvitation.objects.filter(domain__in=added_domains, email=email)
@ -799,7 +809,7 @@ class PortfolioAddMemberView(PortfolioMembersPermissionView, FormMixin):
portfolio, portfolio,
exc_info=True, exc_info=True,
) )
messages.warning(self.request, "Could not send email invitation.") messages.warning(self.request, "Could not send portfolio email invitation.")
elif isinstance(exception, MissingEmailError): elif isinstance(exception, MissingEmailError):
messages.error(self.request, str(exception)) messages.error(self.request, str(exception))
logger.error( logger.error(
@ -808,4 +818,4 @@ class PortfolioAddMemberView(PortfolioMembersPermissionView, FormMixin):
) )
else: else:
logger.warning("Could not send email invitation (Other Exception)", exc_info=True) logger.warning("Could not send email invitation (Other Exception)", exc_info=True)
messages.warning(self.request, "Could not send email invitation.") messages.warning(self.request, "Could not send portfolio email invitation.")

View file

@ -3,7 +3,6 @@ from django.db import IntegrityError
from registrar.models import PortfolioInvitation, User, UserPortfolioPermission from registrar.models import PortfolioInvitation, User, UserPortfolioPermission
from registrar.utility.email import EmailSendingError from registrar.utility.email import EmailSendingError
import logging import logging
from registrar.utility.errors import ( from registrar.utility.errors import (
AlreadyDomainInvitedError, AlreadyDomainInvitedError,
AlreadyDomainManagerError, AlreadyDomainManagerError,
@ -61,20 +60,19 @@ def get_requested_user(email):
def handle_invitation_exceptions(request, exception, email): def handle_invitation_exceptions(request, exception, email):
"""Handle exceptions raised during the process.""" """Handle exceptions raised during the process."""
if isinstance(exception, EmailSendingError): if isinstance(exception, EmailSendingError):
logger.warning(str(exception), exc_info=True) logger.warning(exception, exc_info=True)
messages.error(request, str(exception)) messages.error(request, str(exception))
elif isinstance(exception, MissingEmailError): elif isinstance(exception, MissingEmailError):
messages.error(request, str(exception)) messages.error(request, str(exception))
logger.error(str(exception), exc_info=True) logger.error(exception, exc_info=True)
elif isinstance(exception, OutsideOrgMemberError): elif isinstance(exception, OutsideOrgMemberError):
messages.error(request, str(exception)) messages.error(request, str(exception))
logger.warning(str(exception), exc_info=True)
elif isinstance(exception, AlreadyDomainManagerError): elif isinstance(exception, AlreadyDomainManagerError):
messages.warning(request, str(exception)) messages.error(request, str(exception))
elif isinstance(exception, AlreadyDomainInvitedError): elif isinstance(exception, AlreadyDomainInvitedError):
messages.warning(request, str(exception)) messages.error(request, str(exception))
elif isinstance(exception, IntegrityError): elif isinstance(exception, IntegrityError):
messages.warning(request, f"{email} is already a manager for this domain") messages.error(request, f"{email} is already a manager for this domain")
else: else:
logger.warning("Could not send email invitation (Other Exception)", exc_info=True) logger.warning("Could not send email invitation (Other Exception)", exc_info=True)
messages.warning(request, "Could not send email invitation.") messages.error(request, "Could not send email invitation.")

View file

@ -234,7 +234,8 @@ class DomainPermission(PermissionsLoginMixin):
def can_access_domain_via_portfolio(self, pk): def can_access_domain_via_portfolio(self, pk):
"""Most views should not allow permission to portfolio users. """Most views should not allow permission to portfolio users.
If particular views allow access to the domain pages, they will need to override If particular views allow access to the domain pages, they will need to override
this function.""" this function.
"""
return False return False
def in_editable_state(self, pk): def in_editable_state(self, pk):