mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-22 10:46:06 +02:00
Merge branch 'main' into za/2771-create-requesting-entity-page
This commit is contained in:
commit
ab7a6ac12d
64 changed files with 3216 additions and 1395 deletions
2009
src/Pipfile.lock
generated
2009
src/Pipfile.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -25,7 +25,7 @@ services:
|
|||
# Run Django in debug mode on local
|
||||
- DJANGO_DEBUG=True
|
||||
# Set DJANGO_LOG_LEVEL in env
|
||||
- DJANGO_LOG_LEVEL
|
||||
- DJANGO_LOG_LEVEL=DEBUG
|
||||
# Run Django without production flags
|
||||
- IS_PRODUCTION=False
|
||||
# Tell Django where it is being hosted
|
||||
|
|
|
@ -190,11 +190,11 @@ class PortfolioInvitationAdminForm(UserChangeForm):
|
|||
model = models.PortfolioInvitation
|
||||
fields = "__all__"
|
||||
widgets = {
|
||||
"portfolio_roles": FilteredSelectMultipleArrayWidget(
|
||||
"portfolio_roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices
|
||||
"roles": FilteredSelectMultipleArrayWidget(
|
||||
"roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices
|
||||
),
|
||||
"portfolio_additional_permissions": FilteredSelectMultipleArrayWidget(
|
||||
"portfolio_additional_permissions",
|
||||
"additional_permissions": FilteredSelectMultipleArrayWidget(
|
||||
"additional_permissions",
|
||||
is_stacked=False,
|
||||
choices=UserPortfolioPermissionChoices.choices,
|
||||
),
|
||||
|
@ -1409,8 +1409,8 @@ class PortfolioInvitationAdmin(ListHeaderAdmin):
|
|||
list_display = [
|
||||
"email",
|
||||
"portfolio",
|
||||
"portfolio_roles",
|
||||
"portfolio_additional_permissions",
|
||||
"roles",
|
||||
"additional_permissions",
|
||||
"status",
|
||||
]
|
||||
|
||||
|
@ -2473,7 +2473,10 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
generic_org_type.admin_order_field = "domain_info__generic_org_type" # type: ignore
|
||||
|
||||
def federal_agency(self, obj):
|
||||
return obj.domain_info.federal_agency if obj.domain_info else None
|
||||
if obj.domain_info:
|
||||
return obj.domain_info.federal_agency
|
||||
else:
|
||||
return None
|
||||
|
||||
federal_agency.admin_order_field = "domain_info__federal_agency" # type: ignore
|
||||
|
||||
|
|
|
@ -1614,8 +1614,9 @@ class DomainRequestsTable extends LoadTableBase {
|
|||
const submissionDate = request.last_submitted_date ? new Date(request.last_submitted_date).toLocaleDateString('en-US', options) : `<span class="text-base">Not submitted</span>`;
|
||||
|
||||
// The markup for the delete function either be a simple trigger or a 3 dots menu with a hidden trigger (in the case of portfolio requests page)
|
||||
// Even if the request is not deletable, we may need these empty strings for the td if the deletable column is displayed
|
||||
let modalTrigger = '';
|
||||
// If the request is not deletable, use the following (hidden) span for ANDI screenreaders to indicate this state to the end user
|
||||
let modalTrigger = `
|
||||
<span class="usa-sr-only">Domain request cannot be deleted now. Edit the request for more information.</span>`;
|
||||
|
||||
let markupCreatorRow = '';
|
||||
|
||||
|
@ -1627,8 +1628,8 @@ class DomainRequestsTable extends LoadTableBase {
|
|||
`
|
||||
}
|
||||
|
||||
// If the request is deletable, create modal body and insert it. This is true for both requests and portfolio requests pages
|
||||
if (request.is_deletable) {
|
||||
// If the request is deletable, create modal body and insert it. This is true for both requests and portfolio requests pages
|
||||
let modalHeading = '';
|
||||
let modalDescription = '';
|
||||
|
||||
|
@ -1882,11 +1883,10 @@ class MembersTable extends LoadTableBase {
|
|||
* @param {*} sortBy - the sort column option
|
||||
* @param {*} order - the sort order {asc, desc}
|
||||
* @param {*} scroll - control for the scrollToElement functionality
|
||||
* @param {*} status - control for the status filter
|
||||
* @param {*} searchTerm - the search term
|
||||
* @param {*} portfolio - the portfolio id
|
||||
*/
|
||||
loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) {
|
||||
loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) {
|
||||
|
||||
// --------- SEARCH
|
||||
let searchParams = new URLSearchParams(
|
||||
|
@ -1894,7 +1894,6 @@ class MembersTable extends LoadTableBase {
|
|||
"page": page,
|
||||
"sort_by": sortBy,
|
||||
"order": order,
|
||||
"status": status,
|
||||
"search_term": searchTerm
|
||||
}
|
||||
);
|
||||
|
@ -1930,11 +1929,40 @@ class MembersTable extends LoadTableBase {
|
|||
const memberList = document.querySelector('.members__table tbody');
|
||||
memberList.innerHTML = '';
|
||||
|
||||
const invited = 'Invited';
|
||||
|
||||
data.members.forEach(member => {
|
||||
// const actionUrl = domain.action_url;
|
||||
const member_name = member.name;
|
||||
const member_email = member.email;
|
||||
const last_active = member.last_active;
|
||||
const member_display = member.member_display;
|
||||
const options = { year: 'numeric', month: 'short', day: 'numeric' };
|
||||
|
||||
// Handle last_active values
|
||||
let last_active = member.last_active;
|
||||
let last_active_formatted = '';
|
||||
let last_active_sort_value = '';
|
||||
|
||||
// Handle 'Invited' or null/empty values differently from valid dates
|
||||
if (last_active && last_active !== invited) {
|
||||
try {
|
||||
// Try to parse the last_active as a valid date
|
||||
last_active = new Date(last_active);
|
||||
if (!isNaN(last_active)) {
|
||||
last_active_formatted = last_active.toLocaleDateString('en-US', options);
|
||||
last_active_sort_value = last_active.getTime(); // For sorting purposes
|
||||
} else {
|
||||
last_active_formatted='Invalid date'
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error parsing date: ${last_active}. Error: ${e}`);
|
||||
last_active_formatted='Invalid date'
|
||||
}
|
||||
} else {
|
||||
// Handle 'Invited' or null
|
||||
last_active = invited;
|
||||
last_active_formatted = invited;
|
||||
last_active_sort_value = invited; // Keep 'Invited' as a sortable string
|
||||
}
|
||||
|
||||
const action_url = member.action_url;
|
||||
const action_label = member.action_label;
|
||||
const svg_icon = member.svg_icon;
|
||||
|
@ -1947,10 +1975,10 @@ class MembersTable extends LoadTableBase {
|
|||
|
||||
row.innerHTML = `
|
||||
<th scope="row" role="rowheader" data-label="member email">
|
||||
${member_email ? member_email : member_name} ${admin_tagHTML}
|
||||
${member_display} ${admin_tagHTML}
|
||||
</th>
|
||||
<td data-sort-value="${last_active}" data-label="last_active">
|
||||
${last_active}
|
||||
<td data-sort-value="${last_active_sort_value}" data-label="last_active">
|
||||
${last_active_formatted}
|
||||
</td>
|
||||
<td>
|
||||
<a href="${action_url}">
|
||||
|
|
|
@ -25,6 +25,8 @@
|
|||
/**
|
||||
* Edits made for dotgov project:
|
||||
* - tooltip exposed to window to be accessible in other js files
|
||||
* - tooltip positioning logic updated to allow position:fixed
|
||||
* - tooltip dynamic content updated to include nested element (for better sizing control)
|
||||
* - modal exposed to window to be accessible in other js files
|
||||
* - fixed bug in createHeaderButton which added newlines to header button tooltips
|
||||
*/
|
||||
|
@ -5938,6 +5940,22 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
|
|||
return offset;
|
||||
};
|
||||
|
||||
// ---- DOTGOV EDIT (Added section)
|
||||
// DOTGOV: Tooltip positioning logic updated to allow position:fixed
|
||||
const tooltipStyle = window.getComputedStyle(tooltipBody);
|
||||
const tooltipIsFixedPositioned = tooltipStyle.position === 'fixed';
|
||||
const triggerRect = tooltipTrigger.getBoundingClientRect(); //detect if tooltip is set to "fixed" position
|
||||
const targetLeft = tooltipIsFixedPositioned ? triggerRect.left + triggerRect.width/2 + 'px': `50%`
|
||||
const targetTop = tooltipIsFixedPositioned ? triggerRect.top + triggerRect.height/2 + 'px': `50%`
|
||||
if (tooltipIsFixedPositioned) {
|
||||
/* DOTGOV: Add listener to handle scrolling if tooltip position = 'fixed'
|
||||
(so that the tooltip doesn't appear to stick to the screen) */
|
||||
window.addEventListener('scroll', function() {
|
||||
findBestPosition(tooltipBody)
|
||||
});
|
||||
}
|
||||
// ---- END DOTGOV EDIT
|
||||
|
||||
/**
|
||||
* Positions tooltip at the top
|
||||
* @param {HTMLElement} e - this is the tooltip body
|
||||
|
@ -5949,8 +5967,16 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
|
|||
const topMargin = calculateMarginOffset("top", e.offsetHeight, tooltipTrigger);
|
||||
const leftMargin = calculateMarginOffset("left", e.offsetWidth, tooltipTrigger);
|
||||
setPositionClass("top");
|
||||
e.style.left = `50%`; // center the element
|
||||
e.style.top = `-${TRIANGLE_SIZE}px`; // consider the pseudo element
|
||||
|
||||
// ---- DOTGOV EDIT
|
||||
// e.style.left = `50%`; // center the element
|
||||
// e.style.top = `-${TRIANGLE_SIZE}px`; // consider the pseudo element
|
||||
|
||||
// DOTGOV: updated logic for position:fixed
|
||||
e.style.left = targetLeft; // center the element
|
||||
e.style.top = tooltipIsFixedPositioned ?`${triggerRect.top-TRIANGLE_SIZE}px`:`-${TRIANGLE_SIZE}px`; // consider the pseudo element
|
||||
// ---- END DOTGOV EDIT
|
||||
|
||||
// apply our margins based on the offset
|
||||
e.style.margin = `-${topMargin}px 0 0 -${leftMargin / 2}px`;
|
||||
};
|
||||
|
@ -5963,7 +5989,17 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
|
|||
resetPositionStyles(e);
|
||||
const leftMargin = calculateMarginOffset("left", e.offsetWidth, tooltipTrigger);
|
||||
setPositionClass("bottom");
|
||||
e.style.left = `50%`;
|
||||
|
||||
// ---- DOTGOV EDIT
|
||||
// e.style.left = `50%`;
|
||||
|
||||
// DOTGOV: updated logic for position:fixed
|
||||
if (tooltipIsFixedPositioned){
|
||||
e.style.top = triggerRect.bottom+'px';
|
||||
}
|
||||
// ---- END DOTGOV EDIT
|
||||
|
||||
e.style.left = targetLeft;
|
||||
e.style.margin = `${TRIANGLE_SIZE}px 0 0 -${leftMargin / 2}px`;
|
||||
};
|
||||
|
||||
|
@ -5975,8 +6011,16 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
|
|||
resetPositionStyles(e);
|
||||
const topMargin = calculateMarginOffset("top", e.offsetHeight, tooltipTrigger);
|
||||
setPositionClass("right");
|
||||
e.style.top = `50%`;
|
||||
e.style.left = `${tooltipTrigger.offsetLeft + tooltipTrigger.offsetWidth + TRIANGLE_SIZE}px`;
|
||||
|
||||
// ---- DOTGOV EDIT
|
||||
// e.style.top = `50%`;
|
||||
// e.style.left = `${tooltipTrigger.offsetLeft + tooltipTrigger.offsetWidth + TRIANGLE_SIZE}px`;
|
||||
|
||||
// DOTGOV: updated logic for position:fixed
|
||||
e.style.top = targetTop;
|
||||
e.style.left = tooltipIsFixedPositioned ? `${triggerRect.right + TRIANGLE_SIZE}px`:`${tooltipTrigger.offsetLeft + tooltipTrigger.offsetWidth + TRIANGLE_SIZE}px`;
|
||||
// ---- END DOTGOV EDIT
|
||||
|
||||
e.style.margin = `-${topMargin / 2}px 0 0 0`;
|
||||
};
|
||||
|
||||
|
@ -5991,8 +6035,16 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
|
|||
// we have to check for some utility margins
|
||||
const leftMargin = calculateMarginOffset("left", tooltipTrigger.offsetLeft > e.offsetWidth ? tooltipTrigger.offsetLeft - e.offsetWidth : e.offsetWidth, tooltipTrigger);
|
||||
setPositionClass("left");
|
||||
e.style.top = `50%`;
|
||||
e.style.left = `-${TRIANGLE_SIZE}px`;
|
||||
|
||||
// ---- DOTGOV EDIT
|
||||
// e.style.top = `50%`;
|
||||
// e.style.left = `-${TRIANGLE_SIZE}px`;
|
||||
|
||||
// DOTGOV: updated logic for position:fixed
|
||||
e.style.top = targetTop;
|
||||
e.style.left = tooltipIsFixedPositioned ? `${triggerRect.left-TRIANGLE_SIZE}px` : `-${TRIANGLE_SIZE}px`;
|
||||
// ---- END DOTGOV EDIT
|
||||
|
||||
e.style.margin = `-${topMargin / 2}px 0 0 ${tooltipTrigger.offsetLeft > e.offsetWidth ? leftMargin : -leftMargin}px`; // adjust the margin
|
||||
};
|
||||
|
||||
|
@ -6017,6 +6069,7 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
|
|||
if (i < positions.length) {
|
||||
const pos = positions[i];
|
||||
pos(element);
|
||||
|
||||
if (!isElementInViewport(element)) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
tryPositions(i += 1);
|
||||
|
@ -6128,7 +6181,17 @@ const setUpAttributes = tooltipTrigger => {
|
|||
tooltipBody.setAttribute("aria-hidden", "true");
|
||||
|
||||
// place the text in the tooltip
|
||||
tooltipBody.textContent = tooltipContent;
|
||||
|
||||
// -- DOTGOV EDIT
|
||||
// tooltipBody.textContent = tooltipContent;
|
||||
|
||||
// DOTGOV: nest the text element to allow us greater control over width and wrapping behavior
|
||||
tooltipBody.innerHTML = `
|
||||
<span class="usa-tooltip__content">
|
||||
${tooltipContent}
|
||||
</span>`
|
||||
// -- END DOTGOV EDIT
|
||||
|
||||
return {
|
||||
tooltipBody,
|
||||
position,
|
||||
|
|
|
@ -898,3 +898,10 @@ ul.add-list-reset {
|
|||
font-weight: 600;
|
||||
font-size: .8125rem;
|
||||
}
|
||||
|
||||
.change-form .usa-table {
|
||||
td {
|
||||
color: inherit !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -254,6 +254,7 @@ a .usa-icon,
|
|||
// Note: Can be simplified by adding text-secondary to delete anchors in tables
|
||||
button.text-secondary,
|
||||
button.text-secondary:hover,
|
||||
.dotgov-table a.text-secondary {
|
||||
a.text-secondary,
|
||||
a.text-secondary:hover {
|
||||
color: $theme-color-error;
|
||||
}
|
||||
|
|
|
@ -28,3 +28,47 @@
|
|||
#extended-logo .usa-tooltip__body {
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
|
||||
.domains__table {
|
||||
/*
|
||||
Trick tooltips in the domains table to do 2 things...
|
||||
1 - Shrink itself to a padded viewport window
|
||||
(override width and wrapping properties in key areas to constrain tooltip size)
|
||||
2 - NOT be clipped by the table's scrollable view
|
||||
(Set tooltip position to "fixed", which prevents tooltip from being clipped by parent
|
||||
containers. Fixed-position detection was added to uswds positioning logic to update positioning
|
||||
calculations accordingly.)
|
||||
*/
|
||||
.usa-tooltip__body {
|
||||
white-space: inherit;
|
||||
max-width: fit-content; // prevent adjusted widths from being larger than content
|
||||
position: fixed; // prevents clipping by parent containers
|
||||
}
|
||||
/*
|
||||
Override width adustments in this dynamically added class
|
||||
(this is original to the javascript handler as a way to shrink tooltip contents within the viewport,
|
||||
but is insufficient for our needs. We cannot simply override its properties
|
||||
because the logic surrounding its dynamic appearance in the DOM does not account
|
||||
for parent containers (basically, this class isn't in the DOM when we need it).
|
||||
Intercept .usa-tooltip__content instead and nullify the effects of
|
||||
.usa-tooltip__body--wrap to prevent conflicts)
|
||||
*/
|
||||
.usa-tooltip__body--wrap {
|
||||
min-width: inherit;
|
||||
width: inherit;
|
||||
}
|
||||
/*
|
||||
Add width and wrapping to tooltip content in order to confine it to a smaller viewport window.
|
||||
*/
|
||||
.usa-tooltip__content {
|
||||
width: 50vw;
|
||||
text-wrap: wrap;
|
||||
text-align: center;
|
||||
font-size: inherit; //inherit tooltip fontsize of .93rem
|
||||
max-width: fit-content;
|
||||
@include at-media('desktop') {
|
||||
width: 70vw;
|
||||
}
|
||||
display: block;
|
||||
}
|
||||
}
|
|
@ -476,8 +476,10 @@ class JsonServerFormatter(ServerFormatter):
|
|||
|
||||
def format(self, record):
|
||||
formatted_record = super().format(record)
|
||||
|
||||
if not hasattr(record, "server_time"):
|
||||
record.server_time = self.formatTime(record, self.datefmt)
|
||||
|
||||
log_entry = {"server_time": record.server_time, "level": record.levelname, "message": formatted_record}
|
||||
return json.dumps(log_entry)
|
||||
|
||||
|
|
|
@ -86,6 +86,26 @@ urlpatterns = [
|
|||
views.PortfolioMembersView.as_view(),
|
||||
name="members",
|
||||
),
|
||||
path(
|
||||
"member/<int:pk>",
|
||||
views.PortfolioMemberView.as_view(),
|
||||
name="member",
|
||||
),
|
||||
path(
|
||||
"member/<int:pk>/permissions",
|
||||
views.PortfolioMemberEditView.as_view(),
|
||||
name="member-permissions",
|
||||
),
|
||||
path(
|
||||
"invitedmember/<int:pk>",
|
||||
views.PortfolioInvitedMemberView.as_view(),
|
||||
name="invitedmember",
|
||||
),
|
||||
path(
|
||||
"invitedmember/<int:pk>/permissions",
|
||||
views.PortfolioInvitedMemberEditView.as_view(),
|
||||
name="invitedmember-permissions",
|
||||
),
|
||||
# path(
|
||||
# "no-organization-members/",
|
||||
# views.PortfolioNoMembersView.as_view(),
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import logging
|
||||
import random
|
||||
from faker import Faker
|
||||
from django.db import transaction
|
||||
|
||||
|
@ -7,7 +8,7 @@ from registrar.fixtures.fixtures_users import UserFixture
|
|||
from registrar.models import User
|
||||
from registrar.models.portfolio import Portfolio
|
||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
|
||||
fake = Faker()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -51,22 +52,24 @@ class UserPortfolioPermissionFixture:
|
|||
|
||||
user_portfolio_permissions_to_create = []
|
||||
for user in users:
|
||||
for portfolio in portfolios:
|
||||
try:
|
||||
if not UserPortfolioPermission.objects.filter(user=user, portfolio=portfolio).exists():
|
||||
user_portfolio_permission = UserPortfolioPermission(
|
||||
user=user,
|
||||
portfolio=portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
)
|
||||
user_portfolio_permissions_to_create.append(user_portfolio_permission)
|
||||
else:
|
||||
logger.info(
|
||||
f"Permission exists for user '{user.username}' "
|
||||
f"on portfolio '{portfolio.organization_name}'."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
# Assign a random portfolio to a user
|
||||
portfolio = random.choice(portfolios) # nosec
|
||||
try:
|
||||
if not UserPortfolioPermission.objects.filter(user=user, portfolio=portfolio).exists():
|
||||
user_portfolio_permission = UserPortfolioPermission(
|
||||
user=user,
|
||||
portfolio=portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[UserPortfolioPermissionChoices.EDIT_MEMBERS],
|
||||
)
|
||||
user_portfolio_permissions_to_create.append(user_portfolio_permission)
|
||||
else:
|
||||
logger.info(
|
||||
f"Permission exists for user '{user.username}' "
|
||||
f"on portfolio '{portfolio.organization_name}'."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
|
||||
# Bulk create permissions
|
||||
cls._bulk_create_permissions(user_portfolio_permissions_to_create)
|
||||
|
|
|
@ -137,6 +137,20 @@ class UserFixture:
|
|||
"email": "annagingle@truss.works",
|
||||
"title": "Sweetwater sailor",
|
||||
},
|
||||
{
|
||||
"username": "63688d43-82c6-480c-8e49-8a1bfdd33b9f",
|
||||
"first_name": "Elizabeth",
|
||||
"last_name": "Liao",
|
||||
"email": "elizabeth.liao@cisa.dhs.gov",
|
||||
"title": "Software Engineer",
|
||||
},
|
||||
{
|
||||
"username": "c9c64cd5-bc76-45ef-85cd-4f6eefa9e998",
|
||||
"first_name": "Samiyah",
|
||||
"last_name": "Key",
|
||||
"email": "skey@truss.works",
|
||||
"title": "Designer",
|
||||
},
|
||||
]
|
||||
|
||||
STAFF = [
|
||||
|
@ -231,6 +245,18 @@ class UserFixture:
|
|||
"last_name": "Gingle-Analyst",
|
||||
"email": "annagingle+analyst@truss.works",
|
||||
},
|
||||
{
|
||||
"username": "0c27b05d-0aa3-45fa-91bd-83ee307708df",
|
||||
"first_name": "Elizabeth-Analyst",
|
||||
"last_name": "Liao-Analyst",
|
||||
"email": "elizabeth.liao@gwe.cisa.dhs.gov",
|
||||
},
|
||||
{
|
||||
"username": "ee1e68da-41a5-47f7-949b-d8a4e9e2b9d2",
|
||||
"first_name": "Samiyah-Analyst",
|
||||
"last_name": "Key-Analyst",
|
||||
"email": "skey+1@truss.works",
|
||||
},
|
||||
]
|
||||
|
||||
# Additional emails to add to the AllowedEmail whitelist.
|
||||
|
|
|
@ -13,4 +13,5 @@ from .domain import (
|
|||
)
|
||||
from .portfolio import (
|
||||
PortfolioOrgAddressForm,
|
||||
PortfolioMemberForm,
|
||||
)
|
||||
|
|
|
@ -4,7 +4,7 @@ import logging
|
|||
from django import forms
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator, RegexValidator, MaxLengthValidator
|
||||
from django.forms import formset_factory
|
||||
from registrar.models import DomainRequest
|
||||
from registrar.models import DomainRequest, FederalAgency
|
||||
from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
||||
from registrar.models.suborganization import Suborganization
|
||||
from registrar.models.utility.domain_helper import DomainHelper
|
||||
|
@ -35,7 +35,10 @@ class DomainAddUserForm(forms.Form):
|
|||
email = forms.EmailField(
|
||||
label="Email",
|
||||
max_length=None,
|
||||
error_messages={"invalid": ("Enter your email address in the required format, like name@example.com.")},
|
||||
error_messages={
|
||||
"invalid": ("Enter an email address in the required format, like name@example.com."),
|
||||
"required": ("Enter an email address in the required format, like name@example.com."),
|
||||
},
|
||||
validators=[
|
||||
MaxLengthValidator(
|
||||
320,
|
||||
|
@ -285,7 +288,7 @@ class UserForm(forms.ModelForm):
|
|||
"required": "Enter your title or role in your organization (e.g., Chief Information Officer)"
|
||||
}
|
||||
self.fields["email"].error_messages = {
|
||||
"required": "Enter your email address in the required format, like name@example.com."
|
||||
"required": "Enter an email address in the required format, like name@example.com."
|
||||
}
|
||||
self.fields["phone"].error_messages["required"] = "Enter your phone number."
|
||||
self.domainInfo = None
|
||||
|
@ -342,7 +345,7 @@ class ContactForm(forms.ModelForm):
|
|||
"required": "Enter your title or role in your organization (e.g., Chief Information Officer)"
|
||||
}
|
||||
self.fields["email"].error_messages = {
|
||||
"required": "Enter your email address in the required format, like name@example.com."
|
||||
"required": "Enter an email address in the required format, like name@example.com."
|
||||
}
|
||||
self.fields["phone"].error_messages["required"] = "Enter your phone number."
|
||||
self.domainInfo = None
|
||||
|
@ -458,9 +461,12 @@ class DomainOrgNameAddressForm(forms.ModelForm):
|
|||
validators=[
|
||||
RegexValidator(
|
||||
"^[0-9]{5}(?:-[0-9]{4})?$|^$",
|
||||
message="Enter a zip code in the required format, like 12345 or 12345-6789.",
|
||||
message="Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.",
|
||||
)
|
||||
],
|
||||
error_messages={
|
||||
"required": "Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.",
|
||||
},
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
@ -529,17 +535,25 @@ class DomainOrgNameAddressForm(forms.ModelForm):
|
|||
|
||||
def save(self, commit=True):
|
||||
"""Override the save() method of the BaseModelForm."""
|
||||
|
||||
if self.has_changed():
|
||||
|
||||
# This action should be blocked by the UI, as the text fields are readonly.
|
||||
# If they get past this point, we forbid it this way.
|
||||
# This could be malicious, so lets reserve information for the backend only.
|
||||
if self.is_federal and not self._field_unchanged("federal_agency"):
|
||||
raise ValueError("federal_agency cannot be modified when the generic_org_type is federal")
|
||||
|
||||
if self.is_federal:
|
||||
if not self._field_unchanged("federal_agency"):
|
||||
raise ValueError("federal_agency cannot be modified when the generic_org_type is federal")
|
||||
|
||||
elif self.is_tribal and not self._field_unchanged("organization_name"):
|
||||
raise ValueError("organization_name cannot be modified when the generic_org_type is tribal")
|
||||
|
||||
super().save()
|
||||
else: # If this error that means Non-Federal Agency is missing
|
||||
non_federal_agency_instance = FederalAgency.get_non_federal_agency()
|
||||
self.instance.federal_agency = non_federal_agency_instance
|
||||
|
||||
return super().save(commit=commit)
|
||||
|
||||
def _field_unchanged(self, field_name) -> bool:
|
||||
"""
|
||||
|
|
|
@ -151,6 +151,7 @@ class OrganizationTypeForm(RegistrarForm):
|
|||
choices=DomainRequest.OrganizationChoicesVerbose.choices,
|
||||
widget=forms.RadioSelect,
|
||||
error_messages={"required": "Select the type of organization you represent."},
|
||||
label="What kind of U.S.-based government organization do you represent?",
|
||||
)
|
||||
|
||||
|
||||
|
@ -194,6 +195,7 @@ class OrganizationFederalForm(RegistrarForm):
|
|||
federal_type = forms.ChoiceField(
|
||||
choices=BranchChoices.choices,
|
||||
widget=forms.RadioSelect,
|
||||
label="Which federal branch is your organization in?",
|
||||
error_messages={"required": ("Select the part of the federal government your organization is in.")},
|
||||
)
|
||||
|
||||
|
@ -205,7 +207,8 @@ class OrganizationElectionForm(RegistrarForm):
|
|||
(True, "Yes"),
|
||||
(False, "No"),
|
||||
],
|
||||
)
|
||||
),
|
||||
label="Is your organization an election office?",
|
||||
)
|
||||
|
||||
def clean_is_election_board(self):
|
||||
|
@ -261,10 +264,10 @@ class OrganizationContactForm(RegistrarForm):
|
|||
validators=[
|
||||
RegexValidator(
|
||||
"^[0-9]{5}(?:-[0-9]{4})?$|^$",
|
||||
message="Enter a zip code in the form of 12345 or 12345-6789.",
|
||||
message="Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.",
|
||||
)
|
||||
],
|
||||
error_messages={"required": ("Enter a zip code in the form of 12345 or 12345-6789.")},
|
||||
error_messages={"required": ("Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.")},
|
||||
)
|
||||
urbanization = forms.CharField(
|
||||
required=False,
|
||||
|
@ -350,7 +353,10 @@ class SeniorOfficialForm(RegistrarForm):
|
|||
email = forms.EmailField(
|
||||
label="Email",
|
||||
max_length=None,
|
||||
error_messages={"invalid": ("Enter an email address in the required format, like name@example.com.")},
|
||||
error_messages={
|
||||
"invalid": ("Enter an email address in the required format, like name@example.com."),
|
||||
"required": ("Enter an email address in the required format, like name@example.com."),
|
||||
},
|
||||
validators=[
|
||||
MaxLengthValidator(
|
||||
320,
|
||||
|
@ -564,6 +570,7 @@ class OtherContactsForm(RegistrarForm):
|
|||
message="Response must be less than 320 characters.",
|
||||
)
|
||||
],
|
||||
help_text="Enter an email address in the required format, like name@example.com.",
|
||||
)
|
||||
phone = PhoneNumberField(
|
||||
label="Phone",
|
||||
|
@ -727,7 +734,8 @@ class CisaRepresentativeForm(BaseDeletableRegistrarForm):
|
|||
max_length=None,
|
||||
required=False,
|
||||
error_messages={
|
||||
"invalid": ("Enter your representative’s email address in the required format, like name@example.com."),
|
||||
"invalid": ("Enter an email address in the required format, like name@example.com."),
|
||||
"required": ("Enter an email address in the required format, like name@example.com."),
|
||||
},
|
||||
validators=[
|
||||
MaxLengthValidator(
|
||||
|
|
|
@ -4,7 +4,14 @@ import logging
|
|||
from django import forms
|
||||
from django.core.validators import RegexValidator
|
||||
|
||||
from ..models import DomainInformation, Portfolio, SeniorOfficial
|
||||
from registrar.models import (
|
||||
PortfolioInvitation,
|
||||
UserPortfolioPermission,
|
||||
DomainInformation,
|
||||
Portfolio,
|
||||
SeniorOfficial,
|
||||
)
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -17,9 +24,12 @@ class PortfolioOrgAddressForm(forms.ModelForm):
|
|||
validators=[
|
||||
RegexValidator(
|
||||
"^[0-9]{5}(?:-[0-9]{4})?$|^$",
|
||||
message="Enter a zip code in the required format, like 12345 or 12345-6789.",
|
||||
message="Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.",
|
||||
)
|
||||
],
|
||||
error_messages={
|
||||
"required": "Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.",
|
||||
},
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
@ -38,6 +48,7 @@ class PortfolioOrgAddressForm(forms.ModelForm):
|
|||
"state_territory": {
|
||||
"required": "Select the state, territory, or military post where your organization is located."
|
||||
},
|
||||
"zipcode": {"required": "Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789."},
|
||||
}
|
||||
widgets = {
|
||||
# We need to set the required attributed for State/territory
|
||||
|
@ -95,3 +106,57 @@ class PortfolioSeniorOfficialForm(forms.ModelForm):
|
|||
cleaned_data = super().clean()
|
||||
cleaned_data.pop("full_name", None)
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class PortfolioMemberForm(forms.ModelForm):
|
||||
"""
|
||||
Form for updating a portfolio member.
|
||||
"""
|
||||
|
||||
roles = forms.MultipleChoiceField(
|
||||
choices=UserPortfolioRoleChoices.choices,
|
||||
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
|
||||
required=False,
|
||||
label="Roles",
|
||||
)
|
||||
|
||||
additional_permissions = forms.MultipleChoiceField(
|
||||
choices=UserPortfolioPermissionChoices.choices,
|
||||
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
|
||||
required=False,
|
||||
label="Additional Permissions",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = UserPortfolioPermission
|
||||
fields = [
|
||||
"roles",
|
||||
"additional_permissions",
|
||||
]
|
||||
|
||||
|
||||
class PortfolioInvitedMemberForm(forms.ModelForm):
|
||||
"""
|
||||
Form for updating a portfolio invited member.
|
||||
"""
|
||||
|
||||
roles = forms.MultipleChoiceField(
|
||||
choices=UserPortfolioRoleChoices.choices,
|
||||
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
|
||||
required=False,
|
||||
label="Roles",
|
||||
)
|
||||
|
||||
additional_permissions = forms.MultipleChoiceField(
|
||||
choices=UserPortfolioPermissionChoices.choices,
|
||||
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
|
||||
required=False,
|
||||
label="Additional Permissions",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PortfolioInvitation
|
||||
fields = [
|
||||
"roles",
|
||||
"additional_permissions",
|
||||
]
|
||||
|
|
|
@ -58,7 +58,7 @@ class UserProfileForm(forms.ModelForm):
|
|||
"required": "Enter your title or role in your organization (e.g., Chief Information Officer)"
|
||||
}
|
||||
self.fields["email"].error_messages = {
|
||||
"required": "Enter your email address in the required format, like name@example.com."
|
||||
"required": "Enter an email address in the required format, like name@example.com."
|
||||
}
|
||||
self.fields["phone"].error_messages["required"] = "Enter your phone number."
|
||||
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 4.2.10 on 2024-10-11 19:58
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("registrar", "0133_domainrequest_rejection_reason_email_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="portfolioinvitation",
|
||||
old_name="portfolio_additional_permissions",
|
||||
new_name="additional_permissions",
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="portfolioinvitation",
|
||||
old_name="portfolio_roles",
|
||||
new_name="roles",
|
||||
),
|
||||
]
|
|
@ -849,7 +849,6 @@ class DomainRequest(TimeStampedModel):
|
|||
|
||||
if custom_email_content:
|
||||
context["custom_email_content"] = custom_email_content
|
||||
|
||||
send_templated_email(
|
||||
email_template,
|
||||
email_template_subject,
|
||||
|
@ -895,7 +894,6 @@ class DomainRequest(TimeStampedModel):
|
|||
DraftDomain = apps.get_model("registrar.DraftDomain")
|
||||
if not DraftDomain.string_could_be_domain(self.requested_domain.name):
|
||||
raise ValueError("Requested domain is not a valid domain name.")
|
||||
|
||||
# if the domain has not been submitted before this must be the first time
|
||||
if not self.first_submitted_date:
|
||||
self.first_submitted_date = timezone.now().date()
|
||||
|
|
|
@ -4,6 +4,7 @@ import logging
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from django_fsm import FSMField, transition
|
||||
from registrar.models.domain_invitation import DomainInvitation
|
||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
from .utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices # type: ignore
|
||||
from .utility.time_stamped_model import TimeStampedModel
|
||||
|
@ -38,7 +39,7 @@ class PortfolioInvitation(TimeStampedModel):
|
|||
related_name="portfolios",
|
||||
)
|
||||
|
||||
portfolio_roles = ArrayField(
|
||||
roles = ArrayField(
|
||||
models.CharField(
|
||||
max_length=50,
|
||||
choices=UserPortfolioRoleChoices.choices,
|
||||
|
@ -48,7 +49,7 @@ class PortfolioInvitation(TimeStampedModel):
|
|||
help_text="Select one or more roles.",
|
||||
)
|
||||
|
||||
portfolio_additional_permissions = ArrayField(
|
||||
additional_permissions = ArrayField(
|
||||
models.CharField(
|
||||
max_length=50,
|
||||
choices=UserPortfolioPermissionChoices.choices,
|
||||
|
@ -67,6 +68,31 @@ class PortfolioInvitation(TimeStampedModel):
|
|||
def __str__(self):
|
||||
return f"Invitation for {self.email} on {self.portfolio} is {self.status}"
|
||||
|
||||
def get_managed_domains_count(self):
|
||||
"""Return the count of domain invitations managed by the invited user for this portfolio."""
|
||||
# Filter the UserDomainRole model to get domains where the user has a manager role
|
||||
managed_domains = DomainInvitation.objects.filter(
|
||||
email=self.email, domain__domain_info__portfolio=self.portfolio
|
||||
).count()
|
||||
return managed_domains
|
||||
|
||||
def get_portfolio_permissions(self):
|
||||
"""
|
||||
Retrieve the permissions for the user's portfolio roles from the invite.
|
||||
This is similar logic to _get_portfolio_permissions in user_portfolio_permission
|
||||
"""
|
||||
# Use a set to avoid duplicate permissions
|
||||
portfolio_permissions = set()
|
||||
|
||||
if self.roles:
|
||||
for role in self.roles:
|
||||
portfolio_permissions.update(UserPortfolioPermission.PORTFOLIO_ROLE_PERMISSIONS.get(role, []))
|
||||
|
||||
if self.additional_permissions:
|
||||
portfolio_permissions.update(self.additional_permissions)
|
||||
|
||||
return list(portfolio_permissions)
|
||||
|
||||
@transition(field="status", source=PortfolioInvitationStatus.INVITED, target=PortfolioInvitationStatus.RETRIEVED)
|
||||
def retrieve(self):
|
||||
"""When an invitation is retrieved, create the corresponding permission.
|
||||
|
@ -88,8 +114,8 @@ class PortfolioInvitation(TimeStampedModel):
|
|||
user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
portfolio=self.portfolio, user=user
|
||||
)
|
||||
if self.portfolio_roles and len(self.portfolio_roles) > 0:
|
||||
user_portfolio_permission.roles = self.portfolio_roles
|
||||
if self.portfolio_additional_permissions and len(self.portfolio_additional_permissions) > 0:
|
||||
user_portfolio_permission.additional_permissions = self.portfolio_additional_permissions
|
||||
if self.roles and len(self.roles) > 0:
|
||||
user_portfolio_permission.roles = self.roles
|
||||
if self.additional_permissions and len(self.additional_permissions) > 0:
|
||||
user_portfolio_permission.additional_permissions = self.additional_permissions
|
||||
user_portfolio_permission.save()
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
from django.db import models
|
||||
from django.forms import ValidationError
|
||||
from registrar.models.user_domain_role import UserDomainRole
|
||||
from registrar.utility.waffle import flag_is_active_for_user
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
from .utility.time_stamped_model import TimeStampedModel
|
||||
|
@ -79,6 +80,14 @@ class UserPortfolioPermission(TimeStampedModel):
|
|||
)
|
||||
return readable_roles
|
||||
|
||||
def get_managed_domains_count(self):
|
||||
"""Return the count of domains managed by the user for this portfolio."""
|
||||
# Filter the UserDomainRole model to get domains where the user has a manager role
|
||||
managed_domains = UserDomainRole.objects.filter(
|
||||
user=self.user, role=UserDomainRole.Roles.MANAGER, domain__domain_info__portfolio=self.portfolio
|
||||
).count()
|
||||
return managed_domains
|
||||
|
||||
def _get_portfolio_permissions(self):
|
||||
"""
|
||||
Retrieve the permissions for the user's portfolio roles.
|
||||
|
@ -99,16 +108,6 @@ class UserPortfolioPermission(TimeStampedModel):
|
|||
"""Extends clean method to perform additional validation, which can raise errors in django admin."""
|
||||
super().clean()
|
||||
|
||||
# Check if a user is set without accessing the related object.
|
||||
has_user = bool(self.user_id)
|
||||
if self.pk is None and has_user:
|
||||
existing_permissions = UserPortfolioPermission.objects.filter(user=self.user)
|
||||
if not flag_is_active_for_user(self.user, "multiple_portfolios") and existing_permissions.exists():
|
||||
raise ValidationError(
|
||||
"This user is already assigned to a portfolio. "
|
||||
"Based on current waffle flag settings, users cannot be assigned to multiple portfolios."
|
||||
)
|
||||
|
||||
# Check if portfolio is set without accessing the related object.
|
||||
has_portfolio = bool(self.portfolio_id)
|
||||
if not has_portfolio and self._get_portfolio_permissions():
|
||||
|
@ -116,3 +115,19 @@ class UserPortfolioPermission(TimeStampedModel):
|
|||
|
||||
if has_portfolio and not self._get_portfolio_permissions():
|
||||
raise ValidationError("When portfolio is assigned, portfolio roles or additional permissions are required.")
|
||||
|
||||
# Check if a user is set without accessing the related object.
|
||||
has_user = bool(self.user_id)
|
||||
if has_user:
|
||||
existing_permission_pks = UserPortfolioPermission.objects.filter(user=self.user).values_list(
|
||||
"pk", flat=True
|
||||
)
|
||||
if (
|
||||
not flag_is_active_for_user(self.user, "multiple_portfolios")
|
||||
and existing_permission_pks.exists()
|
||||
and self.pk not in existing_permission_pks
|
||||
):
|
||||
raise ValidationError(
|
||||
"This user is already assigned to a portfolio. "
|
||||
"Based on current waffle flag settings, users cannot be assigned to multiple portfolios."
|
||||
)
|
||||
|
|
|
@ -2,23 +2,21 @@
|
|||
{% load static url_helpers %}
|
||||
|
||||
{% block detail_content %}
|
||||
<table>
|
||||
<table class="usa-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<tr>
|
||||
<th data-sortable scope="col" role="columnheader">Name</th>
|
||||
<th data-sortable scope="col" role="columnheader">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for domain_request in domain_requests %}
|
||||
{% url 'admin:registrar_domainrequest_change' domain_request.pk as url %}
|
||||
<tr>
|
||||
<td><a href={{url}}>{{ domain_request }}</a></td>
|
||||
{% if domain_request.get_status_display %}
|
||||
<td>{{ domain_request.get_status_display }}</td>
|
||||
{% else %}
|
||||
<td>None</td>
|
||||
{% endif %}
|
||||
<td data-sort-value="{{ domain_request }}"> <a href={{url}}>{{ domain_request }}</a></td>
|
||||
<td data-sort-value="{{ domain_request.get_status_display}}"> {{ domain_request.get_status_display|default:"None" }} </td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
|
|
@ -2,11 +2,11 @@
|
|||
{% load static url_helpers %}
|
||||
|
||||
{% block detail_content %}
|
||||
<table>
|
||||
<table class="usa-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>State</th>
|
||||
<th data-sortable scope="col" role="columnheader">Name</th>
|
||||
<th data-sortable scope="col" role="columnheader">State</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
@ -15,11 +15,11 @@
|
|||
{% with domain=domain_info.domain %}
|
||||
{% url 'admin:registrar_domain_change' domain.pk as url %}
|
||||
<tr>
|
||||
<td><a href={{url}}>{{ domain }}</a></td>
|
||||
<td data-sort-value="{{ domain }}"> <a href={{url}}>{{ domain }}</a></td>
|
||||
{% if domain and domain.get_state_display %}
|
||||
<td>{{ domain.get_state_display }}</td>
|
||||
<td data-sort-value="{{ domain.get_state_display }}"> {{ domain.get_state_display }} </td>
|
||||
{% else %}
|
||||
<td>None</td>
|
||||
<td data-sort-value="None"> None</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endwith %}
|
||||
|
|
|
@ -4,11 +4,31 @@
|
|||
{% block title %}Add a domain manager | {% endblock %}
|
||||
|
||||
{% block domain_content %}
|
||||
{% block breadcrumb %}
|
||||
{% url 'domain-users' pk=domain.id as url %}
|
||||
<nav class="usa-breadcrumb padding-top-0" aria-label="Domain manager breadcrumb">
|
||||
<ol class="usa-breadcrumb__list">
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{{ url }}" class="usa-breadcrumb__link"><span>Domain managers</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
|
||||
<span>Add a domain manager</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
{% endblock breadcrumb %}
|
||||
<h1>Add a domain manager</h1>
|
||||
|
||||
<p>You can add another user to help manage your domain. They will need to sign
|
||||
in to the .gov registrar with their Login.gov account.
|
||||
{% if has_organization_feature_flag %}
|
||||
<p>
|
||||
You can add another user to help manage your domain. Users can only be a member of one .gov organization,
|
||||
and they'll need to sign in with their Login.gov account.
|
||||
</p>
|
||||
{% else %}
|
||||
<p>
|
||||
You can add another user to help manage your domain. They will need to sign in to the .gov registrar with
|
||||
their Login.gov account.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<form class="usa-form usa-form--large" method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
|
|
|
@ -45,7 +45,7 @@
|
|||
<div class="usa-alert usa-alert--info usa-alert--slim">
|
||||
<div class="usa-alert__body">
|
||||
<p class="usa-alert__text ">
|
||||
To manage information for this domain, you must add yourself as a domain manager.
|
||||
You don't have access to manage {{domain.name}}. If you need to make updates, contact one of the listed domain managers.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -42,7 +42,7 @@
|
|||
|
||||
{% input_with_errors form.state_territory %}
|
||||
|
||||
{% with add_class="usa-input--small" sublabel_text="Enter a zip code in the required format, like 12345 or 12345-6789." %}
|
||||
{% with add_class="usa-input--small" sublabel_text="Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789." %}
|
||||
{% input_with_errors form.zipcode %}
|
||||
{% endwith %}
|
||||
|
||||
|
|
|
@ -33,7 +33,7 @@
|
|||
|
||||
{% input_with_errors forms.0.state_territory %}
|
||||
|
||||
{% with add_class="usa-input--small" sublabel_text="Enter a zip code in the required format, like 12345 or 12345-6789." %}
|
||||
{% with add_class="usa-input--small" sublabel_text="Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789." %}
|
||||
{% input_with_errors forms.0.zipcode %}
|
||||
{% endwith %}
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
{% load field_helpers %}
|
||||
|
||||
{% block form_instructions %}
|
||||
<h2 class="margin-bottom-05">
|
||||
<h2 id="id_domain_request_federal_org_header" class="margin-bottom-05">
|
||||
Which federal branch is your organization in?
|
||||
</h2>
|
||||
{% endblock %}
|
||||
|
|
|
@ -11,8 +11,13 @@
|
|||
|
||||
<p>
|
||||
The name of your suborganization will be publicly listed as the domain registrant.
|
||||
This list of suborganizations has been populated the .gov program.
|
||||
If you believe there is an error please contact <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
|
||||
</p>
|
||||
<p>
|
||||
When this field is blank, the domain registrant will be listed as the overarching organization: {{ portfolio }}.
|
||||
</p>
|
||||
<p>
|
||||
If you don’t see your suborganization in the menu or need to edit one of the options,
|
||||
please contact <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
|
||||
</p>
|
||||
|
||||
{% if has_any_domains_portfolio_permission and has_edit_suborganization_portfolio_permission %}
|
||||
|
|
|
@ -8,8 +8,7 @@
|
|||
|
||||
<p>
|
||||
Domain managers can update all information related to a domain within the
|
||||
.gov registrar, including contact details, senior official, security
|
||||
email, and DNS name servers.
|
||||
.gov registrar, including security email and DNS name servers.
|
||||
</p>
|
||||
|
||||
<ul class="usa-list">
|
||||
|
@ -17,7 +16,8 @@
|
|||
<li>After adding a domain manager, an email invitation will be sent to that user with
|
||||
instructions on how to set up an account.</li>
|
||||
<li>All domain managers must keep their contact information updated and be responsive if contacted by the .gov team.</li>
|
||||
<li>Domains must have at least one domain manager. You can’t remove yourself as a domain manager if you’re the only one assigned to this domain. Add another domain manager before you remove yourself from this domain.</li>
|
||||
<li>All domain managers will be notified when updates are made to this domain.</li>
|
||||
<li>Domains must have at least one domain manager. You can’t remove yourself as a domain manager if you’re the only one assigned to this domain.</li>
|
||||
</ul>
|
||||
|
||||
{% if domain.permissions %}
|
||||
|
|
31
src/registrar/templates/emails/update_to_approved_domain.txt
Normal file
31
src/registrar/templates/emails/update_to_approved_domain.txt
Normal file
|
@ -0,0 +1,31 @@
|
|||
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
|
||||
|
||||
Hi,
|
||||
An update was made to a domain you manage.
|
||||
|
||||
DOMAIN: {{domain}}
|
||||
UPDATED BY: {{user}}
|
||||
UPDATED ON: {{date}}
|
||||
INFORMATION UPDATED: {{changes}}
|
||||
|
||||
You can view this update in the .gov registrar <https://manage.get.gov/>.
|
||||
|
||||
Get help with managing your .gov domain <https://get.gov/help/domain-management/>.
|
||||
|
||||
----------------------------------------------------------------
|
||||
|
||||
WHY DID YOU RECEIVE THIS EMAIL?
|
||||
You’re listed as a domain manager for {{domain}}, so you’ll receive a notification whenever changes are made to that domain.
|
||||
If you have questions or concerns, reach out to the person who made the change or reply to this email.
|
||||
|
||||
THANK YOU
|
||||
.Gov helps the public identify official, trusted information. Thank you for using a .gov domain.
|
||||
|
||||
----------------------------------------------------------------
|
||||
|
||||
The .gov team
|
||||
Contact us <https://get.gov/contact/>
|
||||
Learn about .gov <https://get.gov>
|
||||
|
||||
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <https://cisa.gov/>
|
||||
{% endautoescape %}
|
|
@ -0,0 +1 @@
|
|||
An update was made to {{domain}}
|
|
@ -93,12 +93,12 @@
|
|||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if has_organization_members_flag and has_view_members_portfolio_permission %}
|
||||
<li class="usa-nav__primary-item">
|
||||
<a href="/members/" class="usa-nav-link {% if path|is_members_subpage %} usa-current{% endif %}">
|
||||
Members
|
||||
</a>
|
||||
</li>
|
||||
{% if has_organization_members_flag %}
|
||||
<li class="usa-nav__primary-item">
|
||||
<a href="/members/" class="usa-nav-link {% if path|is_members_subpage %} usa-current{% endif %}">
|
||||
Members
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<li class="usa-nav__primary-item">
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
<h4 class="margin-bottom-0 text-primary">Assigned domains</h4>
|
||||
{% if domain_count > 0 %}
|
||||
<p class="margin-top-0">{{domain_count}}</p>
|
||||
{% else %}
|
||||
<p class="margin-top-0">This member does not manage any domains.{% if manage_button %} To assign this member a domain, click "Manage".{% endif %}</p>
|
||||
{% endif %}
|
26
src/registrar/templates/includes/member_permissions.html
Normal file
26
src/registrar/templates/includes/member_permissions.html
Normal file
|
@ -0,0 +1,26 @@
|
|||
<h4 class="margin-bottom-0 text-primary">Member access</h4>
|
||||
{% if permissions.roles and 'organization_admin' in permissions.roles %}
|
||||
<p class="margin-top-0">Admin access</p>
|
||||
{% elif permissions.roles and 'organization_member' in permissions.roles %}
|
||||
<p class="margin-top-0">Basic access</p>
|
||||
{% else %}
|
||||
<p class="margin-top-0">⎯</p>
|
||||
{% endif %}
|
||||
|
||||
<h4 class="margin-bottom-0 text-primary">Organization domain requests</h4>
|
||||
{% if member_has_edit_request_portfolio_permission %}
|
||||
<p class="margin-top-0">View all requests plus create requests</p>
|
||||
{% elif member_has_view_all_requests_portfolio_permission %}
|
||||
<p class="margin-top-0">View all requests</p>
|
||||
{% else %}
|
||||
<p class="margin-top-0">No access</p>
|
||||
{% endif %}
|
||||
|
||||
<h4 class="margin-bottom-0 text-primary">Organization members</h4>
|
||||
{% if member_has_edit_members_portfolio_permission %}
|
||||
<p class="margin-top-0">View all members plus manage members</p>
|
||||
{% elif member_has_view_members_portfolio_permission %}
|
||||
<p class="margin-top-0">View all members</p>
|
||||
{% else %}
|
||||
<p class="margin-top-0">No access</p>
|
||||
{% endif %}
|
|
@ -4,6 +4,11 @@
|
|||
<div class="usa-modal__main">
|
||||
<h2 class="usa-modal__heading" id="modal-1-heading">
|
||||
{{ modal_heading }}
|
||||
{%if domain_name_modal is not None %}
|
||||
<span class="domain-name-wrap">
|
||||
{{ domain_name_modal }}
|
||||
</span>
|
||||
{%endif%}
|
||||
{% if heading_value is not None %}
|
||||
{# Add a breakpoint #}
|
||||
<div aria-hidden="true"></div>
|
||||
|
|
|
@ -24,7 +24,11 @@
|
|||
{% if sub_header_text %}
|
||||
<h4 class="register-form-review-header">{{ sub_header_text }}</h4>
|
||||
{% endif %}
|
||||
{% if address %}
|
||||
{% if permissions %}
|
||||
{% include "includes/member_permissions.html" with permissions=value %}
|
||||
{% elif domain_mgmt %}
|
||||
{% include "includes/member_domain_management.html" with domain_count=value %}
|
||||
{% elif address %}
|
||||
{% include "includes/organization_address.html" with organization=value %}
|
||||
{% elif contact %}
|
||||
{% if list %}
|
||||
|
@ -122,9 +126,9 @@
|
|||
class="usa-link usa-link--icon font-sans-sm line-height-sans-5"
|
||||
>
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="{% static 'img/sprite.svg' %}#edit"></use>
|
||||
<use xlink:href="{% static 'img/sprite.svg' %}#{% if manage_button %}settings{% elif view_button %}visibility{% else %}edit{% endif %}"></use>
|
||||
</svg>
|
||||
Edit<span class="sr-only"> {{ title }}</span>
|
||||
{% if manage_button %}Manage{% elif view_button %}View{% else %}Edit{% endif %}<span class="sr-only"> {{ title }}</span>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
137
src/registrar/templates/portfolio_member.html
Normal file
137
src/registrar/templates/portfolio_member.html
Normal file
|
@ -0,0 +1,137 @@
|
|||
{% extends 'portfolio_base.html' %}
|
||||
{% load static field_helpers%}
|
||||
|
||||
{% block title %}Organization member {% endblock %}
|
||||
|
||||
{% load static %}
|
||||
|
||||
{% block portfolio_content %}
|
||||
<div id="main-content">
|
||||
|
||||
{% url 'members' as url %}
|
||||
<nav class="usa-breadcrumb padding-top-0 margin-bottom-3" aria-label="Portfolio member breadcrumb">
|
||||
<ol class="usa-breadcrumb__list">
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{{ url }}" class="usa-breadcrumb__link"><span>Members</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
|
||||
<span>Manage member</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
|
||||
<h1 class="margin-bottom-3">Manage member</h1>
|
||||
|
||||
<div class="tablet:display-flex tablet:flex-justify">
|
||||
<h2 class="margin-top-0 margin-bottom-3 break-word">
|
||||
{% if member %}
|
||||
{{ member.email }}
|
||||
{% elif portfolio_invitation %}
|
||||
{{ portfolio_invitation.email }}
|
||||
{% endif %}
|
||||
</h2>
|
||||
{% if has_edit_members_portfolio_permission %}
|
||||
{% if member %}
|
||||
<a
|
||||
role="button"
|
||||
href="#"
|
||||
class="display-block usa-button text-secondary usa-button--unstyled text-no-underline margin-bottom-3 line-height-sans-5 visible-mobile-flex"
|
||||
>
|
||||
Remove member
|
||||
</a>
|
||||
{% else %}
|
||||
<a
|
||||
role="button"
|
||||
href="#"
|
||||
class="display-block usa-button text-secondary usa-button--unstyled text-no-underline margin-bottom-3 line-height-sans-5 visible-mobile-flex"
|
||||
>
|
||||
Cancel invitation
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<div class="usa-accordion usa-accordion--more-actions hidden-mobile-flex">
|
||||
<div class="usa-accordion__heading">
|
||||
<button
|
||||
type="button"
|
||||
class="usa-button usa-button--unstyled usa-button--with-icon usa-accordion__button usa-button--more-actions"
|
||||
aria-expanded="false"
|
||||
aria-controls="more-actions"
|
||||
>
|
||||
<svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="/public/img/sprite.svg#more_vert"></use>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="more-actions" class="usa-accordion__content usa-prose shadow-1 left-auto right-0" hidden>
|
||||
<h2>More options</h2>
|
||||
{% if member %}
|
||||
<a
|
||||
role="button"
|
||||
href="#"
|
||||
class="usa-button text-secondary usa-button--unstyled text-no-underline margin-top-2 line-height-sans-5"
|
||||
>
|
||||
Remove member
|
||||
</a>
|
||||
{% else %}
|
||||
<a
|
||||
role="button"
|
||||
href="#"
|
||||
class="usa-button text-secondary usa-button--unstyled text-no-underline margin-top-2 line-height-sans-5"
|
||||
>
|
||||
Cancel invitation
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<address>
|
||||
<strong class="text-primary-dark">Last active:</strong>
|
||||
{% if member and member.last_login %}
|
||||
{{ member.last_login }}
|
||||
{% elif portfolio_invitation %}
|
||||
Invited
|
||||
{% else %}
|
||||
⎯
|
||||
{% endif %}
|
||||
<br />
|
||||
|
||||
<strong class="text-primary-dark">Full name:</strong>
|
||||
{% if member %}
|
||||
{% if member.first_name or member.last_name %}
|
||||
{{ member.get_formatted_name }}
|
||||
{% else %}
|
||||
⎯
|
||||
{% endif %}
|
||||
{% else %}
|
||||
⎯
|
||||
{% endif %}
|
||||
<br />
|
||||
|
||||
<strong class="text-primary-dark">Title or organization role:</strong>
|
||||
{% if member and member.title %}
|
||||
{{ member.title }}
|
||||
{% else %}
|
||||
⎯
|
||||
{% endif %}
|
||||
</address>
|
||||
|
||||
{% if portfolio_permission %}
|
||||
{% include "includes/summary_item.html" with title='Member access and permissions' permissions=True value=portfolio_permission edit_link=edit_url editable=has_edit_members_portfolio_permission %}
|
||||
{% elif portfolio_invitation %}
|
||||
{% include "includes/summary_item.html" with title='Member access and permissions' permissions=True value=portfolio_invitation edit_link=edit_url editable=has_edit_members_portfolio_permission %}
|
||||
{% endif %}
|
||||
|
||||
{% comment %}view_button is passed below as true in all cases. This is because manage_button logic will trump view_button logic; ie. if manage_button is true, view_button will never be looked at{% endcomment %}
|
||||
{% if portfolio_permission %}
|
||||
{% include "includes/summary_item.html" with title='Domain management' domain_mgmt=True value=portfolio_permission.get_managed_domains_count edit_link='#' editable=True manage_button=has_edit_members_portfolio_permission view_button=True %}
|
||||
{% elif portfolio_invitation %}
|
||||
{% include "includes/summary_item.html" with title='Domain management' domain_mgmt=True value=portfolio_invitation.get_managed_domains_count edit_link='#' editable=True manage_button=has_edit_members_portfolio_permission view_button=True %}
|
||||
{% else %}
|
||||
{% include "includes/summary_item.html" with title='Domain management' domain_mgmt=True value=0 edit_link='#' editable=True manage_button=has_edit_members_portfolio_permission view_button=True %}
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
42
src/registrar/templates/portfolio_member_permissions.html
Normal file
42
src/registrar/templates/portfolio_member_permissions.html
Normal file
|
@ -0,0 +1,42 @@
|
|||
{% extends 'portfolio_base.html' %}
|
||||
{% load static field_helpers%}
|
||||
|
||||
{% block title %}Organization member {% endblock %}
|
||||
|
||||
{% load static %}
|
||||
|
||||
{% block portfolio_content %}
|
||||
<div class="grid-row grid-gap">
|
||||
<div class="tablet:grid-col-9" id="main-content">
|
||||
|
||||
{% block messages %}
|
||||
{% include "includes/form_messages.html" %}
|
||||
{% endblock %}
|
||||
|
||||
<h1>Manage member</h1>
|
||||
|
||||
<p>
|
||||
{% if member %}
|
||||
{{ member.email }}
|
||||
{% elif invitation %}
|
||||
{{ invitation.email }}
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<form class="usa-form usa-form--large" method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
{% input_with_errors form.roles %}
|
||||
{% input_with_errors form.additional_permissions %}
|
||||
<button
|
||||
type="submit"
|
||||
class="usa-button"
|
||||
>Submit</button>
|
||||
</form>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -18,6 +18,7 @@
|
|||
<div class="mobile:grid-col-12 tablet:grid-col-6">
|
||||
<h1 id="members-header">Members</h1>
|
||||
</div>
|
||||
{% if has_edit_members_portfolio_permission %}
|
||||
<div class="mobile:grid-col-12 tablet:grid-col-6">
|
||||
<p class="float-right-tablet tablet:margin-y-0">
|
||||
<a href="#" class="usa-button"
|
||||
|
@ -26,6 +27,7 @@
|
|||
</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% include "includes/members_table.html" with portfolio=portfolio %}
|
||||
|
|
|
@ -45,7 +45,7 @@
|
|||
{% input_with_errors form.address_line2 %}
|
||||
{% input_with_errors form.city %}
|
||||
{% input_with_errors form.state_territory %}
|
||||
{% with add_class="usa-input--small" sublabel_text="Enter a zip code in the required format, like 12345 or 12345-6789." %}
|
||||
{% with add_class="usa-input--small" sublabel_text="Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789." %}
|
||||
{% input_with_errors form.zipcode %}
|
||||
{% endwith %}
|
||||
<button type="submit" class="usa-button">
|
||||
|
|
|
@ -246,9 +246,7 @@ def is_members_subpage(path):
|
|||
"""Checks if the given page is a subpage of members.
|
||||
Takes a path name, like '/organization/'."""
|
||||
# Since our pages aren't unified under a common path, we need this approach for now.
|
||||
url_names = [
|
||||
"members",
|
||||
]
|
||||
url_names = ["members", "member", "member-permissions", "invitedmember", "invitedmember-permissions"]
|
||||
return get_url_name(path) in url_names
|
||||
|
||||
|
||||
|
|
|
@ -61,11 +61,58 @@ class TestEmails(TestCase):
|
|||
# Assert that an email wasn't sent
|
||||
self.assertFalse(self.mock_client.send_email.called)
|
||||
|
||||
@boto3_mocking.patching
|
||||
def test_email_with_cc(self):
|
||||
"""Test sending email with cc works"""
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
send_templated_email(
|
||||
"emails/update_to_approved_domain.txt",
|
||||
"emails/update_to_approved_domain_subject.txt",
|
||||
"doesnotexist@igorville.com",
|
||||
context={"domain": "test", "user": "test", "date": 1, "changes": "test"},
|
||||
bcc_address=None,
|
||||
cc_addresses=["testy2@town.com", "mayor@igorville.gov"],
|
||||
)
|
||||
|
||||
# check that an email was sent
|
||||
self.assertTrue(self.mock_client.send_email.called)
|
||||
|
||||
# check the call sequence for the email
|
||||
args, kwargs = self.mock_client.send_email.call_args
|
||||
self.assertIn("Destination", kwargs)
|
||||
self.assertIn("CcAddresses", kwargs["Destination"])
|
||||
|
||||
self.assertEqual(["testy2@town.com", "mayor@igorville.gov"], kwargs["Destination"]["CcAddresses"])
|
||||
|
||||
@boto3_mocking.patching
|
||||
@override_settings(IS_PRODUCTION=True)
|
||||
def test_email_with_cc_in_prod(self):
|
||||
"""Test sending email with cc works in prod"""
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
send_templated_email(
|
||||
"emails/update_to_approved_domain.txt",
|
||||
"emails/update_to_approved_domain_subject.txt",
|
||||
"doesnotexist@igorville.com",
|
||||
context={"domain": "test", "user": "test", "date": 1, "changes": "test"},
|
||||
bcc_address=None,
|
||||
cc_addresses=["testy2@town.com", "mayor@igorville.gov"],
|
||||
)
|
||||
|
||||
# check that an email was sent
|
||||
self.assertTrue(self.mock_client.send_email.called)
|
||||
|
||||
# check the call sequence for the email
|
||||
args, kwargs = self.mock_client.send_email.call_args
|
||||
self.assertIn("Destination", kwargs)
|
||||
self.assertIn("CcAddresses", kwargs["Destination"])
|
||||
|
||||
self.assertEqual(["testy2@town.com", "mayor@igorville.gov"], kwargs["Destination"]["CcAddresses"])
|
||||
|
||||
@boto3_mocking.patching
|
||||
@less_console_noise_decorator
|
||||
def test_submission_confirmation(self):
|
||||
"""Submission confirmation email works."""
|
||||
domain_request = completed_domain_request()
|
||||
domain_request = completed_domain_request(user=User.objects.create(username="test", email="testy@town.com"))
|
||||
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
domain_request.submit()
|
||||
|
@ -102,7 +149,9 @@ class TestEmails(TestCase):
|
|||
@less_console_noise_decorator
|
||||
def test_submission_confirmation_no_current_website_spacing(self):
|
||||
"""Test line spacing without current_website."""
|
||||
domain_request = completed_domain_request(has_current_website=False)
|
||||
domain_request = completed_domain_request(
|
||||
has_current_website=False, user=User.objects.create(username="test", email="testy@town.com")
|
||||
)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
domain_request.submit()
|
||||
_, kwargs = self.mock_client.send_email.call_args
|
||||
|
@ -115,7 +164,9 @@ class TestEmails(TestCase):
|
|||
@less_console_noise_decorator
|
||||
def test_submission_confirmation_current_website_spacing(self):
|
||||
"""Test line spacing with current_website."""
|
||||
domain_request = completed_domain_request(has_current_website=True)
|
||||
domain_request = completed_domain_request(
|
||||
has_current_website=True, user=User.objects.create(username="test", email="testy@town.com")
|
||||
)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
domain_request.submit()
|
||||
_, kwargs = self.mock_client.send_email.call_args
|
||||
|
@ -132,7 +183,11 @@ class TestEmails(TestCase):
|
|||
|
||||
# Create fake creator
|
||||
_creator = User.objects.create(
|
||||
username="MrMeoward", first_name="Meoward", last_name="Jones", phone="(888) 888 8888"
|
||||
username="MrMeoward",
|
||||
first_name="Meoward",
|
||||
last_name="Jones",
|
||||
phone="(888) 888 8888",
|
||||
email="testy@town.com",
|
||||
)
|
||||
|
||||
# Create a fake domain request
|
||||
|
@ -149,7 +204,9 @@ class TestEmails(TestCase):
|
|||
@less_console_noise_decorator
|
||||
def test_submission_confirmation_no_other_contacts_spacing(self):
|
||||
"""Test line spacing without other contacts."""
|
||||
domain_request = completed_domain_request(has_other_contacts=False)
|
||||
domain_request = completed_domain_request(
|
||||
has_other_contacts=False, user=User.objects.create(username="test", email="testy@town.com")
|
||||
)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
domain_request.submit()
|
||||
_, kwargs = self.mock_client.send_email.call_args
|
||||
|
@ -161,7 +218,9 @@ class TestEmails(TestCase):
|
|||
@less_console_noise_decorator
|
||||
def test_submission_confirmation_alternative_govdomain_spacing(self):
|
||||
"""Test line spacing with alternative .gov domain."""
|
||||
domain_request = completed_domain_request(has_alternative_gov_domain=True)
|
||||
domain_request = completed_domain_request(
|
||||
has_alternative_gov_domain=True, user=User.objects.create(username="test", email="testy@town.com")
|
||||
)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
domain_request.submit()
|
||||
_, kwargs = self.mock_client.send_email.call_args
|
||||
|
@ -174,7 +233,9 @@ class TestEmails(TestCase):
|
|||
@less_console_noise_decorator
|
||||
def test_submission_confirmation_no_alternative_govdomain_spacing(self):
|
||||
"""Test line spacing without alternative .gov domain."""
|
||||
domain_request = completed_domain_request(has_alternative_gov_domain=False)
|
||||
domain_request = completed_domain_request(
|
||||
has_alternative_gov_domain=False, user=User.objects.create(username="test", email="testy@town.com")
|
||||
)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
domain_request.submit()
|
||||
_, kwargs = self.mock_client.send_email.call_args
|
||||
|
@ -187,7 +248,9 @@ class TestEmails(TestCase):
|
|||
@less_console_noise_decorator
|
||||
def test_submission_confirmation_about_your_organization_spacing(self):
|
||||
"""Test line spacing with about your organization."""
|
||||
domain_request = completed_domain_request(has_about_your_organization=True)
|
||||
domain_request = completed_domain_request(
|
||||
has_about_your_organization=True, user=User.objects.create(username="test", email="testy@town.com")
|
||||
)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
domain_request.submit()
|
||||
_, kwargs = self.mock_client.send_email.call_args
|
||||
|
@ -200,7 +263,9 @@ class TestEmails(TestCase):
|
|||
@less_console_noise_decorator
|
||||
def test_submission_confirmation_no_about_your_organization_spacing(self):
|
||||
"""Test line spacing without about your organization."""
|
||||
domain_request = completed_domain_request(has_about_your_organization=False)
|
||||
domain_request = completed_domain_request(
|
||||
has_about_your_organization=False, user=User.objects.create(username="test", email="testy@town.com")
|
||||
)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
domain_request.submit()
|
||||
_, kwargs = self.mock_client.send_email.call_args
|
||||
|
@ -213,7 +278,9 @@ class TestEmails(TestCase):
|
|||
@less_console_noise_decorator
|
||||
def test_submission_confirmation_anything_else_spacing(self):
|
||||
"""Test line spacing with anything else."""
|
||||
domain_request = completed_domain_request(has_anything_else=True)
|
||||
domain_request = completed_domain_request(
|
||||
has_anything_else=True, user=User.objects.create(username="test", email="testy@town.com")
|
||||
)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
domain_request.submit()
|
||||
_, kwargs = self.mock_client.send_email.call_args
|
||||
|
@ -225,7 +292,9 @@ class TestEmails(TestCase):
|
|||
@less_console_noise_decorator
|
||||
def test_submission_confirmation_no_anything_else_spacing(self):
|
||||
"""Test line spacing without anything else."""
|
||||
domain_request = completed_domain_request(has_anything_else=False)
|
||||
domain_request = completed_domain_request(
|
||||
has_anything_else=False, user=User.objects.create(username="test", email="testy@town.com")
|
||||
)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
domain_request.submit()
|
||||
_, kwargs = self.mock_client.send_email.call_args
|
||||
|
|
|
@ -33,7 +33,7 @@ class TestFormValidation(MockEppLib):
|
|||
form = OrganizationContactForm(data={"zipcode": "nah"})
|
||||
self.assertEqual(
|
||||
form.errors["zipcode"],
|
||||
["Enter a zip code in the form of 12345 or 12345-6789."],
|
||||
["Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789."],
|
||||
)
|
||||
|
||||
def test_org_contact_zip_valid(self):
|
||||
|
|
|
@ -152,12 +152,15 @@ class TestPortfolioInvitations(TestCase):
|
|||
self.invitation, _ = PortfolioInvitation.objects.get_or_create(
|
||||
email=self.email,
|
||||
portfolio=self.portfolio,
|
||||
portfolio_roles=[self.portfolio_role_base, self.portfolio_role_admin],
|
||||
portfolio_additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
|
||||
roles=[self.portfolio_role_base, self.portfolio_role_admin],
|
||||
additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
super().tearDown()
|
||||
DomainInvitation.objects.all().delete()
|
||||
DomainInformation.objects.all().delete()
|
||||
Domain.objects.all().delete()
|
||||
UserPortfolioPermission.objects.all().delete()
|
||||
Portfolio.objects.all().delete()
|
||||
PortfolioInvitation.objects.all().delete()
|
||||
|
@ -209,8 +212,8 @@ class TestPortfolioInvitations(TestCase):
|
|||
PortfolioInvitation.objects.get_or_create(
|
||||
email=self.email,
|
||||
portfolio=portfolio2,
|
||||
portfolio_roles=[self.portfolio_role_base, self.portfolio_role_admin],
|
||||
portfolio_additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
|
||||
roles=[self.portfolio_role_base, self.portfolio_role_admin],
|
||||
additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
|
||||
)
|
||||
with override_flag("multiple_portfolios", active=True):
|
||||
self.user.check_portfolio_invitations_on_login()
|
||||
|
@ -233,8 +236,8 @@ class TestPortfolioInvitations(TestCase):
|
|||
PortfolioInvitation.objects.get_or_create(
|
||||
email=self.email,
|
||||
portfolio=portfolio2,
|
||||
portfolio_roles=[self.portfolio_role_base, self.portfolio_role_admin],
|
||||
portfolio_additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
|
||||
roles=[self.portfolio_role_base, self.portfolio_role_admin],
|
||||
additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
|
||||
)
|
||||
self.user.check_portfolio_invitations_on_login()
|
||||
self.user.refresh_from_db()
|
||||
|
@ -245,11 +248,58 @@ class TestPortfolioInvitations(TestCase):
|
|||
updated_invitation2, _ = PortfolioInvitation.objects.get_or_create(email=self.email, portfolio=portfolio2)
|
||||
self.assertEqual(updated_invitation2.status, PortfolioInvitation.PortfolioInvitationStatus.INVITED)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_get_managed_domains_count(self):
|
||||
"""Test that the correct number of domains, which are associated with the portfolio and
|
||||
have invited the email of the portfolio invitation, are returned."""
|
||||
# Add three domains, one which is in the portfolio and email is invited to,
|
||||
# one which is in the portfolio and email is not invited to,
|
||||
# and one which is email is invited to and not in the portfolio.
|
||||
# Arrange
|
||||
# domain_in_portfolio should not be included in the count
|
||||
domain_in_portfolio, _ = Domain.objects.get_or_create(name="domain_in_portfolio.gov", state=Domain.State.READY)
|
||||
DomainInformation.objects.get_or_create(creator=self.user, domain=domain_in_portfolio, portfolio=self.portfolio)
|
||||
# domain_in_portfolio_and_invited should be included in the count
|
||||
domain_in_portfolio_and_invited, _ = Domain.objects.get_or_create(
|
||||
name="domain_in_portfolio_and_invited.gov", state=Domain.State.READY
|
||||
)
|
||||
DomainInformation.objects.get_or_create(
|
||||
creator=self.user, domain=domain_in_portfolio_and_invited, portfolio=self.portfolio
|
||||
)
|
||||
DomainInvitation.objects.get_or_create(email=self.email, domain=domain_in_portfolio_and_invited)
|
||||
# domain_invited should not be included in the count
|
||||
domain_invited, _ = Domain.objects.get_or_create(name="domain_invited.gov", state=Domain.State.READY)
|
||||
DomainInformation.objects.get_or_create(creator=self.user, domain=domain_invited)
|
||||
DomainInvitation.objects.get_or_create(email=self.email, domain=domain_invited)
|
||||
|
||||
# Assert
|
||||
self.assertEqual(self.invitation.get_managed_domains_count(), 1)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_get_portfolio_permissions(self):
|
||||
"""Test that get_portfolio_permissions returns the expected list of permissions,
|
||||
based on the roles and permissions assigned to the invitation."""
|
||||
# Arrange
|
||||
test_permission_list = set()
|
||||
# add the arrays that are defined in UserPortfolioPermission for member and admin
|
||||
test_permission_list.update(
|
||||
UserPortfolioPermission.PORTFOLIO_ROLE_PERMISSIONS.get(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [])
|
||||
)
|
||||
test_permission_list.update(
|
||||
UserPortfolioPermission.PORTFOLIO_ROLE_PERMISSIONS.get(UserPortfolioRoleChoices.ORGANIZATION_ADMIN, [])
|
||||
)
|
||||
# add the permissions that are added to the invitation as additional_permissions
|
||||
test_permission_list.update([self.portfolio_permission_1, self.portfolio_permission_2])
|
||||
perm_list = list(test_permission_list)
|
||||
# Verify
|
||||
self.assertEquals(self.invitation.get_portfolio_permissions(), perm_list)
|
||||
|
||||
|
||||
class TestUserPortfolioPermission(TestCase):
|
||||
@less_console_noise_decorator
|
||||
def setUp(self):
|
||||
self.user, _ = User.objects.get_or_create(email="mayor@igorville.gov")
|
||||
self.user2, _ = User.objects.get_or_create(email="user2@igorville.gov", username="user2")
|
||||
super().setUp()
|
||||
|
||||
def tearDown(self):
|
||||
|
@ -287,16 +337,15 @@ class TestUserPortfolioPermission(TestCase):
|
|||
@override_flag("multiple_portfolios", active=False)
|
||||
def test_clean_on_creates_multiple_portfolios(self):
|
||||
"""Ensures that a user cannot create multiple portfolio permission objects when the flag is disabled"""
|
||||
# Create an instance of User with a portfolio but no roles or additional permissions
|
||||
# Create an instance of User with a single portfolio
|
||||
portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California")
|
||||
portfolio_2, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Motel California")
|
||||
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
portfolio=portfolio, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
portfolio_2, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Motel California")
|
||||
portfolio_permission_2 = UserPortfolioPermission(
|
||||
portfolio=portfolio_2, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
|
||||
# This should work as intended
|
||||
portfolio_permission.clean()
|
||||
|
||||
|
@ -304,8 +353,6 @@ class TestUserPortfolioPermission(TestCase):
|
|||
with self.assertRaises(ValidationError) as cm:
|
||||
portfolio_permission_2.clean()
|
||||
|
||||
portfolio_permission_2, _ = UserPortfolioPermission.objects.get_or_create(portfolio=portfolio, user=self.user)
|
||||
|
||||
self.assertEqual(
|
||||
cm.exception.message,
|
||||
(
|
||||
|
@ -314,6 +361,72 @@ class TestUserPortfolioPermission(TestCase):
|
|||
),
|
||||
)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("multiple_portfolios", active=False)
|
||||
def test_multiple_portfolio_reassignment(self):
|
||||
"""Ensures that a user cannot be assigned to multiple portfolios based on reassignment"""
|
||||
# Create an instance of two users with separate portfolios
|
||||
portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California")
|
||||
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
portfolio=portfolio, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
portfolio_2, _ = Portfolio.objects.get_or_create(creator=self.user2, organization_name="Motel California")
|
||||
portfolio_permission_2 = UserPortfolioPermission(
|
||||
portfolio=portfolio_2, user=self.user2, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
|
||||
# This should work as intended
|
||||
portfolio_permission.clean()
|
||||
portfolio_permission_2.clean()
|
||||
|
||||
# Reassign the portfolio of "user2" to "user" (this should throw an error
|
||||
# preventing "user" from having multiple portfolios)
|
||||
with self.assertRaises(ValidationError) as cm:
|
||||
portfolio_permission_2.user = self.user
|
||||
portfolio_permission_2.clean()
|
||||
|
||||
self.assertEqual(
|
||||
cm.exception.message,
|
||||
(
|
||||
"This user is already assigned to a portfolio. "
|
||||
"Based on current waffle flag settings, users cannot be assigned to multiple portfolios."
|
||||
),
|
||||
)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_get_managed_domains_count(self):
|
||||
"""Test that the correct number of managed domains associated with the portfolio
|
||||
are returned."""
|
||||
# Add three domains, one which is in the portfolio and managed by the user,
|
||||
# one which is in the portfolio and not managed by the user,
|
||||
# and one which is managed by the user and not in the portfolio.
|
||||
# Arrange
|
||||
portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California")
|
||||
test_user = create_test_user()
|
||||
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
portfolio=portfolio, user=test_user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
# domain_in_portfolio should not be included in the count
|
||||
domain_in_portfolio, _ = Domain.objects.get_or_create(name="domain_in_portfolio.gov", state=Domain.State.READY)
|
||||
DomainInformation.objects.get_or_create(creator=self.user, domain=domain_in_portfolio, portfolio=portfolio)
|
||||
# domain_in_portfolio_and_managed should be included in the count
|
||||
domain_in_portfolio_and_managed, _ = Domain.objects.get_or_create(
|
||||
name="domain_in_portfolio_and_managed.gov", state=Domain.State.READY
|
||||
)
|
||||
DomainInformation.objects.get_or_create(
|
||||
creator=self.user, domain=domain_in_portfolio_and_managed, portfolio=portfolio
|
||||
)
|
||||
UserDomainRole.objects.get_or_create(
|
||||
user=test_user, domain=domain_in_portfolio_and_managed, role=UserDomainRole.Roles.MANAGER
|
||||
)
|
||||
# domain_managed should not be included in the count
|
||||
domain_managed, _ = Domain.objects.get_or_create(name="domain_managed.gov", state=Domain.State.READY)
|
||||
DomainInformation.objects.get_or_create(creator=self.user, domain=domain_managed)
|
||||
UserDomainRole.objects.get_or_create(user=test_user, domain=domain_managed, role=UserDomainRole.Roles.MANAGER)
|
||||
|
||||
# Assert
|
||||
self.assertEqual(portfolio_permission.get_managed_domains_count(), 1)
|
||||
|
||||
|
||||
class TestUser(TestCase):
|
||||
"""Test actions that occur on user login,
|
||||
|
|
|
@ -305,7 +305,7 @@ class TestDomainRequest(TestCase):
|
|||
@less_console_noise_decorator
|
||||
def test_submit_from_withdrawn_sends_email(self):
|
||||
msg = "Create a withdrawn domain request and submit it and see if email was sent."
|
||||
user, _ = User.objects.get_or_create(username="testy")
|
||||
user, _ = User.objects.get_or_create(username="testy", email="testy@town.com")
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.WITHDRAWN, user=user)
|
||||
self.check_email_sent(domain_request, msg, "submit", 1, expected_content="Hi", expected_email=user.email)
|
||||
|
||||
|
@ -324,14 +324,14 @@ class TestDomainRequest(TestCase):
|
|||
@less_console_noise_decorator
|
||||
def test_approve_sends_email(self):
|
||||
msg = "Create a domain request and approve it and see if email was sent."
|
||||
user, _ = User.objects.get_or_create(username="testy")
|
||||
user, _ = User.objects.get_or_create(username="testy", email="testy@town.com")
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=user)
|
||||
self.check_email_sent(domain_request, msg, "approve", 1, expected_content="approved", expected_email=user.email)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_withdraw_sends_email(self):
|
||||
msg = "Create a domain request and withdraw it and see if email was sent."
|
||||
user, _ = User.objects.get_or_create(username="testy")
|
||||
user, _ = User.objects.get_or_create(username="testy", email="testy@town.com")
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=user)
|
||||
self.check_email_sent(
|
||||
domain_request, msg, "withdraw", 1, expected_content="withdrawn", expected_email=user.email
|
||||
|
@ -339,7 +339,7 @@ class TestDomainRequest(TestCase):
|
|||
|
||||
def test_reject_sends_email(self):
|
||||
"Create a domain request and reject it and see if email was sent."
|
||||
user, _ = User.objects.get_or_create(username="testy")
|
||||
user, _ = User.objects.get_or_create(username="testy", email="testy@town.com")
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.APPROVED, user=user)
|
||||
expected_email = user.email
|
||||
email_allowed, _ = AllowedEmail.objects.get_or_create(email=expected_email)
|
||||
|
|
|
@ -65,6 +65,10 @@ class TestWithDomainPermissions(TestWithUser):
|
|||
datetime.combine(date.today() + timedelta(days=1), datetime.min.time())
|
||||
),
|
||||
)
|
||||
self.domain_dns_needed, _ = Domain.objects.get_or_create(
|
||||
name="dns-needed.gov",
|
||||
state=Domain.State.DNS_NEEDED,
|
||||
)
|
||||
self.domain_deleted, _ = Domain.objects.get_or_create(
|
||||
name="deleted.gov",
|
||||
state=Domain.State.DELETED,
|
||||
|
@ -91,6 +95,7 @@ class TestWithDomainPermissions(TestWithUser):
|
|||
DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_just_nameserver)
|
||||
DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_on_hold)
|
||||
DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_deleted)
|
||||
DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_dns_needed)
|
||||
|
||||
self.role, _ = UserDomainRole.objects.get_or_create(
|
||||
user=self.user, domain=self.domain, role=UserDomainRole.Roles.MANAGER
|
||||
|
@ -99,6 +104,9 @@ class TestWithDomainPermissions(TestWithUser):
|
|||
UserDomainRole.objects.get_or_create(
|
||||
user=self.user, domain=self.domain_dsdata, role=UserDomainRole.Roles.MANAGER
|
||||
)
|
||||
UserDomainRole.objects.get_or_create(
|
||||
user=self.user, domain=self.domain_dns_needed, role=UserDomainRole.Roles.MANAGER
|
||||
)
|
||||
UserDomainRole.objects.get_or_create(
|
||||
user=self.user,
|
||||
domain=self.domain_multdsdata,
|
||||
|
@ -236,6 +244,7 @@ class TestDomainDetail(TestDomainOverview):
|
|||
# At the time of this test's writing, there are 6 UNKNOWN domains inherited
|
||||
# from constructors. Let's reset.
|
||||
with less_console_noise():
|
||||
PublicContact.objects.all().delete()
|
||||
Domain.objects.all().delete()
|
||||
UserDomainRole.objects.all().delete()
|
||||
|
||||
|
@ -340,7 +349,10 @@ class TestDomainDetail(TestDomainOverview):
|
|||
detail_page = self.client.get(f"/domain/{domain.id}")
|
||||
# Check that alert message displays properly
|
||||
self.assertContains(
|
||||
detail_page, "To manage information for this domain, you must add yourself as a domain manager."
|
||||
detail_page,
|
||||
"You don't have access to manage "
|
||||
+ domain.name
|
||||
+ ". If you need to make updates, contact one of the listed domain managers.",
|
||||
)
|
||||
# Check that user does not have option to Edit domain
|
||||
self.assertNotContains(detail_page, "Edit")
|
||||
|
@ -1964,3 +1976,292 @@ class TestDomainDNSSEC(TestDomainOverview):
|
|||
self.assertContains(
|
||||
result, str(DsDataError(code=DsDataErrorCodes.INVALID_DIGEST_SHA256)), count=2, status_code=200
|
||||
)
|
||||
|
||||
|
||||
class TestDomainChangeNotifications(TestDomainOverview):
|
||||
"""Test email notifications on updates to domain information"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
allowed_emails = [
|
||||
AllowedEmail(email="info@example.com"),
|
||||
AllowedEmail(email="doesnotexist@igorville.com"),
|
||||
]
|
||||
AllowedEmail.objects.bulk_create(allowed_emails)
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.mock_client_class = MagicMock()
|
||||
self.mock_client = self.mock_client_class.return_value
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
super().tearDownClass()
|
||||
AllowedEmail.objects.all().delete()
|
||||
|
||||
@boto3_mocking.patching
|
||||
@less_console_noise_decorator
|
||||
def test_notification_on_org_name_change(self):
|
||||
"""Test that an email is sent when the organization name is changed."""
|
||||
# We may end up sending emails on org name changes later, but it will be addressed
|
||||
# in the portfolio itself, rather than the individual domain.
|
||||
|
||||
self.domain_information.organization_name = "Town of Igorville"
|
||||
self.domain_information.address_line1 = "123 Main St"
|
||||
self.domain_information.city = "Igorville"
|
||||
self.domain_information.state_territory = "IL"
|
||||
self.domain_information.zipcode = "62052"
|
||||
self.domain_information.save()
|
||||
|
||||
org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"pk": self.domain.id}))
|
||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||
|
||||
org_name_page.form["organization_name"] = "Not igorville"
|
||||
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
org_name_page.form.submit()
|
||||
|
||||
# Check that an email was sent
|
||||
self.assertTrue(self.mock_client.send_email.called)
|
||||
|
||||
# Check email content
|
||||
# check the call sequence for the email
|
||||
_, kwargs = self.mock_client.send_email.call_args
|
||||
self.assertIn("Content", kwargs)
|
||||
self.assertIn("Simple", kwargs["Content"])
|
||||
self.assertIn("Subject", kwargs["Content"]["Simple"])
|
||||
self.assertIn("Body", kwargs["Content"]["Simple"])
|
||||
|
||||
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
|
||||
|
||||
self.assertIn("DOMAIN: igorville.gov", body)
|
||||
self.assertIn("UPDATED BY: First Last info@example.com", body)
|
||||
self.assertIn("INFORMATION UPDATED: Organization details", body)
|
||||
|
||||
@boto3_mocking.patching
|
||||
@less_console_noise_decorator
|
||||
def test_no_notification_on_org_name_change_with_portfolio(self):
|
||||
"""Test that an email is not sent on org name change when the domain is in a portfolio"""
|
||||
|
||||
portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test org", creator=self.user)
|
||||
|
||||
self.domain_information.organization_name = "Town of Igorville"
|
||||
self.domain_information.address_line1 = "123 Main St"
|
||||
self.domain_information.city = "Igorville"
|
||||
self.domain_information.state_territory = "IL"
|
||||
self.domain_information.zipcode = "62052"
|
||||
self.domain_information.portfolio = portfolio
|
||||
self.domain_information.save()
|
||||
|
||||
org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"pk": self.domain.id}))
|
||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||
|
||||
org_name_page.form["organization_name"] = "Not igorville"
|
||||
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
org_name_page.form.submit()
|
||||
|
||||
# Check that an email was not sent
|
||||
self.assertFalse(self.mock_client.send_email.called)
|
||||
|
||||
@boto3_mocking.patching
|
||||
@less_console_noise_decorator
|
||||
def test_no_notification_on_change_by_analyst(self):
|
||||
"""Test that an email is not sent on org name change when the domain is in a portfolio"""
|
||||
|
||||
portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test org", creator=self.user)
|
||||
|
||||
self.domain_information.organization_name = "Town of Igorville"
|
||||
self.domain_information.address_line1 = "123 Main St"
|
||||
self.domain_information.city = "Igorville"
|
||||
self.domain_information.state_territory = "IL"
|
||||
self.domain_information.zipcode = "62052"
|
||||
self.domain_information.portfolio = portfolio
|
||||
self.domain_information.save()
|
||||
|
||||
org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"pk": self.domain.id}))
|
||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||
|
||||
session = self.app.session
|
||||
session["analyst_action"] = "foo"
|
||||
session["analyst_action_location"] = self.domain.id
|
||||
session.save()
|
||||
|
||||
org_name_page.form["organization_name"] = "Not igorville"
|
||||
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
org_name_page.form.submit()
|
||||
|
||||
# Check that an email was not sent
|
||||
self.assertFalse(self.mock_client.send_email.called)
|
||||
|
||||
@boto3_mocking.patching
|
||||
@less_console_noise_decorator
|
||||
def test_notification_on_security_email_change(self):
|
||||
"""Test that an email is sent when the security email is changed."""
|
||||
|
||||
security_email_page = self.app.get(reverse("domain-security-email", kwargs={"pk": self.domain.id}))
|
||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||
|
||||
security_email_page.form["security_email"] = "new_security@example.com"
|
||||
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
security_email_page.form.submit()
|
||||
|
||||
self.assertTrue(self.mock_client.send_email.called)
|
||||
|
||||
_, kwargs = self.mock_client.send_email.call_args
|
||||
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
|
||||
|
||||
self.assertIn("DOMAIN: igorville.gov", body)
|
||||
self.assertIn("UPDATED BY: First Last info@example.com", body)
|
||||
self.assertIn("INFORMATION UPDATED: Security email", body)
|
||||
|
||||
@boto3_mocking.patching
|
||||
@less_console_noise_decorator
|
||||
def test_notification_on_dnssec_enable(self):
|
||||
"""Test that an email is sent when DNSSEC is enabled."""
|
||||
|
||||
page = self.client.get(reverse("domain-dns-dnssec", kwargs={"pk": self.domain_multdsdata.id}))
|
||||
self.assertContains(page, "Disable DNSSEC")
|
||||
|
||||
# Prepare the data for the POST request
|
||||
post_data = {
|
||||
"disable_dnssec": "Disable DNSSEC",
|
||||
}
|
||||
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
updated_page = self.client.post(
|
||||
reverse("domain-dns-dnssec", kwargs={"pk": self.domain.id}),
|
||||
post_data,
|
||||
follow=True,
|
||||
)
|
||||
|
||||
self.assertEqual(updated_page.status_code, 200)
|
||||
|
||||
self.assertContains(updated_page, "Enable DNSSEC")
|
||||
|
||||
self.assertTrue(self.mock_client.send_email.called)
|
||||
|
||||
_, kwargs = self.mock_client.send_email.call_args
|
||||
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
|
||||
|
||||
self.assertIn("DOMAIN: igorville.gov", body)
|
||||
self.assertIn("UPDATED BY: First Last info@example.com", body)
|
||||
self.assertIn("INFORMATION UPDATED: DNSSEC / DS Data", body)
|
||||
|
||||
@boto3_mocking.patching
|
||||
@less_console_noise_decorator
|
||||
def test_notification_on_ds_data_change(self):
|
||||
"""Test that an email is sent when DS data is changed."""
|
||||
|
||||
ds_data_page = self.app.get(reverse("domain-dns-dnssec-dsdata", kwargs={"pk": self.domain.id}))
|
||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||
|
||||
# Add DS data
|
||||
ds_data_page.forms[0]["form-0-key_tag"] = "12345"
|
||||
ds_data_page.forms[0]["form-0-algorithm"] = "13"
|
||||
ds_data_page.forms[0]["form-0-digest_type"] = "2"
|
||||
ds_data_page.forms[0]["form-0-digest"] = "1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF"
|
||||
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
ds_data_page.forms[0].submit()
|
||||
|
||||
# check that the email was sent
|
||||
self.assertTrue(self.mock_client.send_email.called)
|
||||
|
||||
# check some stuff about the email
|
||||
_, kwargs = self.mock_client.send_email.call_args
|
||||
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
|
||||
|
||||
self.assertIn("DOMAIN: igorville.gov", body)
|
||||
self.assertIn("UPDATED BY: First Last info@example.com", body)
|
||||
self.assertIn("INFORMATION UPDATED: DNSSEC / DS Data", body)
|
||||
|
||||
@boto3_mocking.patching
|
||||
@less_console_noise_decorator
|
||||
def test_notification_on_senior_official_change(self):
|
||||
"""Test that an email is sent when the senior official information is changed."""
|
||||
|
||||
self.domain_information.senior_official = Contact.objects.create(
|
||||
first_name="Old", last_name="Official", title="Manager", email="old_official@example.com"
|
||||
)
|
||||
self.domain_information.save()
|
||||
|
||||
senior_official_page = self.app.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id}))
|
||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||
|
||||
senior_official_page.form["first_name"] = "New"
|
||||
senior_official_page.form["last_name"] = "Official"
|
||||
senior_official_page.form["title"] = "Director"
|
||||
senior_official_page.form["email"] = "new_official@example.com"
|
||||
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
senior_official_page.form.submit()
|
||||
|
||||
self.assertTrue(self.mock_client.send_email.called)
|
||||
|
||||
_, kwargs = self.mock_client.send_email.call_args
|
||||
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
|
||||
|
||||
self.assertIn("DOMAIN: igorville.gov", body)
|
||||
self.assertIn("UPDATED BY: First Last info@example.com", body)
|
||||
self.assertIn("INFORMATION UPDATED: Senior official", body)
|
||||
|
||||
@boto3_mocking.patching
|
||||
@less_console_noise_decorator
|
||||
def test_no_notification_on_senior_official_when_portfolio(self):
|
||||
"""Test that an email is not sent when the senior official information is changed
|
||||
and the domain is in a portfolio."""
|
||||
|
||||
self.domain_information.senior_official = Contact.objects.create(
|
||||
first_name="Old", last_name="Official", title="Manager", email="old_official@example.com"
|
||||
)
|
||||
portfolio, _ = Portfolio.objects.get_or_create(
|
||||
organization_name="portfolio",
|
||||
creator=self.user,
|
||||
)
|
||||
self.domain_information.portfolio = portfolio
|
||||
self.domain_information.save()
|
||||
|
||||
senior_official_page = self.app.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id}))
|
||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||
|
||||
senior_official_page.form["first_name"] = "New"
|
||||
senior_official_page.form["last_name"] = "Official"
|
||||
senior_official_page.form["title"] = "Director"
|
||||
senior_official_page.form["email"] = "new_official@example.com"
|
||||
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
senior_official_page.form.submit()
|
||||
|
||||
self.assertFalse(self.mock_client.send_email.called)
|
||||
|
||||
@boto3_mocking.patching
|
||||
@less_console_noise_decorator
|
||||
def test_no_notification_when_dns_needed(self):
|
||||
"""Test that an email is not sent when nameservers are changed while the state is DNS_NEEDED."""
|
||||
|
||||
nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"pk": self.domain_dns_needed.id}))
|
||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||
|
||||
# add nameservers
|
||||
nameservers_page.form["form-0-server"] = "ns1-new.dns-needed.gov"
|
||||
nameservers_page.form["form-0-ip"] = "192.168.1.1"
|
||||
nameservers_page.form["form-1-server"] = "ns2-new.dns-needed.gov"
|
||||
nameservers_page.form["form-1-ip"] = "192.168.1.2"
|
||||
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
nameservers_page.form.submit()
|
||||
|
||||
# Check that an email was not sent
|
||||
self.assertFalse(self.mock_client.send_email.called)
|
||||
|
|
|
@ -37,6 +37,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
|
|||
UserDomainRole.objects.all().delete()
|
||||
UserPortfolioPermission.objects.all().delete()
|
||||
DomainInformation.objects.all().delete()
|
||||
Domain.objects.all().delete()
|
||||
Portfolio.objects.all().delete()
|
||||
super().tearDown()
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
from django.urls import reverse
|
||||
|
||||
from registrar.models.portfolio import Portfolio
|
||||
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||
from registrar.models.user import User
|
||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
|
@ -38,6 +39,7 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
|
|||
phone="8003114567",
|
||||
title="Admin",
|
||||
)
|
||||
cls.email5 = "fifth@example.com"
|
||||
|
||||
# Create Portfolio
|
||||
cls.portfolio = Portfolio.objects.create(creator=cls.user, organization_name="Test Portfolio")
|
||||
|
@ -67,6 +69,23 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
|
|||
portfolio=cls.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
)
|
||||
PortfolioInvitation.objects.create(
|
||||
email=cls.email5,
|
||||
portfolio=cls.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
PortfolioInvitation.objects.all().delete()
|
||||
UserPortfolioPermission.objects.all().delete()
|
||||
Portfolio.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
super().tearDownClass()
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
@ -83,14 +102,21 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
|
|||
self.assertFalse(data["has_previous"])
|
||||
self.assertFalse(data["has_next"])
|
||||
self.assertEqual(data["num_pages"], 1)
|
||||
self.assertEqual(data["total"], 4)
|
||||
self.assertEqual(data["unfiltered_total"], 4)
|
||||
self.assertEqual(data["total"], 5)
|
||||
self.assertEqual(data["unfiltered_total"], 5)
|
||||
|
||||
# Check the number of members
|
||||
self.assertEqual(len(data["members"]), 4)
|
||||
self.assertEqual(len(data["members"]), 5)
|
||||
|
||||
# Check member fields
|
||||
expected_emails = {self.user.email, self.user2.email, self.user3.email, self.user4.email}
|
||||
expected_emails = {
|
||||
self.user.email,
|
||||
self.user2.email,
|
||||
self.user3.email,
|
||||
self.user4.email,
|
||||
self.user4.email,
|
||||
self.email5,
|
||||
}
|
||||
actual_emails = {member["email"] for member in data["members"]}
|
||||
self.assertEqual(expected_emails, actual_emails)
|
||||
|
||||
|
@ -123,8 +149,8 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
|
|||
self.assertTrue(data["has_next"])
|
||||
self.assertFalse(data["has_previous"])
|
||||
self.assertEqual(data["num_pages"], 2)
|
||||
self.assertEqual(data["total"], 14)
|
||||
self.assertEqual(data["unfiltered_total"], 14)
|
||||
self.assertEqual(data["total"], 15)
|
||||
self.assertEqual(data["unfiltered_total"], 15)
|
||||
|
||||
# Check the number of members on page 1
|
||||
self.assertEqual(len(data["members"]), 10)
|
||||
|
@ -142,7 +168,7 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
|
|||
self.assertEqual(data["num_pages"], 2)
|
||||
|
||||
# Check the number of members on page 2
|
||||
self.assertEqual(len(data["members"]), 4)
|
||||
self.assertEqual(len(data["members"]), 5)
|
||||
|
||||
def test_search(self):
|
||||
"""Test search functionality for portfolio members."""
|
||||
|
|
|
@ -10,6 +10,7 @@ from registrar.models import (
|
|||
UserDomainRole,
|
||||
User,
|
||||
)
|
||||
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||
from registrar.models.user_group import UserGroup
|
||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
|
@ -288,9 +289,9 @@ class TestPortfolio(WebTest):
|
|||
def test_accessible_pages_when_user_does_not_have_role(self):
|
||||
"""Test that admin / memmber roles are associated with the right access"""
|
||||
self.app.set_user(self.user.username)
|
||||
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user, portfolio=self.portfolio, roles=portfolio_roles
|
||||
user=self.user, portfolio=self.portfolio, roles=roles
|
||||
)
|
||||
with override_flag("organization_feature", active=True):
|
||||
# This will redirect the user to the portfolio page.
|
||||
|
@ -398,8 +399,8 @@ class TestPortfolio(WebTest):
|
|||
"""When organization_feature flag is true and user has a portfolio,
|
||||
the portfolio should be set in session."""
|
||||
self.client.force_login(self.user)
|
||||
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles)
|
||||
roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=roles)
|
||||
with override_flag("organization_feature", active=True):
|
||||
response = self.client.get(reverse("home"))
|
||||
# Ensure that middleware processes the session
|
||||
|
@ -420,8 +421,8 @@ class TestPortfolio(WebTest):
|
|||
This test also satisfies the condition when multiple_portfolios flag
|
||||
is false and user has a portfolio, so won't add a redundant test for that."""
|
||||
self.client.force_login(self.user)
|
||||
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles)
|
||||
roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=roles)
|
||||
response = self.client.get(reverse("home"))
|
||||
# Ensure that middleware processes the session
|
||||
session_middleware = SessionMiddleware(lambda request: None)
|
||||
|
@ -457,8 +458,8 @@ class TestPortfolio(WebTest):
|
|||
"""When multiple_portfolios flag is true and user has a portfolio,
|
||||
the portfolio should be set in session."""
|
||||
self.client.force_login(self.user)
|
||||
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles)
|
||||
roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=roles)
|
||||
with override_flag("organization_feature", active=True), override_flag("multiple_portfolios", active=True):
|
||||
response = self.client.get(reverse("home"))
|
||||
# Ensure that middleware processes the session
|
||||
|
@ -817,7 +818,6 @@ class TestPortfolio(WebTest):
|
|||
|
||||
# Verify that view-only settings are sent in the dynamic HTML
|
||||
response = self.client.get(reverse("get_portfolio_members_json") + f"?portfolio={self.portfolio.pk}")
|
||||
print(response.content)
|
||||
self.assertContains(response, '"action_label": "View"')
|
||||
self.assertContains(response, '"svg_icon": "visibility"')
|
||||
|
||||
|
@ -856,6 +856,230 @@ class TestPortfolio(WebTest):
|
|||
# TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"{response.content}")
|
||||
self.assertContains(response, '"is_admin": true')
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
def test_cannot_view_member_page_when_flag_is_off(self):
|
||||
"""Test that user cannot access the member page when waffle flag is off"""
|
||||
|
||||
# Verify that the user cannot access the member page
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse("member", kwargs={"pk": 1}), follow=True)
|
||||
# Make sure the page is denied
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_cannot_view_member_page_when_user_has_no_permission(self):
|
||||
"""Test that user cannot access the member page without proper permission"""
|
||||
|
||||
# give user base permissions
|
||||
UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
)
|
||||
|
||||
# Verify that the user cannot access the member page
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse("member", kwargs={"pk": 1}), follow=True)
|
||||
# Make sure the page is denied
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_can_view_member_page_when_user_has_view_members(self):
|
||||
"""Test that user can access the member page with view_members permission"""
|
||||
|
||||
# Arrange
|
||||
# give user permissions to view members
|
||||
permission_obj, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
# Verify the page can be accessed
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse("member", kwargs={"pk": permission_obj.pk}), follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Assert text within the page is correct
|
||||
self.assertContains(response, "First Last")
|
||||
self.assertContains(response, self.user.email)
|
||||
self.assertContains(response, "Basic access")
|
||||
self.assertContains(response, "No access")
|
||||
self.assertContains(response, "View all members")
|
||||
self.assertContains(response, "This member does not manage any domains.")
|
||||
|
||||
# Assert buttons and links within the page are correct
|
||||
self.assertNotContains(response, "usa-button--more-actions") # test that 3 dot is not present
|
||||
self.assertNotContains(response, "sprite.svg#edit") # test that Edit link is not present
|
||||
self.assertNotContains(response, "sprite.svg#settings") # test that Manage link is not present
|
||||
self.assertContains(response, "sprite.svg#visibility") # test that View link is present
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_can_view_member_page_when_user_has_edit_members(self):
|
||||
"""Test that user can access the member page with edit_members permission"""
|
||||
|
||||
# Arrange
|
||||
# give user permissions to view AND manage members
|
||||
permission_obj, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
# Verify the page can be accessed
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse("member", kwargs={"pk": permission_obj.pk}), follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Assert text within the page is correct
|
||||
self.assertContains(response, "First Last")
|
||||
self.assertContains(response, self.user.email)
|
||||
self.assertContains(response, "Admin access")
|
||||
self.assertContains(response, "View all requests plus create requests")
|
||||
self.assertContains(response, "View all members plus manage members")
|
||||
self.assertContains(
|
||||
response, 'This member does not manage any domains. To assign this member a domain, click "Manage"'
|
||||
)
|
||||
|
||||
# Assert buttons and links within the page are correct
|
||||
self.assertContains(response, "usa-button--more-actions") # test that 3 dot is present
|
||||
self.assertContains(response, "sprite.svg#edit") # test that Edit link is present
|
||||
self.assertContains(response, "sprite.svg#settings") # test that Manage link is present
|
||||
self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
def test_cannot_view_invitedmember_page_when_flag_is_off(self):
|
||||
"""Test that user cannot access the invitedmember page when waffle flag is off"""
|
||||
|
||||
# Verify that the user cannot access the member page
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse("invitedmember", kwargs={"pk": 1}), follow=True)
|
||||
# Make sure the page is denied
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_cannot_view_invitedmember_page_when_user_has_no_permission(self):
|
||||
"""Test that user cannot access the invitedmember page without proper permission"""
|
||||
|
||||
# give user base permissions
|
||||
UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
)
|
||||
|
||||
# Verify that the user cannot access the member page
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse("invitedmember", kwargs={"pk": 1}), follow=True)
|
||||
# Make sure the page is denied
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_can_view_invitedmember_page_when_user_has_view_members(self):
|
||||
"""Test that user can access the invitedmember page with view_members permission"""
|
||||
|
||||
# Arrange
|
||||
# give user permissions to view members
|
||||
UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
],
|
||||
)
|
||||
portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(
|
||||
email="info@example.com",
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
# Verify the page can be accessed
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse("invitedmember", kwargs={"pk": portfolio_invitation.pk}), follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Assert text within the page is correct
|
||||
self.assertContains(response, "Invited")
|
||||
self.assertContains(response, portfolio_invitation.email)
|
||||
self.assertContains(response, "Basic access")
|
||||
self.assertContains(response, "No access")
|
||||
self.assertContains(response, "View all members")
|
||||
self.assertContains(response, "This member does not manage any domains.")
|
||||
|
||||
# Assert buttons and links within the page are correct
|
||||
self.assertNotContains(response, "usa-button--more-actions") # test that 3 dot is not present
|
||||
self.assertNotContains(response, "sprite.svg#edit") # test that Edit link is not present
|
||||
self.assertNotContains(response, "sprite.svg#settings") # test that Manage link is not present
|
||||
self.assertContains(response, "sprite.svg#visibility") # test that View link is present
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_can_view_invitedmember_page_when_user_has_edit_members(self):
|
||||
"""Test that user can access the invitedmember page with edit_members permission"""
|
||||
|
||||
# Arrange
|
||||
# give user permissions to view AND manage members
|
||||
permission_obj, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(
|
||||
email="info@example.com",
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
# Verify the page can be accessed
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse("invitedmember", kwargs={"pk": portfolio_invitation.pk}), follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Assert text within the page is correct
|
||||
self.assertContains(response, "Invited")
|
||||
self.assertContains(response, portfolio_invitation.email)
|
||||
self.assertContains(response, "Admin access")
|
||||
self.assertContains(response, "View all requests plus create requests")
|
||||
self.assertContains(response, "View all members plus manage members")
|
||||
self.assertContains(
|
||||
response, 'This member does not manage any domains. To assign this member a domain, click "Manage"'
|
||||
)
|
||||
|
||||
# Assert buttons and links within the page are correct
|
||||
self.assertContains(response, "usa-button--more-actions") # test that 3 dot is present
|
||||
self.assertContains(response, "sprite.svg#edit") # test that Edit link is present
|
||||
self.assertContains(response, "sprite.svg#settings") # test that Manage link is present
|
||||
self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
def test_portfolio_domain_requests_page_when_user_has_no_permissions(self):
|
||||
|
@ -1015,8 +1239,8 @@ class TestPortfolio(WebTest):
|
|||
def test_portfolio_cache_updates_when_modified(self):
|
||||
"""Test that the portfolio in session updates when the portfolio is modified"""
|
||||
self.client.force_login(self.user)
|
||||
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles)
|
||||
roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=roles)
|
||||
|
||||
with override_flag("organization_feature", active=True):
|
||||
# Initial request to set the portfolio in session
|
||||
|
@ -1044,8 +1268,8 @@ class TestPortfolio(WebTest):
|
|||
def test_portfolio_cache_updates_when_flag_disabled_while_logged_in(self):
|
||||
"""Test that the portfolio in session is set to None when the organization_feature flag is disabled"""
|
||||
self.client.force_login(self.user)
|
||||
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles)
|
||||
roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=roles)
|
||||
|
||||
with override_flag("organization_feature", active=True):
|
||||
# Initial request to set the portfolio in session
|
||||
|
|
|
@ -521,7 +521,8 @@ class DomainRequestTests(TestWithUser, WebTest):
|
|||
# And the existence of the modal's data parked and ready for the js init.
|
||||
# The next assert also tests for the passed requested domain context from
|
||||
# the view > domain_request_form > modal
|
||||
self.assertContains(review_page, "You are about to submit a domain request for city.gov")
|
||||
self.assertContains(review_page, "You are about to submit a domain request for")
|
||||
self.assertContains(review_page, "city.gov")
|
||||
|
||||
# final submission results in a redirect to the "finished" URL
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
|
|
|
@ -22,30 +22,47 @@ class EmailSendingError(RuntimeError):
|
|||
pass
|
||||
|
||||
|
||||
def send_templated_email(
|
||||
def send_templated_email( # noqa
|
||||
template_name: str,
|
||||
subject_template_name: str,
|
||||
to_address: str,
|
||||
bcc_address="",
|
||||
to_address: str = "",
|
||||
bcc_address: str = "",
|
||||
context={},
|
||||
attachment_file=None,
|
||||
wrap_email=False,
|
||||
cc_addresses: list[str] = [],
|
||||
):
|
||||
"""Send an email built from a template to one email address.
|
||||
"""Send an email built from a template.
|
||||
|
||||
to_address and bcc_address currently only support single addresses.
|
||||
|
||||
cc_address is a list and can contain many addresses. Emails not in the
|
||||
whitelist (if applicable) will be filtered out before sending.
|
||||
|
||||
template_name and subject_template_name are relative to the same template
|
||||
context as Django's HTML templates. context gives additional information
|
||||
that the template may use.
|
||||
|
||||
Raises EmailSendingError if SES client could not be accessed
|
||||
Raises EmailSendingError if:
|
||||
SES client could not be accessed
|
||||
No valid recipient addresses are provided
|
||||
"""
|
||||
|
||||
# by default assume we can send to all addresses (prod has no whitelist)
|
||||
sendable_cc_addresses = cc_addresses
|
||||
|
||||
if not settings.IS_PRODUCTION: # type: ignore
|
||||
# Split into a function: C901 'send_templated_email' is too complex.
|
||||
# Raises an error if we cannot send an email (due to restrictions).
|
||||
# Does nothing otherwise.
|
||||
_can_send_email(to_address, bcc_address)
|
||||
|
||||
# if we're not in prod, we need to check the whitelist for CC'ed addresses
|
||||
sendable_cc_addresses, blocked_cc_addresses = get_sendable_addresses(cc_addresses)
|
||||
|
||||
if blocked_cc_addresses:
|
||||
logger.warning("Some CC'ed addresses were removed: %s.", blocked_cc_addresses)
|
||||
|
||||
template = get_template(template_name)
|
||||
email_body = template.render(context=context)
|
||||
|
||||
|
@ -64,14 +81,23 @@ def send_templated_email(
|
|||
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||
config=settings.BOTO_CONFIG,
|
||||
)
|
||||
logger.info(f"An email was sent! Template name: {template_name} to {to_address}")
|
||||
logger.info(f"Connected to SES client! Template name: {template_name} to {to_address}")
|
||||
except Exception as exc:
|
||||
logger.debug("E-mail unable to send! Could not access the SES client.")
|
||||
raise EmailSendingError("Could not access the SES client.") from exc
|
||||
|
||||
destination = {"ToAddresses": [to_address]}
|
||||
destination = {}
|
||||
if to_address:
|
||||
destination["ToAddresses"] = [to_address]
|
||||
if bcc_address:
|
||||
destination["BccAddresses"] = [bcc_address]
|
||||
if cc_addresses:
|
||||
destination["CcAddresses"] = sendable_cc_addresses
|
||||
|
||||
# make sure we don't try and send an email to nowhere
|
||||
if not destination:
|
||||
message = "Email unable to send, no valid recipients provided."
|
||||
raise EmailSendingError(message)
|
||||
|
||||
try:
|
||||
if not attachment_file:
|
||||
|
@ -90,6 +116,7 @@ def send_templated_email(
|
|||
},
|
||||
},
|
||||
)
|
||||
logger.info("Email sent to [%s], bcc [%s], cc %s", to_address, bcc_address, sendable_cc_addresses)
|
||||
else:
|
||||
ses_client = boto3.client(
|
||||
"ses",
|
||||
|
@ -101,6 +128,10 @@ def send_templated_email(
|
|||
send_email_with_attachment(
|
||||
settings.DEFAULT_FROM_EMAIL, to_address, subject, email_body, attachment_file, ses_client
|
||||
)
|
||||
logger.info(
|
||||
"Email with attachment sent to [%s], bcc [%s], cc %s", to_address, bcc_address, sendable_cc_addresses
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
raise EmailSendingError("Could not send SES email.") from exc
|
||||
|
||||
|
@ -125,6 +156,33 @@ def _can_send_email(to_address, bcc_address):
|
|||
raise EmailSendingError(message.format(bcc_address))
|
||||
|
||||
|
||||
def get_sendable_addresses(addresses: list[str]) -> tuple[list[str], list[str]]:
|
||||
"""Checks whether a list of addresses can be sent to.
|
||||
|
||||
Returns: a lists of all provided addresses that are ok to send to and a list of addresses that were blocked.
|
||||
|
||||
Paramaters:
|
||||
|
||||
addresses: a list of strings representing all addresses to be checked.
|
||||
"""
|
||||
|
||||
if flag_is_active(None, "disable_email_sending"): # type: ignore
|
||||
message = "Could not send email. Email sending is disabled due to flag 'disable_email_sending'."
|
||||
logger.warning(message)
|
||||
return ([], [])
|
||||
else:
|
||||
AllowedEmail = apps.get_model("registrar", "AllowedEmail")
|
||||
allowed_emails = []
|
||||
blocked_emails = []
|
||||
for address in addresses:
|
||||
if AllowedEmail.is_allowed_email(address):
|
||||
allowed_emails.append(address)
|
||||
else:
|
||||
blocked_emails.append(address)
|
||||
|
||||
return allowed_emails, blocked_emails
|
||||
|
||||
|
||||
def wrap_text_and_preserve_paragraphs(text, width):
|
||||
"""
|
||||
Wraps text to `width` preserving newlines; splits on '\n', wraps segments, rejoins with '\n'.
|
||||
|
|
|
@ -23,6 +23,15 @@ class InvalidDomainError(ValueError):
|
|||
pass
|
||||
|
||||
|
||||
class OutsideOrgMemberError(ValueError):
|
||||
"""
|
||||
Error raised when an org member tries adding a user from a different .gov org.
|
||||
To be deleted when users can be members of multiple orgs.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ActionNotAllowed(Exception):
|
||||
"""User accessed an action that is not
|
||||
allowed by the current state"""
|
||||
|
@ -240,7 +249,7 @@ class SecurityEmailError(Exception):
|
|||
"""
|
||||
|
||||
_error_mapping = {
|
||||
SecurityEmailErrorCodes.BAD_DATA: ("Enter an email address in the required format, " "like name@example.com."),
|
||||
SecurityEmailErrorCodes.BAD_DATA: ("Enter an email address in the required format, like name@example.com."),
|
||||
}
|
||||
|
||||
def __init__(self, *args, code=None, **kwargs):
|
||||
|
|
|
@ -5,6 +5,7 @@ authorized users can see information on a domain, every view here should
|
|||
inherit from `DomainPermissionView` (or DomainInvitationPermissionDeleteView).
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
import logging
|
||||
|
||||
from django.contrib import messages
|
||||
|
@ -21,8 +22,10 @@ from registrar.models import (
|
|||
DomainRequest,
|
||||
DomainInformation,
|
||||
DomainInvitation,
|
||||
PortfolioInvitation,
|
||||
User,
|
||||
UserDomainRole,
|
||||
UserPortfolioPermission,
|
||||
PublicContact,
|
||||
)
|
||||
from registrar.utility.enums import DefaultEmail
|
||||
|
@ -35,9 +38,11 @@ from registrar.utility.errors import (
|
|||
DsDataErrorCodes,
|
||||
SecurityEmailError,
|
||||
SecurityEmailErrorCodes,
|
||||
OutsideOrgMemberError,
|
||||
)
|
||||
from registrar.models.utility.contact_error import ContactError
|
||||
from registrar.views.utility.permission_views import UserDomainRolePermissionDeleteView
|
||||
from registrar.utility.waffle import flag_is_active_for_user
|
||||
|
||||
from ..forms import (
|
||||
SeniorOfficialContactForm,
|
||||
|
@ -148,6 +153,103 @@ class DomainFormBaseView(DomainBaseView, FormMixin):
|
|||
|
||||
return current_domain_info
|
||||
|
||||
def send_update_notification(self, form, force_send=False):
|
||||
"""Send a notification to all domain managers that an update has occured
|
||||
for a single domain. Uses update_to_approved_domain.txt template.
|
||||
|
||||
If there are no changes to the form, emails will NOT be sent unless force_send
|
||||
is set to True.
|
||||
"""
|
||||
|
||||
# send notification email for changes to any of these forms
|
||||
form_label_dict = {
|
||||
DomainSecurityEmailForm: "Security email",
|
||||
DomainDnssecForm: "DNSSEC / DS Data",
|
||||
DomainDsdataFormset: "DNSSEC / DS Data",
|
||||
DomainOrgNameAddressForm: "Organization details",
|
||||
SeniorOfficialContactForm: "Senior official",
|
||||
NameserverFormset: "Name servers",
|
||||
}
|
||||
|
||||
# forms of these types should not send notifications if they're part of a portfolio/Organization
|
||||
check_for_portfolio = {
|
||||
DomainOrgNameAddressForm,
|
||||
SeniorOfficialContactForm,
|
||||
}
|
||||
|
||||
is_analyst_action = "analyst_action" in self.session and "analyst_action_location" in self.session
|
||||
|
||||
should_notify = False
|
||||
|
||||
if form.__class__ in form_label_dict:
|
||||
if is_analyst_action:
|
||||
logger.debug("No notification sent: Action was conducted by an analyst")
|
||||
else:
|
||||
# these types of forms can cause notifications
|
||||
should_notify = True
|
||||
if form.__class__ in check_for_portfolio:
|
||||
# some forms shouldn't cause notifications if they are in a portfolio
|
||||
info = self.get_domain_info_from_domain()
|
||||
if not info or info.portfolio:
|
||||
logger.debug("No notification sent: Domain is part of a portfolio")
|
||||
should_notify = False
|
||||
else:
|
||||
# don't notify for any other types of forms
|
||||
should_notify = False
|
||||
if should_notify and (form.has_changed() or force_send):
|
||||
context = {
|
||||
"domain": self.object.name,
|
||||
"user": self.request.user,
|
||||
"date": date.today(),
|
||||
"changes": form_label_dict[form.__class__],
|
||||
}
|
||||
self.email_domain_managers(
|
||||
self.object,
|
||||
"emails/update_to_approved_domain.txt",
|
||||
"emails/update_to_approved_domain_subject.txt",
|
||||
context,
|
||||
)
|
||||
else:
|
||||
logger.info(f"No notification sent for {form.__class__}.")
|
||||
|
||||
def email_domain_managers(self, domain: Domain, template: str, subject_template: str, context={}):
|
||||
"""Send a single email built from a template to all managers for a given domain.
|
||||
|
||||
template_name and subject_template_name are relative to the same template
|
||||
context as Django's HTML templates. context gives additional information
|
||||
that the template may use.
|
||||
|
||||
context is a dictionary containing any information needed to fill in values
|
||||
in the provided template, exactly the same as with send_templated_email.
|
||||
|
||||
Will log a warning if the email fails to send for any reason, but will not raise an error.
|
||||
"""
|
||||
manager_pks = UserDomainRole.objects.filter(domain=domain.pk, role=UserDomainRole.Roles.MANAGER).values_list(
|
||||
"user", flat=True
|
||||
)
|
||||
emails = list(User.objects.filter(pk__in=manager_pks).values_list("email", flat=True))
|
||||
try:
|
||||
# Remove the current user so they aren't CC'ed, since they will be the "to_address"
|
||||
emails.remove(self.request.user.email) # type: ignore
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
try:
|
||||
send_templated_email(
|
||||
template,
|
||||
subject_template,
|
||||
to_address=self.request.user.email, # type: ignore
|
||||
context=context,
|
||||
cc_addresses=emails,
|
||||
)
|
||||
except EmailSendingError:
|
||||
logger.warning(
|
||||
"Could not sent notification email to %s for domain %s",
|
||||
emails,
|
||||
domain.name,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
class DomainView(DomainBaseView):
|
||||
"""Domain detail overview page."""
|
||||
|
@ -223,6 +325,8 @@ class DomainOrgNameAddressView(DomainFormBaseView):
|
|||
|
||||
def form_valid(self, form):
|
||||
"""The form is valid, save the organization name and mailing address."""
|
||||
self.send_update_notification(form)
|
||||
|
||||
form.save()
|
||||
|
||||
messages.success(self.request, "The organization information for this domain has been updated.")
|
||||
|
@ -326,6 +430,8 @@ class DomainSeniorOfficialView(DomainFormBaseView):
|
|||
form.set_domain_info(self.object.domain_info)
|
||||
form.save()
|
||||
|
||||
self.send_update_notification(form)
|
||||
|
||||
messages.success(self.request, "The senior official for this domain has been updated.")
|
||||
|
||||
# superclass has the redirect
|
||||
|
@ -404,19 +510,25 @@ class DomainNameserversView(DomainFormBaseView):
|
|||
self._get_domain(request)
|
||||
formset = self.get_form()
|
||||
|
||||
logger.debug("got formet")
|
||||
|
||||
if "btn-cancel-click" in request.POST:
|
||||
url = self.get_success_url()
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
if formset.is_valid():
|
||||
logger.debug("formset is valid")
|
||||
return self.form_valid(formset)
|
||||
else:
|
||||
logger.debug("formset is invalid")
|
||||
logger.debug(formset.errors)
|
||||
return self.form_invalid(formset)
|
||||
|
||||
def form_valid(self, formset):
|
||||
"""The formset is valid, perform something with it."""
|
||||
|
||||
self.request.session["nameservers_form_domain"] = self.object
|
||||
initial_state = self.object.state
|
||||
|
||||
# Set the nameservers from the formset
|
||||
nameservers = []
|
||||
|
@ -438,7 +550,6 @@ class DomainNameserversView(DomainFormBaseView):
|
|||
except KeyError:
|
||||
# no server information in this field, skip it
|
||||
pass
|
||||
|
||||
try:
|
||||
self.object.nameservers = nameservers
|
||||
except NameserverError as Err:
|
||||
|
@ -458,6 +569,8 @@ class DomainNameserversView(DomainFormBaseView):
|
|||
messages.error(self.request, NameserverError(code=nsErrorCodes.BAD_DATA))
|
||||
logger.error(f"Registry error: {Err}")
|
||||
else:
|
||||
if initial_state == Domain.State.READY:
|
||||
self.send_update_notification(formset)
|
||||
messages.success(
|
||||
self.request,
|
||||
"The name servers for this domain have been updated. "
|
||||
|
@ -510,7 +623,8 @@ class DomainDNSSECView(DomainFormBaseView):
|
|||
errmsg = "Error removing existing DNSSEC record(s)."
|
||||
logger.error(errmsg + ": " + err)
|
||||
messages.error(self.request, errmsg)
|
||||
|
||||
else:
|
||||
self.send_update_notification(form, force_send=True)
|
||||
return self.form_valid(form)
|
||||
|
||||
|
||||
|
@ -634,6 +748,8 @@ class DomainDsDataView(DomainFormBaseView):
|
|||
logger.error(f"Registry error: {err}")
|
||||
return self.form_invalid(formset)
|
||||
else:
|
||||
self.send_update_notification(formset)
|
||||
|
||||
messages.success(self.request, "The DS data records for this domain have been updated.")
|
||||
# superclass has the redirect
|
||||
return super().form_valid(formset)
|
||||
|
@ -700,8 +816,12 @@ class DomainSecurityEmailView(DomainFormBaseView):
|
|||
messages.error(self.request, SecurityEmailError(code=SecurityEmailErrorCodes.BAD_DATA))
|
||||
logger.error(f"Generic registry error: {Err}")
|
||||
else:
|
||||
self.send_update_notification(form)
|
||||
messages.success(self.request, "The security email for this domain has been updated.")
|
||||
|
||||
# superclass has the redirect
|
||||
return super().form_valid(form)
|
||||
|
||||
# superclass has the redirect
|
||||
return redirect(self.get_success_url())
|
||||
|
||||
|
@ -778,7 +898,18 @@ class DomainAddUserView(DomainFormBaseView):
|
|||
"""Get an absolute URL for this domain."""
|
||||
return self.request.build_absolute_uri(reverse("domain", kwargs={"pk": self.object.id}))
|
||||
|
||||
def _send_domain_invitation_email(self, email: str, requestor: User, add_success=True):
|
||||
def _is_member_of_different_org(self, email, requestor, requested_user):
|
||||
"""Verifies if an email belongs to a different organization as a member or invited member."""
|
||||
# Check if user is a already member of a different organization than the requestor's org
|
||||
requestor_org = UserPortfolioPermission.objects.filter(user=requestor).first().portfolio
|
||||
existing_org_permission = UserPortfolioPermission.objects.filter(user=requested_user).first()
|
||||
existing_org_invitation = PortfolioInvitation.objects.filter(email=email).first()
|
||||
|
||||
return (existing_org_permission and existing_org_permission.portfolio != requestor_org) or (
|
||||
existing_org_invitation and existing_org_invitation.portfolio != requestor_org
|
||||
)
|
||||
|
||||
def _send_domain_invitation_email(self, email: str, requestor: User, requested_user=None, add_success=True):
|
||||
"""Performs the sending of the domain invitation email,
|
||||
does not make a domain information object
|
||||
email: string- email to send to
|
||||
|
@ -803,6 +934,13 @@ class DomainAddUserView(DomainFormBaseView):
|
|||
)
|
||||
return None
|
||||
|
||||
# Check is user is a member or invited member of a different org from this domain's org
|
||||
if flag_is_active_for_user(requestor, "organization_feature") and self._is_member_of_different_org(
|
||||
email, requestor, requested_user
|
||||
):
|
||||
add_success = False
|
||||
raise OutsideOrgMemberError
|
||||
|
||||
# Check to see if an invite has already been sent
|
||||
try:
|
||||
invite = DomainInvitation.objects.get(email=email, domain=self.object)
|
||||
|
@ -859,16 +997,21 @@ class DomainAddUserView(DomainFormBaseView):
|
|||
Throws EmailSendingError."""
|
||||
requested_email = form.cleaned_data["email"]
|
||||
requestor = self.request.user
|
||||
email_success = False
|
||||
# look up a user with that email
|
||||
try:
|
||||
requested_user = User.objects.get(email=requested_email)
|
||||
except User.DoesNotExist:
|
||||
# no matching user, go make an invitation
|
||||
email_success = True
|
||||
return self._make_invitation(requested_email, requestor)
|
||||
else:
|
||||
# if user already exists then just send an email
|
||||
try:
|
||||
self._send_domain_invitation_email(requested_email, requestor, add_success=False)
|
||||
self._send_domain_invitation_email(
|
||||
requested_email, requestor, requested_user=requested_user, add_success=False
|
||||
)
|
||||
email_success = True
|
||||
except EmailSendingError:
|
||||
logger.warn(
|
||||
"Could not send email invitation (EmailSendingError)",
|
||||
|
@ -876,6 +1019,17 @@ class DomainAddUserView(DomainFormBaseView):
|
|||
exc_info=True,
|
||||
)
|
||||
messages.warning(self.request, "Could not send email invitation.")
|
||||
email_success = True
|
||||
except OutsideOrgMemberError:
|
||||
logger.warn(
|
||||
"Could not send email. Can not invite member of a .gov organization to a different organization.",
|
||||
self.object,
|
||||
exc_info=True,
|
||||
)
|
||||
messages.error(
|
||||
self.request,
|
||||
f"{requested_email} is already a member of another .gov organization.",
|
||||
)
|
||||
except Exception:
|
||||
logger.warn(
|
||||
"Could not send email invitation (Other Exception)",
|
||||
|
@ -883,17 +1037,17 @@ class DomainAddUserView(DomainFormBaseView):
|
|||
exc_info=True,
|
||||
)
|
||||
messages.warning(self.request, "Could not send email invitation.")
|
||||
if email_success:
|
||||
try:
|
||||
UserDomainRole.objects.create(
|
||||
user=requested_user,
|
||||
domain=self.object,
|
||||
role=UserDomainRole.Roles.MANAGER,
|
||||
)
|
||||
messages.success(self.request, f"Added user {requested_email}.")
|
||||
except IntegrityError:
|
||||
messages.warning(self.request, f"{requested_email} is already a manager for this domain")
|
||||
|
||||
try:
|
||||
UserDomainRole.objects.create(
|
||||
user=requested_user,
|
||||
domain=self.object,
|
||||
role=UserDomainRole.Roles.MANAGER,
|
||||
)
|
||||
except IntegrityError:
|
||||
messages.warning(self.request, f"{requested_email} is already a manager for this domain")
|
||||
else:
|
||||
messages.success(self.request, f"Added user {requested_email}.")
|
||||
return redirect(self.get_success_url())
|
||||
|
||||
|
||||
|
|
|
@ -458,8 +458,8 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
|||
"visited": self.storage.get("step_history", []),
|
||||
"is_federal": self.domain_request.is_federal(),
|
||||
"modal_button": modal_button,
|
||||
"modal_heading": "You are about to submit a domain request for "
|
||||
+ str(self.domain_request.requested_domain),
|
||||
"modal_heading": "You are about to submit a domain request for ",
|
||||
"domain_name_modal": str(self.domain_request.requested_domain),
|
||||
"modal_description": "Once you submit this request, you won’t be able to edit it until we review it.\
|
||||
You’ll only be able to withdraw your request.",
|
||||
"review_form_is_complete": True,
|
||||
|
|
|
@ -1,45 +1,41 @@
|
|||
from django.http import JsonResponse
|
||||
from django.core.paginator import Paginator
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Q
|
||||
from django.db.models import Value, F, CharField, TextField, Q, Case, When
|
||||
from django.db.models.functions import Concat, Coalesce
|
||||
from django.urls import reverse
|
||||
from django.db.models.functions import Cast
|
||||
|
||||
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||
from registrar.models.user import User
|
||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
||||
|
||||
|
||||
@login_required
|
||||
def get_portfolio_members_json(request):
|
||||
"""Given the current request,
|
||||
get all members that are associated with the given portfolio"""
|
||||
"""Fetch members (permissions and invitations) for the given portfolio."""
|
||||
|
||||
portfolio = request.GET.get("portfolio")
|
||||
member_ids = get_member_ids_from_request(request, portfolio)
|
||||
objects = User.objects.filter(id__in=member_ids)
|
||||
|
||||
admin_ids = UserPortfolioPermission.objects.filter(
|
||||
portfolio=portfolio,
|
||||
roles__overlap=[
|
||||
UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
|
||||
],
|
||||
).values_list("user__id", flat=True)
|
||||
portfolio_invitation_emails = PortfolioInvitation.objects.filter(portfolio=portfolio).values_list(
|
||||
"email", flat=True
|
||||
)
|
||||
# Two initial querysets which will be combined
|
||||
permissions = initial_permissions_search(portfolio)
|
||||
invitations = initial_invitations_search(portfolio)
|
||||
|
||||
unfiltered_total = objects.count()
|
||||
# Get total across both querysets before applying filters
|
||||
unfiltered_total = permissions.count() + invitations.count()
|
||||
|
||||
objects = apply_search(objects, request)
|
||||
# objects = apply_status_filter(objects, request)
|
||||
permissions = apply_search_term(permissions, request)
|
||||
invitations = apply_search_term(invitations, request)
|
||||
|
||||
# Union the two querysets
|
||||
objects = permissions.union(invitations)
|
||||
objects = apply_sorting(objects, request)
|
||||
|
||||
paginator = Paginator(objects, 10)
|
||||
page_number = request.GET.get("page", 1)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
members = [
|
||||
serialize_members(request, portfolio, member, request.user, admin_ids, portfolio_invitation_emails)
|
||||
for member in page_obj.object_list
|
||||
]
|
||||
|
||||
members = [serialize_members(request, portfolio, item, request.user) for item in page_obj.object_list]
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
|
@ -54,71 +50,121 @@ def get_portfolio_members_json(request):
|
|||
)
|
||||
|
||||
|
||||
def get_member_ids_from_request(request, portfolio):
|
||||
"""Given the current request,
|
||||
get all members that are associated with the given portfolio"""
|
||||
member_ids = []
|
||||
if portfolio:
|
||||
member_ids = UserPortfolioPermission.objects.filter(portfolio=portfolio).values_list("user__id", flat=True)
|
||||
return member_ids
|
||||
def initial_permissions_search(portfolio):
|
||||
"""Perform initial search for permissions before applying any filters."""
|
||||
permissions = UserPortfolioPermission.objects.filter(portfolio=portfolio)
|
||||
permissions = (
|
||||
permissions.select_related("user")
|
||||
.annotate(
|
||||
first_name=F("user__first_name"),
|
||||
last_name=F("user__last_name"),
|
||||
email_display=F("user__email"),
|
||||
last_active=Cast(F("user__last_login"), output_field=TextField()), # Cast last_login to text
|
||||
additional_permissions_display=F("additional_permissions"),
|
||||
member_display=Case(
|
||||
# If email is present and not blank, use email
|
||||
When(Q(user__email__isnull=False) & ~Q(user__email=""), then=F("user__email")),
|
||||
# If first name or last name is present, use concatenation of first_name + " " + last_name
|
||||
When(
|
||||
Q(user__first_name__isnull=False) | Q(user__last_name__isnull=False),
|
||||
then=Concat(
|
||||
Coalesce(F("user__first_name"), Value("")),
|
||||
Value(" "),
|
||||
Coalesce(F("user__last_name"), Value("")),
|
||||
),
|
||||
),
|
||||
# If neither, use an empty string
|
||||
default=Value(""),
|
||||
output_field=CharField(),
|
||||
),
|
||||
source=Value("permission", output_field=CharField()),
|
||||
)
|
||||
.values(
|
||||
"id",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"email_display",
|
||||
"last_active",
|
||||
"roles",
|
||||
"additional_permissions_display",
|
||||
"member_display",
|
||||
"source",
|
||||
)
|
||||
)
|
||||
return permissions
|
||||
|
||||
|
||||
def apply_search(queryset, request):
|
||||
search_term = request.GET.get("search_term")
|
||||
def initial_invitations_search(portfolio):
|
||||
"""Perform initial invitations search before applying any filters."""
|
||||
invitations = PortfolioInvitation.objects.filter(portfolio=portfolio)
|
||||
invitations = invitations.annotate(
|
||||
first_name=Value(None, output_field=CharField()),
|
||||
last_name=Value(None, output_field=CharField()),
|
||||
email_display=F("email"),
|
||||
last_active=Value("Invited", output_field=TextField()),
|
||||
additional_permissions_display=F("additional_permissions"),
|
||||
member_display=F("email"),
|
||||
source=Value("invitation", output_field=CharField()),
|
||||
).values(
|
||||
"id",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"email_display",
|
||||
"last_active",
|
||||
"roles",
|
||||
"additional_permissions_display",
|
||||
"member_display",
|
||||
"source",
|
||||
)
|
||||
return invitations
|
||||
|
||||
|
||||
def apply_search_term(queryset, request):
|
||||
"""Apply search term to the queryset."""
|
||||
search_term = request.GET.get("search_term", "").lower()
|
||||
if search_term:
|
||||
queryset = queryset.filter(
|
||||
Q(username__icontains=search_term)
|
||||
| Q(first_name__icontains=search_term)
|
||||
Q(first_name__icontains=search_term)
|
||||
| Q(last_name__icontains=search_term)
|
||||
| Q(email__icontains=search_term)
|
||||
| Q(email_display__icontains=search_term)
|
||||
)
|
||||
return queryset
|
||||
|
||||
|
||||
def apply_sorting(queryset, request):
|
||||
"""Apply sorting to the queryset."""
|
||||
sort_by = request.GET.get("sort_by", "id") # Default to 'id'
|
||||
order = request.GET.get("order", "asc") # Default to 'asc'
|
||||
|
||||
# Adjust sort_by to match the annotated fields in the unioned queryset
|
||||
if sort_by == "member":
|
||||
sort_by = ["email", "first_name", "middle_name", "last_name"]
|
||||
else:
|
||||
sort_by = [sort_by]
|
||||
|
||||
sort_by = "member_display"
|
||||
if order == "desc":
|
||||
sort_by = [f"-{field}" for field in sort_by]
|
||||
|
||||
return queryset.order_by(*sort_by)
|
||||
queryset = queryset.order_by(F(sort_by).desc())
|
||||
else:
|
||||
queryset = queryset.order_by(sort_by)
|
||||
return queryset
|
||||
|
||||
|
||||
def serialize_members(request, portfolio, member, user, admin_ids, portfolio_invitation_emails):
|
||||
# ------- VIEW ONLY
|
||||
# If not view_only (the user has permissions to edit/manage users), show the gear icon with "Manage" link.
|
||||
# If view_only (the user only has view user permissions), show the "View" link (no gear icon).
|
||||
# We check on user_group_permision to account for the upcoming "Manage portfolio" button on admin.
|
||||
user_can_edit_other_users = False
|
||||
for user_group_permission in ["registrar.full_access_permission", "registrar.change_user"]:
|
||||
if user.has_perm(user_group_permission):
|
||||
user_can_edit_other_users = True
|
||||
break
|
||||
def serialize_members(request, portfolio, item, user):
|
||||
# Check if the user can edit other users
|
||||
user_can_edit_other_users = any(
|
||||
user.has_perm(perm) for perm in ["registrar.full_access_permission", "registrar.change_user"]
|
||||
)
|
||||
|
||||
view_only = not user.has_edit_members_portfolio_permission(portfolio) or not user_can_edit_other_users
|
||||
|
||||
# ------- USER STATUSES
|
||||
is_invited = member.email in portfolio_invitation_emails
|
||||
last_active = "Invited" if is_invited else "Unknown"
|
||||
if member.last_login:
|
||||
last_active = member.last_login.strftime("%b. %d, %Y")
|
||||
is_admin = member.id in admin_ids
|
||||
is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in (item.get("roles") or [])
|
||||
action_url = reverse("member" if item["source"] == "permission" else "invitedmember", kwargs={"pk": item["id"]})
|
||||
|
||||
# ------- SERIALIZE
|
||||
# Serialize member data
|
||||
member_json = {
|
||||
"id": member.id,
|
||||
"name": member.get_formatted_name(),
|
||||
"email": member.email,
|
||||
"id": item.get("id", ""),
|
||||
"name": " ".join(filter(None, [item.get("first_name", ""), item.get("last_name", "")])),
|
||||
"email": item.get("email_display", ""),
|
||||
"member_display": item.get("member_display", ""),
|
||||
"is_admin": is_admin,
|
||||
"last_active": last_active,
|
||||
"action_url": "#", # reverse("members", kwargs={"pk": member.id}), # TODO: Future ticket?
|
||||
"last_active": item.get("last_active", ""),
|
||||
"action_url": action_url,
|
||||
"action_label": ("View" if view_only else "Manage"),
|
||||
"svg_icon": ("visibility" if view_only else "settings"),
|
||||
}
|
||||
|
|
|
@ -3,20 +3,30 @@ from django.http import Http404
|
|||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
from django.contrib import messages
|
||||
from registrar.forms.portfolio import PortfolioOrgAddressForm, PortfolioSeniorOfficialForm
|
||||
from registrar.forms.portfolio import (
|
||||
PortfolioInvitedMemberForm,
|
||||
PortfolioMemberForm,
|
||||
PortfolioOrgAddressForm,
|
||||
PortfolioSeniorOfficialForm,
|
||||
)
|
||||
from registrar.models import Portfolio, User
|
||||
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
from registrar.views.utility.permission_views import (
|
||||
PortfolioDomainRequestsPermissionView,
|
||||
PortfolioDomainsPermissionView,
|
||||
PortfolioBasePermissionView,
|
||||
NoPortfolioDomainsPermissionView,
|
||||
PortfolioInvitedMemberEditPermissionView,
|
||||
PortfolioInvitedMemberPermissionView,
|
||||
PortfolioMemberEditPermissionView,
|
||||
PortfolioMemberPermissionView,
|
||||
PortfolioMembersPermissionView,
|
||||
)
|
||||
from django.views.generic import View
|
||||
from django.views.generic.edit import FormMixin
|
||||
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -51,6 +61,155 @@ class PortfolioMembersView(PortfolioMembersPermissionView, View):
|
|||
return render(request, "portfolio_members.html")
|
||||
|
||||
|
||||
class PortfolioMemberView(PortfolioMemberPermissionView, View):
|
||||
|
||||
template_name = "portfolio_member.html"
|
||||
|
||||
def get(self, request, pk):
|
||||
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
|
||||
member = portfolio_permission.user
|
||||
|
||||
# We have to explicitely name these with member_ otherwise we'll have conflicts with context preprocessors
|
||||
member_has_view_all_requests_portfolio_permission = member.has_view_all_requests_portfolio_permission(
|
||||
portfolio_permission.portfolio
|
||||
)
|
||||
member_has_edit_request_portfolio_permission = member.has_edit_request_portfolio_permission(
|
||||
portfolio_permission.portfolio
|
||||
)
|
||||
member_has_view_members_portfolio_permission = member.has_view_members_portfolio_permission(
|
||||
portfolio_permission.portfolio
|
||||
)
|
||||
member_has_edit_members_portfolio_permission = member.has_edit_members_portfolio_permission(
|
||||
portfolio_permission.portfolio
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
{
|
||||
"edit_url": reverse("member-permissions", args=[pk]),
|
||||
"portfolio_permission": portfolio_permission,
|
||||
"member": member,
|
||||
"member_has_view_all_requests_portfolio_permission": member_has_view_all_requests_portfolio_permission,
|
||||
"member_has_edit_request_portfolio_permission": member_has_edit_request_portfolio_permission,
|
||||
"member_has_view_members_portfolio_permission": member_has_view_members_portfolio_permission,
|
||||
"member_has_edit_members_portfolio_permission": member_has_edit_members_portfolio_permission,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View):
|
||||
|
||||
template_name = "portfolio_member_permissions.html"
|
||||
form_class = PortfolioMemberForm
|
||||
|
||||
def get(self, request, pk):
|
||||
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
|
||||
user = portfolio_permission.user
|
||||
|
||||
form = self.form_class(instance=portfolio_permission)
|
||||
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
{
|
||||
"form": form,
|
||||
"member": user,
|
||||
},
|
||||
)
|
||||
|
||||
def post(self, request, pk):
|
||||
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
|
||||
user = portfolio_permission.user
|
||||
|
||||
form = self.form_class(request.POST, instance=portfolio_permission)
|
||||
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect("member", pk=pk)
|
||||
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
{
|
||||
"form": form,
|
||||
"member": user, # Pass the user object again to the template
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class PortfolioInvitedMemberView(PortfolioInvitedMemberPermissionView, View):
|
||||
|
||||
template_name = "portfolio_member.html"
|
||||
# form_class = PortfolioInvitedMemberForm
|
||||
|
||||
def get(self, request, pk):
|
||||
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
|
||||
# form = self.form_class(instance=portfolio_invitation)
|
||||
|
||||
# We have to explicitely name these with member_ otherwise we'll have conflicts with context preprocessors
|
||||
member_has_view_all_requests_portfolio_permission = (
|
||||
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS in portfolio_invitation.get_portfolio_permissions()
|
||||
)
|
||||
member_has_edit_request_portfolio_permission = (
|
||||
UserPortfolioPermissionChoices.EDIT_REQUESTS in portfolio_invitation.get_portfolio_permissions()
|
||||
)
|
||||
member_has_view_members_portfolio_permission = (
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS in portfolio_invitation.get_portfolio_permissions()
|
||||
)
|
||||
member_has_edit_members_portfolio_permission = (
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS in portfolio_invitation.get_portfolio_permissions()
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
{
|
||||
"edit_url": reverse("invitedmember-permissions", args=[pk]),
|
||||
"portfolio_invitation": portfolio_invitation,
|
||||
"member_has_view_all_requests_portfolio_permission": member_has_view_all_requests_portfolio_permission,
|
||||
"member_has_edit_request_portfolio_permission": member_has_edit_request_portfolio_permission,
|
||||
"member_has_view_members_portfolio_permission": member_has_view_members_portfolio_permission,
|
||||
"member_has_edit_members_portfolio_permission": member_has_edit_members_portfolio_permission,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class PortfolioInvitedMemberEditView(PortfolioInvitedMemberEditPermissionView, View):
|
||||
|
||||
template_name = "portfolio_member_permissions.html"
|
||||
form_class = PortfolioInvitedMemberForm
|
||||
|
||||
def get(self, request, pk):
|
||||
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
|
||||
form = self.form_class(instance=portfolio_invitation)
|
||||
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
{
|
||||
"form": form,
|
||||
"invitation": portfolio_invitation,
|
||||
},
|
||||
)
|
||||
|
||||
def post(self, request, pk):
|
||||
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
|
||||
form = self.form_class(request.POST, instance=portfolio_invitation)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect("invitedmember", pk=pk)
|
||||
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
{
|
||||
"form": form,
|
||||
"invitation": portfolio_invitation, # Pass the user object again to the template
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class PortfolioNoDomainsView(NoPortfolioDomainsPermissionView, View):
|
||||
"""Some users have access to the underlying portfolio, but not any domains.
|
||||
This is a custom view which explains that to the user - and denotes who to contact.
|
||||
|
|
|
@ -512,7 +512,81 @@ class PortfolioMembersPermission(PortfolioBasePermission):
|
|||
up from the portfolio's primary key in self.kwargs["pk"]"""
|
||||
|
||||
portfolio = self.request.session.get("portfolio")
|
||||
if not self.request.user.has_view_members_portfolio_permission(portfolio):
|
||||
if not self.request.user.has_view_members_portfolio_permission(
|
||||
portfolio
|
||||
) and not self.request.user.has_edit_members_portfolio_permission(portfolio):
|
||||
return False
|
||||
|
||||
return super().has_permission()
|
||||
|
||||
|
||||
class PortfolioMemberPermission(PortfolioBasePermission):
|
||||
"""Permission mixin that allows access to portfolio member pages if user
|
||||
has access, otherwise 403"""
|
||||
|
||||
def has_permission(self):
|
||||
"""Check if this user has access to members for this portfolio.
|
||||
|
||||
The user is in self.request.user and the portfolio can be looked
|
||||
up from the portfolio's primary key in self.kwargs["pk"]"""
|
||||
|
||||
portfolio = self.request.session.get("portfolio")
|
||||
if not self.request.user.has_view_members_portfolio_permission(
|
||||
portfolio
|
||||
) and not self.request.user.has_edit_members_portfolio_permission(portfolio):
|
||||
return False
|
||||
|
||||
return super().has_permission()
|
||||
|
||||
|
||||
class PortfolioMemberEditPermission(PortfolioBasePermission):
|
||||
"""Permission mixin that allows access to portfolio member pages if user
|
||||
has access to edit, otherwise 403"""
|
||||
|
||||
def has_permission(self):
|
||||
"""Check if this user has access to members for this portfolio.
|
||||
|
||||
The user is in self.request.user and the portfolio can be looked
|
||||
up from the portfolio's primary key in self.kwargs["pk"]"""
|
||||
|
||||
portfolio = self.request.session.get("portfolio")
|
||||
if not self.request.user.has_edit_members_portfolio_permission(portfolio):
|
||||
return False
|
||||
|
||||
return super().has_permission()
|
||||
|
||||
|
||||
class PortfolioInvitedMemberPermission(PortfolioBasePermission):
|
||||
"""Permission mixin that allows access to portfolio invited member pages if user
|
||||
has access, otherwise 403"""
|
||||
|
||||
def has_permission(self):
|
||||
"""Check if this user has access to members for this portfolio.
|
||||
|
||||
The user is in self.request.user and the portfolio can be looked
|
||||
up from the portfolio's primary key in self.kwargs["pk"]"""
|
||||
|
||||
portfolio = self.request.session.get("portfolio")
|
||||
if not self.request.user.has_view_members_portfolio_permission(
|
||||
portfolio
|
||||
) and not self.request.user.has_edit_members_portfolio_permission(portfolio):
|
||||
return False
|
||||
|
||||
return super().has_permission()
|
||||
|
||||
|
||||
class PortfolioInvitedMemberEditPermission(PortfolioBasePermission):
|
||||
"""Permission mixin that allows access to portfolio invited member pages if user
|
||||
has access to edit, otherwise 403"""
|
||||
|
||||
def has_permission(self):
|
||||
"""Check if this user has access to members for this portfolio.
|
||||
|
||||
The user is in self.request.user and the portfolio can be looked
|
||||
up from the portfolio's primary key in self.kwargs["pk"]"""
|
||||
|
||||
portfolio = self.request.session.get("portfolio")
|
||||
if not self.request.user.has_edit_members_portfolio_permission(portfolio):
|
||||
return False
|
||||
|
||||
return super().has_permission()
|
||||
|
|
|
@ -15,10 +15,14 @@ from .mixins import (
|
|||
DomainRequestWizardPermission,
|
||||
PortfolioDomainRequestsPermission,
|
||||
PortfolioDomainsPermission,
|
||||
PortfolioInvitedMemberEditPermission,
|
||||
PortfolioInvitedMemberPermission,
|
||||
PortfolioMemberEditPermission,
|
||||
UserDeleteDomainRolePermission,
|
||||
UserProfilePermission,
|
||||
PortfolioBasePermission,
|
||||
PortfolioMembersPermission,
|
||||
PortfolioMemberPermission,
|
||||
DomainRequestPortfolioViewonlyPermission,
|
||||
)
|
||||
import logging
|
||||
|
@ -253,7 +257,41 @@ class PortfolioDomainRequestsPermissionView(PortfolioDomainRequestsPermission, P
|
|||
|
||||
|
||||
class PortfolioMembersPermissionView(PortfolioMembersPermission, PortfolioBasePermissionView, abc.ABC):
|
||||
"""Abstract base view for portfolio domain request views that enforces permissions.
|
||||
"""Abstract base view for portfolio members views that enforces permissions.
|
||||
|
||||
This abstract view cannot be instantiated. Actual views must specify
|
||||
`template_name`.
|
||||
"""
|
||||
|
||||
|
||||
class PortfolioMemberPermissionView(PortfolioMemberPermission, PortfolioBasePermissionView, abc.ABC):
|
||||
"""Abstract base view for portfolio member views that enforces permissions.
|
||||
|
||||
This abstract view cannot be instantiated. Actual views must specify
|
||||
`template_name`.
|
||||
"""
|
||||
|
||||
|
||||
class PortfolioMemberEditPermissionView(PortfolioMemberEditPermission, PortfolioBasePermissionView, abc.ABC):
|
||||
"""Abstract base view for portfolio member edit views that enforces permissions.
|
||||
|
||||
This abstract view cannot be instantiated. Actual views must specify
|
||||
`template_name`.
|
||||
"""
|
||||
|
||||
|
||||
class PortfolioInvitedMemberPermissionView(PortfolioInvitedMemberPermission, PortfolioBasePermissionView, abc.ABC):
|
||||
"""Abstract base view for portfolio member views that enforces permissions.
|
||||
|
||||
This abstract view cannot be instantiated. Actual views must specify
|
||||
`template_name`.
|
||||
"""
|
||||
|
||||
|
||||
class PortfolioInvitedMemberEditPermissionView(
|
||||
PortfolioInvitedMemberEditPermission, PortfolioBasePermissionView, abc.ABC
|
||||
):
|
||||
"""Abstract base view for portfolio member edit views that enforces permissions.
|
||||
|
||||
This abstract view cannot be instantiated. Actual views must specify
|
||||
`template_name`.
|
||||
|
|
|
@ -1,75 +1,68 @@
|
|||
-i https://pypi.python.org/simple
|
||||
annotated-types==0.6.0; python_version >= '3.8'
|
||||
annotated-types==0.7.0; python_version >= '3.8'
|
||||
asgiref==3.8.1; python_version >= '3.8'
|
||||
boto3==1.34.95; python_version >= '3.8'
|
||||
botocore==1.34.95; python_version >= '3.8'
|
||||
cachetools==5.3.3; python_version >= '3.7'
|
||||
certifi==2024.2.2; python_version >= '3.6'
|
||||
boto3==1.35.41; python_version >= '3.8'
|
||||
botocore==1.35.41; python_version >= '3.8'
|
||||
cachetools==5.5.0; python_version >= '3.7'
|
||||
certifi==2024.8.30; python_version >= '3.6'
|
||||
cfenv==0.5.3
|
||||
cffi==1.16.0; platform_python_implementation != 'PyPy'
|
||||
charset-normalizer==3.3.2; python_full_version >= '3.7.0'
|
||||
cryptography==42.0.5; python_version >= '3.7'
|
||||
cffi==1.17.1; platform_python_implementation != 'PyPy'
|
||||
charset-normalizer==3.4.0; python_full_version >= '3.7.0'
|
||||
cryptography==43.0.1; python_version >= '3.7'
|
||||
defusedxml==0.7.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
|
||||
diff-match-patch==20230430; python_version >= '3.7'
|
||||
dj-database-url==2.1.0
|
||||
dj-database-url==2.2.0
|
||||
dj-email-url==1.0.6
|
||||
django==4.2.10; python_version >= '3.8'
|
||||
django-admin-multiple-choice-list-filter==0.1.1
|
||||
django-allow-cidr==0.7.1
|
||||
django-auditlog==3.0.0; python_version >= '3.8'
|
||||
django-cache-url==3.4.5
|
||||
django-cors-headers==4.3.1; python_version >= '3.8'
|
||||
django-cors-headers==4.5.0; python_version >= '3.9'
|
||||
django-csp==3.8
|
||||
django-fsm==2.8.1
|
||||
django-import-export==3.3.8; python_version >= '3.8'
|
||||
django-import-export==4.1.1; python_version >= '3.8'
|
||||
django-login-required-middleware==0.9.0
|
||||
django-phonenumber-field[phonenumberslite]==7.3.0; python_version >= '3.8'
|
||||
django-phonenumber-field[phonenumberslite]==8.0.0; python_version >= '3.8'
|
||||
django-waffle==4.1.0; python_version >= '3.8'
|
||||
django-widget-tweaks==1.5.0; python_version >= '3.8'
|
||||
environs[django]==11.0.0; python_version >= '3.8'
|
||||
et-xmlfile==1.1.0; python_version >= '3.6'
|
||||
faker==25.0.0; python_version >= '3.8'
|
||||
faker==30.3.0; python_version >= '3.8'
|
||||
fred-epplib@ git+https://github.com/cisagov/epplib.git@d56d183f1664f34c40ca9716a3a9a345f0ef561c
|
||||
furl==2.1.3
|
||||
future==1.0.0; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'
|
||||
gevent==24.2.1; python_version >= '3.8'
|
||||
greenlet==3.0.3; python_version >= '3.7'
|
||||
gunicorn==22.0.0; python_version >= '3.7'
|
||||
idna==3.7; python_version >= '3.5'
|
||||
gevent==24.10.2; python_version >= '3.9'
|
||||
greenlet==3.1.1; python_version >= '3.7'
|
||||
gunicorn==23.0.0; python_version >= '3.7'
|
||||
idna==3.10; python_version >= '3.6'
|
||||
jmespath==1.0.1; python_version >= '3.7'
|
||||
lxml==5.2.1; python_version >= '3.6'
|
||||
mako==1.3.3; python_version >= '3.8'
|
||||
markuppy==1.14
|
||||
markupsafe==2.1.5; python_version >= '3.7'
|
||||
marshmallow==3.21.1; python_version >= '3.8'
|
||||
odfpy==1.4.1
|
||||
lxml==5.3.0; python_version >= '3.6'
|
||||
mako==1.3.5; python_version >= '3.8'
|
||||
markupsafe==3.0.1; python_version >= '3.9'
|
||||
marshmallow==3.22.0; python_version >= '3.8'
|
||||
oic==1.7.0; python_version ~= '3.8'
|
||||
openpyxl==3.1.2
|
||||
orderedmultidict==1.0.1
|
||||
packaging==24.0; python_version >= '3.7'
|
||||
phonenumberslite==8.13.35
|
||||
packaging==24.1; python_version >= '3.8'
|
||||
phonenumberslite==8.13.47
|
||||
psycopg2-binary==2.9.9; python_version >= '3.7'
|
||||
pycparser==2.22; python_version >= '3.8'
|
||||
pycryptodomex==3.20.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
|
||||
pydantic==2.7.1; python_version >= '3.8'
|
||||
pydantic-core==2.18.2; python_version >= '3.8'
|
||||
pydantic-settings==2.2.1; python_version >= '3.8'
|
||||
pycryptodomex==3.21.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'
|
||||
pydantic==2.9.2; python_version >= '3.8'
|
||||
pydantic-core==2.23.4; python_version >= '3.8'
|
||||
pydantic-settings==2.5.2; python_version >= '3.8'
|
||||
pyjwkest==1.4.2
|
||||
python-dateutil==2.9.0.post0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
|
||||
python-dotenv==1.0.1; python_version >= '3.8'
|
||||
pyyaml==6.0.1
|
||||
pyzipper==0.3.6; python_version >= '3.4'
|
||||
requests==2.31.0; python_version >= '3.7'
|
||||
s3transfer==0.10.1; python_version >= '3.8'
|
||||
setuptools==69.5.1; python_version >= '3.8'
|
||||
requests==2.32.3; python_version >= '3.8'
|
||||
s3transfer==0.10.3; python_version >= '3.8'
|
||||
setuptools==75.1.0; python_version >= '3.8'
|
||||
six==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
|
||||
sqlparse==0.5.0; python_version >= '3.8'
|
||||
sqlparse==0.5.1; python_version >= '3.8'
|
||||
tablib[html,ods,xls,xlsx,yaml]==3.5.0; python_version >= '3.8'
|
||||
tblib==3.0.0; python_version >= '3.8'
|
||||
typing-extensions==4.11.0; python_version >= '3.8'
|
||||
urllib3==2.2.1; python_version >= '3.8'
|
||||
whitenoise==6.6.0; python_version >= '3.8'
|
||||
xlrd==2.0.1
|
||||
xlwt==1.3.0
|
||||
typing-extensions==4.12.2; python_version >= '3.8'
|
||||
urllib3==2.2.3; python_version >= '3.8'
|
||||
whitenoise==6.7.0; python_version >= '3.8'
|
||||
zope.event==5.0; python_version >= '3.7'
|
||||
zope.interface==6.3; python_version >= '3.7'
|
||||
zope.interface==7.1.0; python_version >= '3.8'
|
||||
|
|
|
@ -71,6 +71,7 @@
|
|||
10038 OUTOFSCOPE http://app:8080/domain_requests/
|
||||
10038 OUTOFSCOPE http://app:8080/domains/
|
||||
10038 OUTOFSCOPE http://app:8080/organization/
|
||||
10038 OUTOFSCOPE http://app:8080/permissions
|
||||
10038 OUTOFSCOPE http://app:8080/suborganization/
|
||||
10038 OUTOFSCOPE http://app:8080/transfer/
|
||||
# This URL always returns 404, so include it as well.
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue