Merge branch 'main' into za/2771-create-requesting-entity-page

This commit is contained in:
zandercymatics 2024-10-23 13:15:48 -06:00
commit ab7a6ac12d
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
64 changed files with 3216 additions and 1395 deletions

View file

@ -1,11 +1,7 @@
## Ticket ## Ticket
<!-- PR title format: `#issue_number: Descriptive name ideally matching ticket name - [sandbox]`-->
Resolves #00 Resolves #00
<!--Reminder, when a code change is made that is user facing, beyond content updates, then the following are required:
- a developer approves the PR
- a designer approves the PR or checks off all relevant items in this checklist.
All other changes require just a single approving review.-->
## Changes ## Changes
@ -45,82 +41,63 @@ All other changes require just a single approving review.-->
- [ ] Met the acceptance criteria, or will meet them in a subsequent PR - [ ] Met the acceptance criteria, or will meet them in a subsequent PR
- [ ] Created/modified automated tests - [ ] Created/modified automated tests
- [ ] Added at least 2 developers as PR reviewers (only 1 will need to approve) - [ ] Update documentation in READMEs and/or onboarding guide
- [ ] Messaged on Slack or in standup to notify the team that a PR is ready for review
- [ ] Changes to “how we do things” are documented in READMEs and or onboarding guide
- [ ] If any model was updated to modify/add/delete columns, makemigrations was ran and the associated migrations file has been commited.
#### Ensured code standards are met (Original Developer) #### Ensured code standards are met (Original Developer)
<!-- Mark "- N/A" and check at the end of each check that is not applicable to your PR -->
- [ ] All new functions and methods are commented using plain language - [ ] If any updated dependencies on Pipfile, also update dependencies in requirements.txt.
- [ ] Did dependency updates in Pipfile also get changed in requirements.txt?
- [ ] Interactions with external systems are wrapped in try/except - [ ] Interactions with external systems are wrapped in try/except
- [ ] Error handling exists for unusual or missing values - [ ] Error handling exists for unusual or missing values
#### Validated user-facing changes (if applicable) #### Validated user-facing changes (if applicable)
- [ ] New pages have been added to .pa11yci file so that they will be tested with our automated accessibility testing - [ ] Tag @dotgov-designers in this PR's Reviewers for design review. If code is not user-facing, delete design reviewer checklist
- [ ] Verify new pages have been added to .pa11yci file so that they will be tested with our automated accessibility testing
- [ ] Checked keyboard navigability - [ ] Checked keyboard navigability
- [ ] Tested general usability, landmarks, page header structure, and links with a screen reader (such as Voiceover or ANDI) - [ ] Tested general usability, landmarks, page header structure, and links with a screen reader (such as Voiceover or ANDI)
- [ ] Add at least 1 designer as PR reviewer
### As a code reviewer, I have ### As a code reviewer, I have
#### Reviewed, tested, and left feedback about the changes #### Reviewed, tested, and left feedback about the changes
- [ ] Pulled this branch locally and tested it - [ ] Pulled this branch locally and tested it
- [ ] Reviewed this code and left comments - [ ] Verified code meets all checks above. Address any checks that are not satisfied
- [ ] Reviewed this code and left comments. Indicate if comments must be addressed before code is merged
- [ ] Checked that all code is adequately covered by tests - [ ] Checked that all code is adequately covered by tests
- [ ] Made it clear which comments need to be addressed before this work is merged - [ ] Verify migrations are valid and do not conflict with existing migrations
- [ ] If any model was updated to modify/add/delete columns, makemigrations was ran and the associated migrations file has been commited.
#### Ensured code standards are met (Code reviewer)
- [ ] All new functions and methods are commented using plain language
- [ ] Interactions with external systems are wrapped in try/except
- [ ] Error handling exists for unusual or missing values
- [ ] (Rarely needed) Did dependency updates in Pipfile also get changed in requirements.txt?
#### Validated user-facing changes as a developer #### Validated user-facing changes as a developer
**Note:** Multiple code reviewers can share the checklists above, a second reviewer should not make a duplicate checklist. All checks should be checked before approving, even those labeled N/A.
- [ ] New pages have been added to .pa11yci file so that they will be tested with our automated accessibility testing - [ ] New pages have been added to .pa11yci file so that they will be tested with our automated accessibility testing
- [ ] Checked keyboard navigability - [ ] Checked keyboard navigability
- [ ] Meets all designs and user flows provided by design/product - [ ] Meets all designs and user flows provided by design/product
- [ ] Tested general usability, landmarks, page header structure, and links with a screen reader (such as Voiceover or ANDI) - [ ] Tested general usability, landmarks, page header structure, and links with a screen reader (such as Voiceover or ANDI)
- [ ] Tested with multiple browsers, the suggestion is to use ones that the developer didn't (check off which ones were used)
- [ ] Chrome
- [ ] Microsoft Edge
- [ ] FireFox
- [ ] Safari
- [ ] (Rarely needed) Tested as both an analyst and applicant user - [ ] (Rarely needed) Tested as both an analyst and applicant user
**Note:** Multiple code reviewers can share the checklists above, a second reviewers should not make a duplicate checklist
### As a designer reviewer, I have ### As a designer reviewer, I have
#### Verified that the changes match the design intention #### Verified that the changes match the design intention
- [ ] Checked that the design translated visually - [ ] Checked that the design translated visually
- [ ] Checked behavior - [ ] Checked behavior. Comment any found issues or broken flows.
- [ ] Checked different states (empty, one, some, error) - [ ] Checked different states (empty, one, some, error)
- [ ] Checked for landmarks, page heading structure, and links - [ ] Checked for landmarks, page heading structure, and links
- [ ] Tried to break the intended flow
#### Validated user-facing changes as a designer #### Validated user-facing changes as a designer
- [ ] Checked keyboard navigability - [ ] Checked keyboard navigability
- [ ] Tested general usability, landmarks, page header structure, and links with a screen reader (such as Voiceover or ANDI) - [ ] Tested general usability, landmarks, page header structure, and links with a screen reader (such as Voiceover or ANDI)
- [ ] Tested with multiple browsers (check off which ones were used) - [ ] Tested with multiple browsers (check off which ones were used)
- [ ] Chrome - [ ] Chrome
- [ ] Microsoft Edge - [ ] Microsoft Edge
- [ ] FireFox - [ ] FireFox
- [ ] Safari - [ ] Safari
- [ ] (Rarely needed) Tested as both an analyst and applicant user - [ ] (Rarely needed) Tested as both an analyst and applicant user
### References
- [Code review best practices](../docs/dev-practices/code_review.md)
## Screenshots ## Screenshots
<!-- If this PR makes visible interface changes, an image of the finished interface can help reviewers <!-- If this PR makes visible interface changes, an image of the finished interface can help reviewers

View file

@ -14,17 +14,6 @@ There are a handful of things we do not commit to the repository:
For developers, you can auto-deploy your code to your sandbox (if applicable) by naming your branch thusly: jsd/123-feature-description For developers, you can auto-deploy your code to your sandbox (if applicable) by naming your branch thusly: jsd/123-feature-description
Where 'jsd' stands for your initials and sandbox environment name (if you were called John Smith Doe), and 123 matches the ticket number if applicable. Where 'jsd' stands for your initials and sandbox environment name (if you were called John Smith Doe), and 123 matches the ticket number if applicable.
## Approvals
When a code change is made that is not user facing, then the following is required:
- a developer approves the PR
When a code change is made that is user facing, beyond content updates, then the following are required:
- a developer approves the PR
- a designer approves the PR or checks off all relevant items in this checklist
Content or document updates require a single person to approve.
## Project Management ## Project Management
We use [Github Projects](https://docs.github.com/en/issues/planning-and-tracking-with-projects/learning-about-projects/about-projects) for project management and tracking. We use [Github Projects](https://docs.github.com/en/issues/planning-and-tracking-with-projects/learning-about-projects/about-projects) for project management and tracking.
@ -39,14 +28,6 @@ Every issue in this respository and on the project board should be appropriately
We also have labels for each discipline and for research and project management related tasks. While this repository and project board track development work, we try to document all work related to the project here as well. We also have labels for each discipline and for research and project management related tasks. While this repository and project board track development work, we try to document all work related to the project here as well.
## Pull request etiquette
- The submitter is in charge of merging their PRs unless the approver is given explicit permission.
- Do not commit to another person's branch unless given explicit permission.
- Keep pull requests as small as possible. This makes them easier to review and track changes.
- Bias towards approving i.e. "good to merge once X is fixed" rather than blocking until X is fixed, requiring an additional review.
- Write descriptive pull requests. This is not only something that makes it easier to review, but is a great source of documentation.
## Branch Naming ## Branch Naming
Our branch naming convention is `name/topic-or-feature`, for example: `lmm/add-contributing-doc`. Our branch naming convention is `name/issue_no-description`, for example: `lmm/1234-add-contributing-doc`.

View file

@ -0,0 +1,31 @@
## Code Review
Pull requests should be titled in the format of `#issue_number: Descriptive name ideally matching ticket name - [sandbox]`
Pull requests including a migration should be suffixed with ` - MIGRATION`
After creating a pull request, pull request submitters should:
- Add at least 2 developers as PR reviewers (only 1 will need to approve).
- Message on Slack or in standup to notify the team that a PR is ready for review.
- If any model was updated to modify/add/delete columns, run makemigrations and commit the associated migrations file.
## Pull request approvals
Code changes on user-facing features (excluding content updates) require approval from at least one developer and one designer.
All other changes require a single approving review.
The submitter is responsible for merging their PR unless the approver is given explicit permission. Similarly, do not commit to another person's branch unless given explicit permission.
Bias towards approving i.e. "good to merge once X is fixed" rather than blocking until X is fixed, requiring an additional review.
## Pull Requests for User-facing changes
When making or reviewing user-facing changes, test that your changes work on multiple browsers including Chrome, Microsoft Edge, Firefox, and Safari.
Add new pages to the .pa11yci file so they are included in our automated accessibility testing.
## Other Pull request norms
- Keep pull requests as small as possible. This makes them easier to review and track changes.
- Write descriptive pull requests. This is not only something that makes it easier to review, but is a great source of documentation.
## Coding standards
### Plain language
All functions and methods should use plain language.

2009
src/Pipfile.lock generated

File diff suppressed because it is too large Load diff

View file

@ -25,7 +25,7 @@ services:
# Run Django in debug mode on local # Run Django in debug mode on local
- DJANGO_DEBUG=True - DJANGO_DEBUG=True
# Set DJANGO_LOG_LEVEL in env # Set DJANGO_LOG_LEVEL in env
- DJANGO_LOG_LEVEL - DJANGO_LOG_LEVEL=DEBUG
# Run Django without production flags # Run Django without production flags
- IS_PRODUCTION=False - IS_PRODUCTION=False
# Tell Django where it is being hosted # Tell Django where it is being hosted

View file

@ -190,11 +190,11 @@ class PortfolioInvitationAdminForm(UserChangeForm):
model = models.PortfolioInvitation model = models.PortfolioInvitation
fields = "__all__" fields = "__all__"
widgets = { widgets = {
"portfolio_roles": FilteredSelectMultipleArrayWidget( "roles": FilteredSelectMultipleArrayWidget(
"portfolio_roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices "roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices
), ),
"portfolio_additional_permissions": FilteredSelectMultipleArrayWidget( "additional_permissions": FilteredSelectMultipleArrayWidget(
"portfolio_additional_permissions", "additional_permissions",
is_stacked=False, is_stacked=False,
choices=UserPortfolioPermissionChoices.choices, choices=UserPortfolioPermissionChoices.choices,
), ),
@ -1409,8 +1409,8 @@ class PortfolioInvitationAdmin(ListHeaderAdmin):
list_display = [ list_display = [
"email", "email",
"portfolio", "portfolio",
"portfolio_roles", "roles",
"portfolio_additional_permissions", "additional_permissions",
"status", "status",
] ]
@ -2473,7 +2473,10 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
generic_org_type.admin_order_field = "domain_info__generic_org_type" # type: ignore generic_org_type.admin_order_field = "domain_info__generic_org_type" # type: ignore
def federal_agency(self, obj): def federal_agency(self, obj):
return obj.domain_info.federal_agency if obj.domain_info else None if obj.domain_info:
return obj.domain_info.federal_agency
else:
return None
federal_agency.admin_order_field = "domain_info__federal_agency" # type: ignore federal_agency.admin_order_field = "domain_info__federal_agency" # type: ignore

View file

@ -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>`; 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) // 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 // If the request is not deletable, use the following (hidden) span for ANDI screenreaders to indicate this state to the end user
let modalTrigger = ''; let modalTrigger = `
<span class="usa-sr-only">Domain request cannot be deleted now. Edit the request for more information.</span>`;
let markupCreatorRow = ''; 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 (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 modalHeading = '';
let modalDescription = ''; let modalDescription = '';
@ -1882,11 +1883,10 @@ class MembersTable extends LoadTableBase {
* @param {*} sortBy - the sort column option * @param {*} sortBy - the sort column option
* @param {*} order - the sort order {asc, desc} * @param {*} order - the sort order {asc, desc}
* @param {*} scroll - control for the scrollToElement functionality * @param {*} scroll - control for the scrollToElement functionality
* @param {*} status - control for the status filter
* @param {*} searchTerm - the search term * @param {*} searchTerm - the search term
* @param {*} portfolio - the portfolio id * @param {*} portfolio - the portfolio id
*/ */
loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) { loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) {
// --------- SEARCH // --------- SEARCH
let searchParams = new URLSearchParams( let searchParams = new URLSearchParams(
@ -1894,7 +1894,6 @@ class MembersTable extends LoadTableBase {
"page": page, "page": page,
"sort_by": sortBy, "sort_by": sortBy,
"order": order, "order": order,
"status": status,
"search_term": searchTerm "search_term": searchTerm
} }
); );
@ -1930,11 +1929,40 @@ class MembersTable extends LoadTableBase {
const memberList = document.querySelector('.members__table tbody'); const memberList = document.querySelector('.members__table tbody');
memberList.innerHTML = ''; memberList.innerHTML = '';
const invited = 'Invited';
data.members.forEach(member => { data.members.forEach(member => {
// const actionUrl = domain.action_url;
const member_name = member.name; const member_name = member.name;
const member_email = member.email; const member_display = member.member_display;
const last_active = member.last_active; const options = { year: 'numeric', month: 'short', day: 'numeric' };
// Handle last_active values
let last_active = member.last_active;
let last_active_formatted = '';
let last_active_sort_value = '';
// Handle 'Invited' or null/empty values differently from valid dates
if (last_active && last_active !== invited) {
try {
// Try to parse the last_active as a valid date
last_active = new Date(last_active);
if (!isNaN(last_active)) {
last_active_formatted = last_active.toLocaleDateString('en-US', options);
last_active_sort_value = last_active.getTime(); // For sorting purposes
} else {
last_active_formatted='Invalid date'
}
} catch (e) {
console.error(`Error parsing date: ${last_active}. Error: ${e}`);
last_active_formatted='Invalid date'
}
} else {
// Handle 'Invited' or null
last_active = invited;
last_active_formatted = invited;
last_active_sort_value = invited; // Keep 'Invited' as a sortable string
}
const action_url = member.action_url; const action_url = member.action_url;
const action_label = member.action_label; const action_label = member.action_label;
const svg_icon = member.svg_icon; const svg_icon = member.svg_icon;
@ -1947,10 +1975,10 @@ class MembersTable extends LoadTableBase {
row.innerHTML = ` row.innerHTML = `
<th scope="row" role="rowheader" data-label="member email"> <th scope="row" role="rowheader" data-label="member email">
${member_email ? member_email : member_name} ${admin_tagHTML} ${member_display} ${admin_tagHTML}
</th> </th>
<td data-sort-value="${last_active}" data-label="last_active"> <td data-sort-value="${last_active_sort_value}" data-label="last_active">
${last_active} ${last_active_formatted}
</td> </td>
<td> <td>
<a href="${action_url}"> <a href="${action_url}">

View file

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

View file

@ -898,3 +898,10 @@ ul.add-list-reset {
font-weight: 600; font-weight: 600;
font-size: .8125rem; font-size: .8125rem;
} }
.change-form .usa-table {
td {
color: inherit !important;
background-color: transparent !important;
}
}

View file

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

View file

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

View file

@ -476,8 +476,10 @@ class JsonServerFormatter(ServerFormatter):
def format(self, record): def format(self, record):
formatted_record = super().format(record) formatted_record = super().format(record)
if not hasattr(record, "server_time"): if not hasattr(record, "server_time"):
record.server_time = self.formatTime(record, self.datefmt) record.server_time = self.formatTime(record, self.datefmt)
log_entry = {"server_time": record.server_time, "level": record.levelname, "message": formatted_record} log_entry = {"server_time": record.server_time, "level": record.levelname, "message": formatted_record}
return json.dumps(log_entry) return json.dumps(log_entry)

View file

@ -86,6 +86,26 @@ urlpatterns = [
views.PortfolioMembersView.as_view(), views.PortfolioMembersView.as_view(),
name="members", name="members",
), ),
path(
"member/<int:pk>",
views.PortfolioMemberView.as_view(),
name="member",
),
path(
"member/<int:pk>/permissions",
views.PortfolioMemberEditView.as_view(),
name="member-permissions",
),
path(
"invitedmember/<int:pk>",
views.PortfolioInvitedMemberView.as_view(),
name="invitedmember",
),
path(
"invitedmember/<int:pk>/permissions",
views.PortfolioInvitedMemberEditView.as_view(),
name="invitedmember-permissions",
),
# path( # path(
# "no-organization-members/", # "no-organization-members/",
# views.PortfolioNoMembersView.as_view(), # views.PortfolioNoMembersView.as_view(),

View file

@ -1,4 +1,5 @@
import logging import logging
import random
from faker import Faker from faker import Faker
from django.db import transaction from django.db import transaction
@ -7,7 +8,7 @@ from registrar.fixtures.fixtures_users import UserFixture
from registrar.models import User from registrar.models import User
from registrar.models.portfolio import Portfolio from registrar.models.portfolio import Portfolio
from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
fake = Faker() fake = Faker()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -51,22 +52,24 @@ class UserPortfolioPermissionFixture:
user_portfolio_permissions_to_create = [] user_portfolio_permissions_to_create = []
for user in users: for user in users:
for portfolio in portfolios: # Assign a random portfolio to a user
try: portfolio = random.choice(portfolios) # nosec
if not UserPortfolioPermission.objects.filter(user=user, portfolio=portfolio).exists(): try:
user_portfolio_permission = UserPortfolioPermission( if not UserPortfolioPermission.objects.filter(user=user, portfolio=portfolio).exists():
user=user, user_portfolio_permission = UserPortfolioPermission(
portfolio=portfolio, user=user,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], portfolio=portfolio,
) roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
user_portfolio_permissions_to_create.append(user_portfolio_permission) additional_permissions=[UserPortfolioPermissionChoices.EDIT_MEMBERS],
else: )
logger.info( user_portfolio_permissions_to_create.append(user_portfolio_permission)
f"Permission exists for user '{user.username}' " else:
f"on portfolio '{portfolio.organization_name}'." logger.info(
) f"Permission exists for user '{user.username}' "
except Exception as e: f"on portfolio '{portfolio.organization_name}'."
logger.warning(e) )
except Exception as e:
logger.warning(e)
# Bulk create permissions # Bulk create permissions
cls._bulk_create_permissions(user_portfolio_permissions_to_create) cls._bulk_create_permissions(user_portfolio_permissions_to_create)

View file

@ -137,6 +137,20 @@ class UserFixture:
"email": "annagingle@truss.works", "email": "annagingle@truss.works",
"title": "Sweetwater sailor", "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 = [ STAFF = [
@ -231,6 +245,18 @@ class UserFixture:
"last_name": "Gingle-Analyst", "last_name": "Gingle-Analyst",
"email": "annagingle+analyst@truss.works", "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. # Additional emails to add to the AllowedEmail whitelist.

View file

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

View file

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

View file

@ -151,6 +151,7 @@ class OrganizationTypeForm(RegistrarForm):
choices=DomainRequest.OrganizationChoicesVerbose.choices, choices=DomainRequest.OrganizationChoicesVerbose.choices,
widget=forms.RadioSelect, widget=forms.RadioSelect,
error_messages={"required": "Select the type of organization you represent."}, 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( federal_type = forms.ChoiceField(
choices=BranchChoices.choices, choices=BranchChoices.choices,
widget=forms.RadioSelect, 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.")}, error_messages={"required": ("Select the part of the federal government your organization is in.")},
) )
@ -205,7 +207,8 @@ class OrganizationElectionForm(RegistrarForm):
(True, "Yes"), (True, "Yes"),
(False, "No"), (False, "No"),
], ],
) ),
label="Is your organization an election office?",
) )
def clean_is_election_board(self): def clean_is_election_board(self):
@ -261,10 +264,10 @@ class OrganizationContactForm(RegistrarForm):
validators=[ validators=[
RegexValidator( RegexValidator(
"^[0-9]{5}(?:-[0-9]{4})?$|^$", "^[0-9]{5}(?:-[0-9]{4})?$|^$",
message="Enter a zip code in the form of 12345 or 12345-6789.", message="Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.",
) )
], ],
error_messages={"required": ("Enter a zip code in the form of 12345 or 12345-6789.")}, error_messages={"required": ("Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.")},
) )
urbanization = forms.CharField( urbanization = forms.CharField(
required=False, required=False,
@ -350,7 +353,10 @@ class SeniorOfficialForm(RegistrarForm):
email = forms.EmailField( email = forms.EmailField(
label="Email", label="Email",
max_length=None, max_length=None,
error_messages={"invalid": ("Enter an email address in the required format, like name@example.com.")}, error_messages={
"invalid": ("Enter an email address in the required format, like name@example.com."),
"required": ("Enter an email address in the required format, like name@example.com."),
},
validators=[ validators=[
MaxLengthValidator( MaxLengthValidator(
320, 320,
@ -564,6 +570,7 @@ class OtherContactsForm(RegistrarForm):
message="Response must be less than 320 characters.", message="Response must be less than 320 characters.",
) )
], ],
help_text="Enter an email address in the required format, like name@example.com.",
) )
phone = PhoneNumberField( phone = PhoneNumberField(
label="Phone", label="Phone",
@ -727,7 +734,8 @@ class CisaRepresentativeForm(BaseDeletableRegistrarForm):
max_length=None, max_length=None,
required=False, required=False,
error_messages={ error_messages={
"invalid": ("Enter your representatives email address in the required format, like name@example.com."), "invalid": ("Enter an email address in the required format, like name@example.com."),
"required": ("Enter an email address in the required format, like name@example.com."),
}, },
validators=[ validators=[
MaxLengthValidator( MaxLengthValidator(

View file

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

View file

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

View file

@ -0,0 +1,23 @@
# Generated by Django 4.2.10 on 2024-10-11 19:58
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("registrar", "0133_domainrequest_rejection_reason_email_and_more"),
]
operations = [
migrations.RenameField(
model_name="portfolioinvitation",
old_name="portfolio_additional_permissions",
new_name="additional_permissions",
),
migrations.RenameField(
model_name="portfolioinvitation",
old_name="portfolio_roles",
new_name="roles",
),
]

View file

@ -849,7 +849,6 @@ class DomainRequest(TimeStampedModel):
if custom_email_content: if custom_email_content:
context["custom_email_content"] = custom_email_content context["custom_email_content"] = custom_email_content
send_templated_email( send_templated_email(
email_template, email_template,
email_template_subject, email_template_subject,
@ -895,7 +894,6 @@ class DomainRequest(TimeStampedModel):
DraftDomain = apps.get_model("registrar.DraftDomain") DraftDomain = apps.get_model("registrar.DraftDomain")
if not DraftDomain.string_could_be_domain(self.requested_domain.name): if not DraftDomain.string_could_be_domain(self.requested_domain.name):
raise ValueError("Requested domain is not a valid 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 the domain has not been submitted before this must be the first time
if not self.first_submitted_date: if not self.first_submitted_date:
self.first_submitted_date = timezone.now().date() self.first_submitted_date = timezone.now().date()

View file

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

View file

@ -1,5 +1,6 @@
from django.db import models from django.db import models
from django.forms import ValidationError from django.forms import ValidationError
from registrar.models.user_domain_role import UserDomainRole
from registrar.utility.waffle import flag_is_active_for_user from registrar.utility.waffle import flag_is_active_for_user
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from .utility.time_stamped_model import TimeStampedModel from .utility.time_stamped_model import TimeStampedModel
@ -79,6 +80,14 @@ class UserPortfolioPermission(TimeStampedModel):
) )
return readable_roles return readable_roles
def get_managed_domains_count(self):
"""Return the count of domains managed by the user for this portfolio."""
# Filter the UserDomainRole model to get domains where the user has a manager role
managed_domains = UserDomainRole.objects.filter(
user=self.user, role=UserDomainRole.Roles.MANAGER, domain__domain_info__portfolio=self.portfolio
).count()
return managed_domains
def _get_portfolio_permissions(self): def _get_portfolio_permissions(self):
""" """
Retrieve the permissions for the user's portfolio roles. Retrieve the permissions for the user's portfolio roles.
@ -99,16 +108,6 @@ class UserPortfolioPermission(TimeStampedModel):
"""Extends clean method to perform additional validation, which can raise errors in django admin.""" """Extends clean method to perform additional validation, which can raise errors in django admin."""
super().clean() super().clean()
# 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. # Check if portfolio is set without accessing the related object.
has_portfolio = bool(self.portfolio_id) has_portfolio = bool(self.portfolio_id)
if not has_portfolio and self._get_portfolio_permissions(): 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(): if has_portfolio and not self._get_portfolio_permissions():
raise ValidationError("When portfolio is assigned, portfolio roles or additional permissions are required.") 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."
)

View file

@ -2,23 +2,21 @@
{% load static url_helpers %} {% load static url_helpers %}
{% block detail_content %} {% block detail_content %}
<table> <table class="usa-table">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th data-sortable scope="col" role="columnheader">Name</th>
<th>Status</th> <th data-sortable scope="col" role="columnheader">
Status
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for domain_request in domain_requests %} {% for domain_request in domain_requests %}
{% url 'admin:registrar_domainrequest_change' domain_request.pk as url %} {% url 'admin:registrar_domainrequest_change' domain_request.pk as url %}
<tr> <tr>
<td><a href={{url}}>{{ domain_request }}</a></td> <td data-sort-value="{{ domain_request }}"> <a href={{url}}>{{ domain_request }}</a></td>
{% if domain_request.get_status_display %} <td data-sort-value="{{ domain_request.get_status_display}}"> {{ domain_request.get_status_display|default:"None" }} </td>
<td>{{ domain_request.get_status_display }}</td>
{% else %}
<td>None</td>
{% endif %}
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View file

@ -2,11 +2,11 @@
{% load static url_helpers %} {% load static url_helpers %}
{% block detail_content %} {% block detail_content %}
<table> <table class="usa-table">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th data-sortable scope="col" role="columnheader">Name</th>
<th>State</th> <th data-sortable scope="col" role="columnheader">State</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -15,11 +15,11 @@
{% with domain=domain_info.domain %} {% with domain=domain_info.domain %}
{% url 'admin:registrar_domain_change' domain.pk as url %} {% url 'admin:registrar_domain_change' domain.pk as url %}
<tr> <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 %} {% 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 %} {% else %}
<td>None</td> <td data-sort-value="None"> None</td>
{% endif %} {% endif %}
</tr> </tr>
{% endwith %} {% endwith %}

View file

@ -4,11 +4,31 @@
{% block title %}Add a domain manager | {% endblock %} {% block title %}Add a domain manager | {% endblock %}
{% block domain_content %} {% 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> <h1>Add a domain manager</h1>
{% if has_organization_feature_flag %}
<p>You can add another user to help manage your domain. They will need to sign <p>
in to the .gov registrar with their Login.gov account. 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> </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> <form class="usa-form usa-form--large" method="post" novalidate>
{% csrf_token %} {% csrf_token %}

View file

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

View file

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

View file

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

View file

@ -2,7 +2,7 @@
{% load field_helpers %} {% load field_helpers %}
{% block form_instructions %} {% 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? Which federal branch is your organization in?
</h2> </h2>
{% endblock %} {% endblock %}

View file

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

View file

@ -8,8 +8,7 @@
<p> <p>
Domain managers can update all information related to a domain within the Domain managers can update all information related to a domain within the
.gov registrar, including contact details, senior official, security .gov registrar, including security email and DNS name servers.
email, and DNS name servers.
</p> </p>
<ul class="usa-list"> <ul class="usa-list">
@ -17,7 +16,8 @@
<li>After adding a domain manager, an email invitation will be sent to that user with <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> 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>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 cant remove yourself as a domain manager if youre 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 cant remove yourself as a domain manager if youre the only one assigned to this domain.</li>
</ul> </ul>
{% if domain.permissions %} {% if domain.permissions %}

View 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?
Youre listed as a domain manager for {{domain}}, so youll 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 %}

View file

@ -0,0 +1 @@
An update was made to {{domain}}

View file

@ -93,12 +93,12 @@
</li> </li>
{% endif %} {% endif %}
{% if has_organization_members_flag and has_view_members_portfolio_permission %} {% if has_organization_members_flag %}
<li class="usa-nav__primary-item"> <li class="usa-nav__primary-item">
<a href="/members/" class="usa-nav-link {% if path|is_members_subpage %} usa-current{% endif %}"> <a href="/members/" class="usa-nav-link {% if path|is_members_subpage %} usa-current{% endif %}">
Members Members
</a> </a>
</li> </li>
{% endif %} {% endif %}
<li class="usa-nav__primary-item"> <li class="usa-nav__primary-item">

View file

@ -0,0 +1,6 @@
<h4 class="margin-bottom-0 text-primary">Assigned domains</h4>
{% if domain_count > 0 %}
<p class="margin-top-0">{{domain_count}}</p>
{% else %}
<p class="margin-top-0">This member does not manage any domains.{% if manage_button %} To assign this member a domain, click "Manage".{% endif %}</p>
{% endif %}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,137 @@
{% extends 'portfolio_base.html' %}
{% load static field_helpers%}
{% block title %}Organization member {% endblock %}
{% load static %}
{% block portfolio_content %}
<div id="main-content">
{% url 'members' as url %}
<nav class="usa-breadcrumb padding-top-0 margin-bottom-3" aria-label="Portfolio member breadcrumb">
<ol class="usa-breadcrumb__list">
<li class="usa-breadcrumb__list-item">
<a href="{{ url }}" class="usa-breadcrumb__link"><span>Members</span></a>
</li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>Manage member</span>
</li>
</ol>
</nav>
<h1 class="margin-bottom-3">Manage member</h1>
<div class="tablet:display-flex tablet:flex-justify">
<h2 class="margin-top-0 margin-bottom-3 break-word">
{% if member %}
{{ member.email }}
{% elif portfolio_invitation %}
{{ portfolio_invitation.email }}
{% endif %}
</h2>
{% if has_edit_members_portfolio_permission %}
{% if member %}
<a
role="button"
href="#"
class="display-block usa-button text-secondary usa-button--unstyled text-no-underline margin-bottom-3 line-height-sans-5 visible-mobile-flex"
>
Remove member
</a>
{% else %}
<a
role="button"
href="#"
class="display-block usa-button text-secondary usa-button--unstyled text-no-underline margin-bottom-3 line-height-sans-5 visible-mobile-flex"
>
Cancel invitation
</a>
{% endif %}
<div class="usa-accordion usa-accordion--more-actions hidden-mobile-flex">
<div class="usa-accordion__heading">
<button
type="button"
class="usa-button usa-button--unstyled usa-button--with-icon usa-accordion__button usa-button--more-actions"
aria-expanded="false"
aria-controls="more-actions"
>
<svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#more_vert"></use>
</svg>
</button>
</div>
<div id="more-actions" class="usa-accordion__content usa-prose shadow-1 left-auto right-0" hidden>
<h2>More options</h2>
{% if member %}
<a
role="button"
href="#"
class="usa-button text-secondary usa-button--unstyled text-no-underline margin-top-2 line-height-sans-5"
>
Remove member
</a>
{% else %}
<a
role="button"
href="#"
class="usa-button text-secondary usa-button--unstyled text-no-underline margin-top-2 line-height-sans-5"
>
Cancel invitation
</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
<address>
<strong class="text-primary-dark">Last active:</strong>
{% if member and member.last_login %}
{{ member.last_login }}
{% elif portfolio_invitation %}
Invited
{% else %}
{% endif %}
<br />
<strong class="text-primary-dark">Full name:</strong>
{% if member %}
{% if member.first_name or member.last_name %}
{{ member.get_formatted_name }}
{% else %}
{% endif %}
{% else %}
{% endif %}
<br />
<strong class="text-primary-dark">Title or organization role:</strong>
{% if member and member.title %}
{{ member.title }}
{% else %}
{% endif %}
</address>
{% if portfolio_permission %}
{% include "includes/summary_item.html" with title='Member access and permissions' permissions=True value=portfolio_permission edit_link=edit_url editable=has_edit_members_portfolio_permission %}
{% elif portfolio_invitation %}
{% include "includes/summary_item.html" with title='Member access and permissions' permissions=True value=portfolio_invitation edit_link=edit_url editable=has_edit_members_portfolio_permission %}
{% endif %}
{% comment %}view_button is passed below as true in all cases. This is because manage_button logic will trump view_button logic; ie. if manage_button is true, view_button will never be looked at{% endcomment %}
{% if portfolio_permission %}
{% include "includes/summary_item.html" with title='Domain management' domain_mgmt=True value=portfolio_permission.get_managed_domains_count edit_link='#' editable=True manage_button=has_edit_members_portfolio_permission view_button=True %}
{% elif portfolio_invitation %}
{% include "includes/summary_item.html" with title='Domain management' domain_mgmt=True value=portfolio_invitation.get_managed_domains_count edit_link='#' editable=True manage_button=has_edit_members_portfolio_permission view_button=True %}
{% else %}
{% include "includes/summary_item.html" with title='Domain management' domain_mgmt=True value=0 edit_link='#' editable=True manage_button=has_edit_members_portfolio_permission view_button=True %}
{% endif %}
</div>
{% endblock %}

View file

@ -0,0 +1,42 @@
{% extends 'portfolio_base.html' %}
{% load static field_helpers%}
{% block title %}Organization member {% endblock %}
{% load static %}
{% block portfolio_content %}
<div class="grid-row grid-gap">
<div class="tablet:grid-col-9" id="main-content">
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock %}
<h1>Manage member</h1>
<p>
{% if member %}
{{ member.email }}
{% elif invitation %}
{{ invitation.email }}
{% endif %}
</p>
<hr>
<form class="usa-form usa-form--large" method="post" novalidate>
{% csrf_token %}
{% input_with_errors form.roles %}
{% input_with_errors form.additional_permissions %}
<button
type="submit"
class="usa-button"
>Submit</button>
</form>
</div>
</div>
{% endblock %}

View file

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

View file

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

View file

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

View file

@ -61,11 +61,58 @@ class TestEmails(TestCase):
# Assert that an email wasn't sent # Assert that an email wasn't sent
self.assertFalse(self.mock_client.send_email.called) 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 @boto3_mocking.patching
@less_console_noise_decorator @less_console_noise_decorator
def test_submission_confirmation(self): def test_submission_confirmation(self):
"""Submission confirmation email works.""" """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): with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
domain_request.submit() domain_request.submit()
@ -102,7 +149,9 @@ class TestEmails(TestCase):
@less_console_noise_decorator @less_console_noise_decorator
def test_submission_confirmation_no_current_website_spacing(self): def test_submission_confirmation_no_current_website_spacing(self):
"""Test line spacing without current_website.""" """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): with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
domain_request.submit() domain_request.submit()
_, kwargs = self.mock_client.send_email.call_args _, kwargs = self.mock_client.send_email.call_args
@ -115,7 +164,9 @@ class TestEmails(TestCase):
@less_console_noise_decorator @less_console_noise_decorator
def test_submission_confirmation_current_website_spacing(self): def test_submission_confirmation_current_website_spacing(self):
"""Test line spacing with current_website.""" """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): with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
domain_request.submit() domain_request.submit()
_, kwargs = self.mock_client.send_email.call_args _, kwargs = self.mock_client.send_email.call_args
@ -132,7 +183,11 @@ class TestEmails(TestCase):
# Create fake creator # Create fake creator
_creator = User.objects.create( _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 # Create a fake domain request
@ -149,7 +204,9 @@ class TestEmails(TestCase):
@less_console_noise_decorator @less_console_noise_decorator
def test_submission_confirmation_no_other_contacts_spacing(self): def test_submission_confirmation_no_other_contacts_spacing(self):
"""Test line spacing without other contacts.""" """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): with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
domain_request.submit() domain_request.submit()
_, kwargs = self.mock_client.send_email.call_args _, kwargs = self.mock_client.send_email.call_args
@ -161,7 +218,9 @@ class TestEmails(TestCase):
@less_console_noise_decorator @less_console_noise_decorator
def test_submission_confirmation_alternative_govdomain_spacing(self): def test_submission_confirmation_alternative_govdomain_spacing(self):
"""Test line spacing with alternative .gov domain.""" """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): with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
domain_request.submit() domain_request.submit()
_, kwargs = self.mock_client.send_email.call_args _, kwargs = self.mock_client.send_email.call_args
@ -174,7 +233,9 @@ class TestEmails(TestCase):
@less_console_noise_decorator @less_console_noise_decorator
def test_submission_confirmation_no_alternative_govdomain_spacing(self): def test_submission_confirmation_no_alternative_govdomain_spacing(self):
"""Test line spacing without alternative .gov domain.""" """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): with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
domain_request.submit() domain_request.submit()
_, kwargs = self.mock_client.send_email.call_args _, kwargs = self.mock_client.send_email.call_args
@ -187,7 +248,9 @@ class TestEmails(TestCase):
@less_console_noise_decorator @less_console_noise_decorator
def test_submission_confirmation_about_your_organization_spacing(self): def test_submission_confirmation_about_your_organization_spacing(self):
"""Test line spacing with about your organization.""" """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): with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
domain_request.submit() domain_request.submit()
_, kwargs = self.mock_client.send_email.call_args _, kwargs = self.mock_client.send_email.call_args
@ -200,7 +263,9 @@ class TestEmails(TestCase):
@less_console_noise_decorator @less_console_noise_decorator
def test_submission_confirmation_no_about_your_organization_spacing(self): def test_submission_confirmation_no_about_your_organization_spacing(self):
"""Test line spacing without about your organization.""" """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): with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
domain_request.submit() domain_request.submit()
_, kwargs = self.mock_client.send_email.call_args _, kwargs = self.mock_client.send_email.call_args
@ -213,7 +278,9 @@ class TestEmails(TestCase):
@less_console_noise_decorator @less_console_noise_decorator
def test_submission_confirmation_anything_else_spacing(self): def test_submission_confirmation_anything_else_spacing(self):
"""Test line spacing with anything else.""" """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): with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
domain_request.submit() domain_request.submit()
_, kwargs = self.mock_client.send_email.call_args _, kwargs = self.mock_client.send_email.call_args
@ -225,7 +292,9 @@ class TestEmails(TestCase):
@less_console_noise_decorator @less_console_noise_decorator
def test_submission_confirmation_no_anything_else_spacing(self): def test_submission_confirmation_no_anything_else_spacing(self):
"""Test line spacing without anything else.""" """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): with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
domain_request.submit() domain_request.submit()
_, kwargs = self.mock_client.send_email.call_args _, kwargs = self.mock_client.send_email.call_args

View file

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

View file

@ -152,12 +152,15 @@ class TestPortfolioInvitations(TestCase):
self.invitation, _ = PortfolioInvitation.objects.get_or_create( self.invitation, _ = PortfolioInvitation.objects.get_or_create(
email=self.email, email=self.email,
portfolio=self.portfolio, portfolio=self.portfolio,
portfolio_roles=[self.portfolio_role_base, self.portfolio_role_admin], roles=[self.portfolio_role_base, self.portfolio_role_admin],
portfolio_additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2], additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
) )
def tearDown(self): def tearDown(self):
super().tearDown() super().tearDown()
DomainInvitation.objects.all().delete()
DomainInformation.objects.all().delete()
Domain.objects.all().delete()
UserPortfolioPermission.objects.all().delete() UserPortfolioPermission.objects.all().delete()
Portfolio.objects.all().delete() Portfolio.objects.all().delete()
PortfolioInvitation.objects.all().delete() PortfolioInvitation.objects.all().delete()
@ -209,8 +212,8 @@ class TestPortfolioInvitations(TestCase):
PortfolioInvitation.objects.get_or_create( PortfolioInvitation.objects.get_or_create(
email=self.email, email=self.email,
portfolio=portfolio2, portfolio=portfolio2,
portfolio_roles=[self.portfolio_role_base, self.portfolio_role_admin], roles=[self.portfolio_role_base, self.portfolio_role_admin],
portfolio_additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2], additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
) )
with override_flag("multiple_portfolios", active=True): with override_flag("multiple_portfolios", active=True):
self.user.check_portfolio_invitations_on_login() self.user.check_portfolio_invitations_on_login()
@ -233,8 +236,8 @@ class TestPortfolioInvitations(TestCase):
PortfolioInvitation.objects.get_or_create( PortfolioInvitation.objects.get_or_create(
email=self.email, email=self.email,
portfolio=portfolio2, portfolio=portfolio2,
portfolio_roles=[self.portfolio_role_base, self.portfolio_role_admin], roles=[self.portfolio_role_base, self.portfolio_role_admin],
portfolio_additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2], additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
) )
self.user.check_portfolio_invitations_on_login() self.user.check_portfolio_invitations_on_login()
self.user.refresh_from_db() self.user.refresh_from_db()
@ -245,11 +248,58 @@ class TestPortfolioInvitations(TestCase):
updated_invitation2, _ = PortfolioInvitation.objects.get_or_create(email=self.email, portfolio=portfolio2) updated_invitation2, _ = PortfolioInvitation.objects.get_or_create(email=self.email, portfolio=portfolio2)
self.assertEqual(updated_invitation2.status, PortfolioInvitation.PortfolioInvitationStatus.INVITED) self.assertEqual(updated_invitation2.status, PortfolioInvitation.PortfolioInvitationStatus.INVITED)
@less_console_noise_decorator
def test_get_managed_domains_count(self):
"""Test that the correct number of domains, which are associated with the portfolio and
have invited the email of the portfolio invitation, are returned."""
# Add three domains, one which is in the portfolio and email is invited to,
# one which is in the portfolio and email is not invited to,
# and one which is email is invited to and not in the portfolio.
# Arrange
# domain_in_portfolio should not be included in the count
domain_in_portfolio, _ = Domain.objects.get_or_create(name="domain_in_portfolio.gov", state=Domain.State.READY)
DomainInformation.objects.get_or_create(creator=self.user, domain=domain_in_portfolio, portfolio=self.portfolio)
# domain_in_portfolio_and_invited should be included in the count
domain_in_portfolio_and_invited, _ = Domain.objects.get_or_create(
name="domain_in_portfolio_and_invited.gov", state=Domain.State.READY
)
DomainInformation.objects.get_or_create(
creator=self.user, domain=domain_in_portfolio_and_invited, portfolio=self.portfolio
)
DomainInvitation.objects.get_or_create(email=self.email, domain=domain_in_portfolio_and_invited)
# domain_invited should not be included in the count
domain_invited, _ = Domain.objects.get_or_create(name="domain_invited.gov", state=Domain.State.READY)
DomainInformation.objects.get_or_create(creator=self.user, domain=domain_invited)
DomainInvitation.objects.get_or_create(email=self.email, domain=domain_invited)
# Assert
self.assertEqual(self.invitation.get_managed_domains_count(), 1)
@less_console_noise_decorator
def test_get_portfolio_permissions(self):
"""Test that get_portfolio_permissions returns the expected list of permissions,
based on the roles and permissions assigned to the invitation."""
# Arrange
test_permission_list = set()
# add the arrays that are defined in UserPortfolioPermission for member and admin
test_permission_list.update(
UserPortfolioPermission.PORTFOLIO_ROLE_PERMISSIONS.get(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [])
)
test_permission_list.update(
UserPortfolioPermission.PORTFOLIO_ROLE_PERMISSIONS.get(UserPortfolioRoleChoices.ORGANIZATION_ADMIN, [])
)
# add the permissions that are added to the invitation as additional_permissions
test_permission_list.update([self.portfolio_permission_1, self.portfolio_permission_2])
perm_list = list(test_permission_list)
# Verify
self.assertEquals(self.invitation.get_portfolio_permissions(), perm_list)
class TestUserPortfolioPermission(TestCase): class TestUserPortfolioPermission(TestCase):
@less_console_noise_decorator @less_console_noise_decorator
def setUp(self): def setUp(self):
self.user, _ = User.objects.get_or_create(email="mayor@igorville.gov") 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() super().setUp()
def tearDown(self): def tearDown(self):
@ -287,16 +337,15 @@ class TestUserPortfolioPermission(TestCase):
@override_flag("multiple_portfolios", active=False) @override_flag("multiple_portfolios", active=False)
def test_clean_on_creates_multiple_portfolios(self): def test_clean_on_creates_multiple_portfolios(self):
"""Ensures that a user cannot create multiple portfolio permission objects when the flag is disabled""" """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, _ = 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_permission, _ = UserPortfolioPermission.objects.get_or_create(
portfolio=portfolio, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] 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_permission_2 = UserPortfolioPermission(
portfolio=portfolio_2, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] portfolio=portfolio_2, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
) )
# This should work as intended # This should work as intended
portfolio_permission.clean() portfolio_permission.clean()
@ -304,8 +353,6 @@ class TestUserPortfolioPermission(TestCase):
with self.assertRaises(ValidationError) as cm: with self.assertRaises(ValidationError) as cm:
portfolio_permission_2.clean() portfolio_permission_2.clean()
portfolio_permission_2, _ = UserPortfolioPermission.objects.get_or_create(portfolio=portfolio, user=self.user)
self.assertEqual( self.assertEqual(
cm.exception.message, 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): class TestUser(TestCase):
"""Test actions that occur on user login, """Test actions that occur on user login,

View file

@ -305,7 +305,7 @@ class TestDomainRequest(TestCase):
@less_console_noise_decorator @less_console_noise_decorator
def test_submit_from_withdrawn_sends_email(self): def test_submit_from_withdrawn_sends_email(self):
msg = "Create a withdrawn domain request and submit it and see if email was sent." 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) 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) 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 @less_console_noise_decorator
def test_approve_sends_email(self): def test_approve_sends_email(self):
msg = "Create a domain request and approve it and see if email was sent." 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) 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) self.check_email_sent(domain_request, msg, "approve", 1, expected_content="approved", expected_email=user.email)
@less_console_noise_decorator @less_console_noise_decorator
def test_withdraw_sends_email(self): def test_withdraw_sends_email(self):
msg = "Create a domain request and withdraw it and see if email was sent." 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) domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=user)
self.check_email_sent( self.check_email_sent(
domain_request, msg, "withdraw", 1, expected_content="withdrawn", expected_email=user.email 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): def test_reject_sends_email(self):
"Create a domain request and reject it and see if email was sent." "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) domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.APPROVED, user=user)
expected_email = user.email expected_email = user.email
email_allowed, _ = AllowedEmail.objects.get_or_create(email=expected_email) email_allowed, _ = AllowedEmail.objects.get_or_create(email=expected_email)

View file

@ -65,6 +65,10 @@ class TestWithDomainPermissions(TestWithUser):
datetime.combine(date.today() + timedelta(days=1), datetime.min.time()) 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( self.domain_deleted, _ = Domain.objects.get_or_create(
name="deleted.gov", name="deleted.gov",
state=Domain.State.DELETED, 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_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_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_deleted)
DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_dns_needed)
self.role, _ = UserDomainRole.objects.get_or_create( self.role, _ = UserDomainRole.objects.get_or_create(
user=self.user, domain=self.domain, role=UserDomainRole.Roles.MANAGER user=self.user, domain=self.domain, role=UserDomainRole.Roles.MANAGER
@ -99,6 +104,9 @@ class TestWithDomainPermissions(TestWithUser):
UserDomainRole.objects.get_or_create( UserDomainRole.objects.get_or_create(
user=self.user, domain=self.domain_dsdata, role=UserDomainRole.Roles.MANAGER 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( UserDomainRole.objects.get_or_create(
user=self.user, user=self.user,
domain=self.domain_multdsdata, 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 # At the time of this test's writing, there are 6 UNKNOWN domains inherited
# from constructors. Let's reset. # from constructors. Let's reset.
with less_console_noise(): with less_console_noise():
PublicContact.objects.all().delete()
Domain.objects.all().delete() Domain.objects.all().delete()
UserDomainRole.objects.all().delete() UserDomainRole.objects.all().delete()
@ -340,7 +349,10 @@ class TestDomainDetail(TestDomainOverview):
detail_page = self.client.get(f"/domain/{domain.id}") detail_page = self.client.get(f"/domain/{domain.id}")
# Check that alert message displays properly # Check that alert message displays properly
self.assertContains( self.assertContains(
detail_page, "To manage information for this domain, you must add yourself as a domain manager." detail_page,
"You don't have access to manage "
+ domain.name
+ ". If you need to make updates, contact one of the listed domain managers.",
) )
# Check that user does not have option to Edit domain # Check that user does not have option to Edit domain
self.assertNotContains(detail_page, "Edit") self.assertNotContains(detail_page, "Edit")
@ -1964,3 +1976,292 @@ class TestDomainDNSSEC(TestDomainOverview):
self.assertContains( self.assertContains(
result, str(DsDataError(code=DsDataErrorCodes.INVALID_DIGEST_SHA256)), count=2, status_code=200 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)

View file

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

View file

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

View file

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

View file

@ -521,7 +521,8 @@ class DomainRequestTests(TestWithUser, WebTest):
# And the existence of the modal's data parked and ready for the js init. # And the existence of the modal's data parked and ready for the js init.
# The next assert also tests for the passed requested domain context from # The next assert also tests for the passed requested domain context from
# the view > domain_request_form > modal # the view > domain_request_form > modal
self.assertContains(review_page, "You are about to submit a domain request for city.gov") self.assertContains(review_page, "You are about to submit a domain request for")
self.assertContains(review_page, "city.gov")
# final submission results in a redirect to the "finished" URL # final submission results in a redirect to the "finished" URL
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)

View file

@ -22,30 +22,47 @@ class EmailSendingError(RuntimeError):
pass pass
def send_templated_email( def send_templated_email( # noqa
template_name: str, template_name: str,
subject_template_name: str, subject_template_name: str,
to_address: str, to_address: str = "",
bcc_address="", bcc_address: str = "",
context={}, context={},
attachment_file=None, attachment_file=None,
wrap_email=False, 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 template_name and subject_template_name are relative to the same template
context as Django's HTML templates. context gives additional information context as Django's HTML templates. context gives additional information
that the template may use. 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 if not settings.IS_PRODUCTION: # type: ignore
# Split into a function: C901 'send_templated_email' is too complex. # Split into a function: C901 'send_templated_email' is too complex.
# Raises an error if we cannot send an email (due to restrictions). # Raises an error if we cannot send an email (due to restrictions).
# Does nothing otherwise. # Does nothing otherwise.
_can_send_email(to_address, bcc_address) _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) template = get_template(template_name)
email_body = template.render(context=context) email_body = template.render(context=context)
@ -64,14 +81,23 @@ def send_templated_email(
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=settings.BOTO_CONFIG, 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: except Exception as exc:
logger.debug("E-mail unable to send! Could not access the SES client.") logger.debug("E-mail unable to send! Could not access the SES client.")
raise EmailSendingError("Could not access the SES client.") from exc 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: if bcc_address:
destination["BccAddresses"] = [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: try:
if not attachment_file: 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: else:
ses_client = boto3.client( ses_client = boto3.client(
"ses", "ses",
@ -101,6 +128,10 @@ def send_templated_email(
send_email_with_attachment( send_email_with_attachment(
settings.DEFAULT_FROM_EMAIL, to_address, subject, email_body, attachment_file, ses_client 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: except Exception as exc:
raise EmailSendingError("Could not send SES email.") from 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)) 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): def wrap_text_and_preserve_paragraphs(text, width):
""" """
Wraps text to `width` preserving newlines; splits on '\n', wraps segments, rejoins with '\n'. Wraps text to `width` preserving newlines; splits on '\n', wraps segments, rejoins with '\n'.

View file

@ -23,6 +23,15 @@ class InvalidDomainError(ValueError):
pass 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): class ActionNotAllowed(Exception):
"""User accessed an action that is not """User accessed an action that is not
allowed by the current state""" allowed by the current state"""
@ -240,7 +249,7 @@ class SecurityEmailError(Exception):
""" """
_error_mapping = { _error_mapping = {
SecurityEmailErrorCodes.BAD_DATA: ("Enter an email address in the required format, " "like name@example.com."), SecurityEmailErrorCodes.BAD_DATA: ("Enter an email address in the required format, like name@example.com."),
} }
def __init__(self, *args, code=None, **kwargs): def __init__(self, *args, code=None, **kwargs):

View file

@ -5,6 +5,7 @@ authorized users can see information on a domain, every view here should
inherit from `DomainPermissionView` (or DomainInvitationPermissionDeleteView). inherit from `DomainPermissionView` (or DomainInvitationPermissionDeleteView).
""" """
from datetime import date
import logging import logging
from django.contrib import messages from django.contrib import messages
@ -21,8 +22,10 @@ from registrar.models import (
DomainRequest, DomainRequest,
DomainInformation, DomainInformation,
DomainInvitation, DomainInvitation,
PortfolioInvitation,
User, User,
UserDomainRole, UserDomainRole,
UserPortfolioPermission,
PublicContact, PublicContact,
) )
from registrar.utility.enums import DefaultEmail from registrar.utility.enums import DefaultEmail
@ -35,9 +38,11 @@ from registrar.utility.errors import (
DsDataErrorCodes, DsDataErrorCodes,
SecurityEmailError, SecurityEmailError,
SecurityEmailErrorCodes, SecurityEmailErrorCodes,
OutsideOrgMemberError,
) )
from registrar.models.utility.contact_error import ContactError from registrar.models.utility.contact_error import ContactError
from registrar.views.utility.permission_views import UserDomainRolePermissionDeleteView from registrar.views.utility.permission_views import UserDomainRolePermissionDeleteView
from registrar.utility.waffle import flag_is_active_for_user
from ..forms import ( from ..forms import (
SeniorOfficialContactForm, SeniorOfficialContactForm,
@ -148,6 +153,103 @@ class DomainFormBaseView(DomainBaseView, FormMixin):
return current_domain_info 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): class DomainView(DomainBaseView):
"""Domain detail overview page.""" """Domain detail overview page."""
@ -223,6 +325,8 @@ class DomainOrgNameAddressView(DomainFormBaseView):
def form_valid(self, form): def form_valid(self, form):
"""The form is valid, save the organization name and mailing address.""" """The form is valid, save the organization name and mailing address."""
self.send_update_notification(form)
form.save() form.save()
messages.success(self.request, "The organization information for this domain has been updated.") 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.set_domain_info(self.object.domain_info)
form.save() form.save()
self.send_update_notification(form)
messages.success(self.request, "The senior official for this domain has been updated.") messages.success(self.request, "The senior official for this domain has been updated.")
# superclass has the redirect # superclass has the redirect
@ -404,19 +510,25 @@ class DomainNameserversView(DomainFormBaseView):
self._get_domain(request) self._get_domain(request)
formset = self.get_form() formset = self.get_form()
logger.debug("got formet")
if "btn-cancel-click" in request.POST: if "btn-cancel-click" in request.POST:
url = self.get_success_url() url = self.get_success_url()
return HttpResponseRedirect(url) return HttpResponseRedirect(url)
if formset.is_valid(): if formset.is_valid():
logger.debug("formset is valid")
return self.form_valid(formset) return self.form_valid(formset)
else: else:
logger.debug("formset is invalid")
logger.debug(formset.errors)
return self.form_invalid(formset) return self.form_invalid(formset)
def form_valid(self, formset): def form_valid(self, formset):
"""The formset is valid, perform something with it.""" """The formset is valid, perform something with it."""
self.request.session["nameservers_form_domain"] = self.object self.request.session["nameservers_form_domain"] = self.object
initial_state = self.object.state
# Set the nameservers from the formset # Set the nameservers from the formset
nameservers = [] nameservers = []
@ -438,7 +550,6 @@ class DomainNameserversView(DomainFormBaseView):
except KeyError: except KeyError:
# no server information in this field, skip it # no server information in this field, skip it
pass pass
try: try:
self.object.nameservers = nameservers self.object.nameservers = nameservers
except NameserverError as Err: except NameserverError as Err:
@ -458,6 +569,8 @@ class DomainNameserversView(DomainFormBaseView):
messages.error(self.request, NameserverError(code=nsErrorCodes.BAD_DATA)) messages.error(self.request, NameserverError(code=nsErrorCodes.BAD_DATA))
logger.error(f"Registry error: {Err}") logger.error(f"Registry error: {Err}")
else: else:
if initial_state == Domain.State.READY:
self.send_update_notification(formset)
messages.success( messages.success(
self.request, self.request,
"The name servers for this domain have been updated. " "The name servers for this domain have been updated. "
@ -510,7 +623,8 @@ class DomainDNSSECView(DomainFormBaseView):
errmsg = "Error removing existing DNSSEC record(s)." errmsg = "Error removing existing DNSSEC record(s)."
logger.error(errmsg + ": " + err) logger.error(errmsg + ": " + err)
messages.error(self.request, errmsg) messages.error(self.request, errmsg)
else:
self.send_update_notification(form, force_send=True)
return self.form_valid(form) return self.form_valid(form)
@ -634,6 +748,8 @@ class DomainDsDataView(DomainFormBaseView):
logger.error(f"Registry error: {err}") logger.error(f"Registry error: {err}")
return self.form_invalid(formset) return self.form_invalid(formset)
else: else:
self.send_update_notification(formset)
messages.success(self.request, "The DS data records for this domain have been updated.") messages.success(self.request, "The DS data records for this domain have been updated.")
# superclass has the redirect # superclass has the redirect
return super().form_valid(formset) return super().form_valid(formset)
@ -700,8 +816,12 @@ class DomainSecurityEmailView(DomainFormBaseView):
messages.error(self.request, SecurityEmailError(code=SecurityEmailErrorCodes.BAD_DATA)) messages.error(self.request, SecurityEmailError(code=SecurityEmailErrorCodes.BAD_DATA))
logger.error(f"Generic registry error: {Err}") logger.error(f"Generic registry error: {Err}")
else: else:
self.send_update_notification(form)
messages.success(self.request, "The security email for this domain has been updated.") 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 # superclass has the redirect
return redirect(self.get_success_url()) return redirect(self.get_success_url())
@ -778,7 +898,18 @@ class DomainAddUserView(DomainFormBaseView):
"""Get an absolute URL for this domain.""" """Get an absolute URL for this domain."""
return self.request.build_absolute_uri(reverse("domain", kwargs={"pk": self.object.id})) 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, """Performs the sending of the domain invitation email,
does not make a domain information object does not make a domain information object
email: string- email to send to email: string- email to send to
@ -803,6 +934,13 @@ class DomainAddUserView(DomainFormBaseView):
) )
return None 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 # Check to see if an invite has already been sent
try: try:
invite = DomainInvitation.objects.get(email=email, domain=self.object) invite = DomainInvitation.objects.get(email=email, domain=self.object)
@ -859,16 +997,21 @@ class DomainAddUserView(DomainFormBaseView):
Throws EmailSendingError.""" Throws EmailSendingError."""
requested_email = form.cleaned_data["email"] requested_email = form.cleaned_data["email"]
requestor = self.request.user requestor = self.request.user
email_success = False
# look up a user with that email # look up a user with that email
try: try:
requested_user = User.objects.get(email=requested_email) requested_user = User.objects.get(email=requested_email)
except User.DoesNotExist: except User.DoesNotExist:
# no matching user, go make an invitation # no matching user, go make an invitation
email_success = True
return self._make_invitation(requested_email, requestor) return self._make_invitation(requested_email, requestor)
else: else:
# if user already exists then just send an email # if user already exists then just send an email
try: 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: except EmailSendingError:
logger.warn( logger.warn(
"Could not send email invitation (EmailSendingError)", "Could not send email invitation (EmailSendingError)",
@ -876,6 +1019,17 @@ class DomainAddUserView(DomainFormBaseView):
exc_info=True, exc_info=True,
) )
messages.warning(self.request, "Could not send email invitation.") 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: except Exception:
logger.warn( logger.warn(
"Could not send email invitation (Other Exception)", "Could not send email invitation (Other Exception)",
@ -883,17 +1037,17 @@ class DomainAddUserView(DomainFormBaseView):
exc_info=True, exc_info=True,
) )
messages.warning(self.request, "Could not send email invitation.") 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()) return redirect(self.get_success_url())

View file

@ -458,8 +458,8 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
"visited": self.storage.get("step_history", []), "visited": self.storage.get("step_history", []),
"is_federal": self.domain_request.is_federal(), "is_federal": self.domain_request.is_federal(),
"modal_button": modal_button, "modal_button": modal_button,
"modal_heading": "You are about to submit a domain request for " "modal_heading": "You are about to submit a domain request for ",
+ str(self.domain_request.requested_domain), "domain_name_modal": str(self.domain_request.requested_domain),
"modal_description": "Once you submit this request, you wont be able to edit it until we review it.\ "modal_description": "Once you submit this request, you wont be able to edit it until we review it.\
Youll only be able to withdraw your request.", Youll only be able to withdraw your request.",
"review_form_is_complete": True, "review_form_is_complete": True,

View file

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

View file

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

View file

@ -512,7 +512,81 @@ class PortfolioMembersPermission(PortfolioBasePermission):
up from the portfolio's primary key in self.kwargs["pk"]""" up from the portfolio's primary key in self.kwargs["pk"]"""
portfolio = self.request.session.get("portfolio") portfolio = self.request.session.get("portfolio")
if not self.request.user.has_view_members_portfolio_permission(portfolio): if not self.request.user.has_view_members_portfolio_permission(
portfolio
) and not self.request.user.has_edit_members_portfolio_permission(portfolio):
return False
return super().has_permission()
class PortfolioMemberPermission(PortfolioBasePermission):
"""Permission mixin that allows access to portfolio member pages if user
has access, otherwise 403"""
def has_permission(self):
"""Check if this user has access to members for this portfolio.
The user is in self.request.user and the portfolio can be looked
up from the portfolio's primary key in self.kwargs["pk"]"""
portfolio = self.request.session.get("portfolio")
if not self.request.user.has_view_members_portfolio_permission(
portfolio
) and not self.request.user.has_edit_members_portfolio_permission(portfolio):
return False
return super().has_permission()
class PortfolioMemberEditPermission(PortfolioBasePermission):
"""Permission mixin that allows access to portfolio member pages if user
has access to edit, otherwise 403"""
def has_permission(self):
"""Check if this user has access to members for this portfolio.
The user is in self.request.user and the portfolio can be looked
up from the portfolio's primary key in self.kwargs["pk"]"""
portfolio = self.request.session.get("portfolio")
if not self.request.user.has_edit_members_portfolio_permission(portfolio):
return False
return super().has_permission()
class PortfolioInvitedMemberPermission(PortfolioBasePermission):
"""Permission mixin that allows access to portfolio invited member pages if user
has access, otherwise 403"""
def has_permission(self):
"""Check if this user has access to members for this portfolio.
The user is in self.request.user and the portfolio can be looked
up from the portfolio's primary key in self.kwargs["pk"]"""
portfolio = self.request.session.get("portfolio")
if not self.request.user.has_view_members_portfolio_permission(
portfolio
) and not self.request.user.has_edit_members_portfolio_permission(portfolio):
return False
return super().has_permission()
class PortfolioInvitedMemberEditPermission(PortfolioBasePermission):
"""Permission mixin that allows access to portfolio invited member pages if user
has access to edit, otherwise 403"""
def has_permission(self):
"""Check if this user has access to members for this portfolio.
The user is in self.request.user and the portfolio can be looked
up from the portfolio's primary key in self.kwargs["pk"]"""
portfolio = self.request.session.get("portfolio")
if not self.request.user.has_edit_members_portfolio_permission(portfolio):
return False return False
return super().has_permission() return super().has_permission()

View file

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

View file

@ -1,75 +1,68 @@
-i https://pypi.python.org/simple -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' asgiref==3.8.1; python_version >= '3.8'
boto3==1.34.95; python_version >= '3.8' boto3==1.35.41; python_version >= '3.8'
botocore==1.34.95; python_version >= '3.8' botocore==1.35.41; python_version >= '3.8'
cachetools==5.3.3; python_version >= '3.7' cachetools==5.5.0; python_version >= '3.7'
certifi==2024.2.2; python_version >= '3.6' certifi==2024.8.30; python_version >= '3.6'
cfenv==0.5.3 cfenv==0.5.3
cffi==1.16.0; platform_python_implementation != 'PyPy' cffi==1.17.1; platform_python_implementation != 'PyPy'
charset-normalizer==3.3.2; python_full_version >= '3.7.0' charset-normalizer==3.4.0; python_full_version >= '3.7.0'
cryptography==42.0.5; python_version >= '3.7' 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' 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' 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 dj-email-url==1.0.6
django==4.2.10; python_version >= '3.8' django==4.2.10; python_version >= '3.8'
django-admin-multiple-choice-list-filter==0.1.1 django-admin-multiple-choice-list-filter==0.1.1
django-allow-cidr==0.7.1 django-allow-cidr==0.7.1
django-auditlog==3.0.0; python_version >= '3.8' django-auditlog==3.0.0; python_version >= '3.8'
django-cache-url==3.4.5 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-csp==3.8
django-fsm==2.8.1 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-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-waffle==4.1.0; python_version >= '3.8'
django-widget-tweaks==1.5.0; python_version >= '3.8' django-widget-tweaks==1.5.0; python_version >= '3.8'
environs[django]==11.0.0; python_version >= '3.8' environs[django]==11.0.0; python_version >= '3.8'
et-xmlfile==1.1.0; python_version >= '3.6' faker==30.3.0; python_version >= '3.8'
faker==25.0.0; python_version >= '3.8'
fred-epplib@ git+https://github.com/cisagov/epplib.git@d56d183f1664f34c40ca9716a3a9a345f0ef561c fred-epplib@ git+https://github.com/cisagov/epplib.git@d56d183f1664f34c40ca9716a3a9a345f0ef561c
furl==2.1.3 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' 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' gevent==24.10.2; python_version >= '3.9'
greenlet==3.0.3; python_version >= '3.7' greenlet==3.1.1; python_version >= '3.7'
gunicorn==22.0.0; python_version >= '3.7' gunicorn==23.0.0; python_version >= '3.7'
idna==3.7; python_version >= '3.5' idna==3.10; python_version >= '3.6'
jmespath==1.0.1; python_version >= '3.7' jmespath==1.0.1; python_version >= '3.7'
lxml==5.2.1; python_version >= '3.6' lxml==5.3.0; python_version >= '3.6'
mako==1.3.3; python_version >= '3.8' mako==1.3.5; python_version >= '3.8'
markuppy==1.14 markupsafe==3.0.1; python_version >= '3.9'
markupsafe==2.1.5; python_version >= '3.7' marshmallow==3.22.0; python_version >= '3.8'
marshmallow==3.21.1; python_version >= '3.8'
odfpy==1.4.1
oic==1.7.0; python_version ~= '3.8' oic==1.7.0; python_version ~= '3.8'
openpyxl==3.1.2
orderedmultidict==1.0.1 orderedmultidict==1.0.1
packaging==24.0; python_version >= '3.7' packaging==24.1; python_version >= '3.8'
phonenumberslite==8.13.35 phonenumberslite==8.13.47
psycopg2-binary==2.9.9; python_version >= '3.7' psycopg2-binary==2.9.9; python_version >= '3.7'
pycparser==2.22; python_version >= '3.8' 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' 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.7.1; python_version >= '3.8' pydantic==2.9.2; python_version >= '3.8'
pydantic-core==2.18.2; python_version >= '3.8' pydantic-core==2.23.4; python_version >= '3.8'
pydantic-settings==2.2.1; python_version >= '3.8' pydantic-settings==2.5.2; python_version >= '3.8'
pyjwkest==1.4.2 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-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' python-dotenv==1.0.1; python_version >= '3.8'
pyyaml==6.0.1
pyzipper==0.3.6; python_version >= '3.4' pyzipper==0.3.6; python_version >= '3.4'
requests==2.31.0; python_version >= '3.7' requests==2.32.3; python_version >= '3.8'
s3transfer==0.10.1; python_version >= '3.8' s3transfer==0.10.3; python_version >= '3.8'
setuptools==69.5.1; 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' 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' tablib[html,ods,xls,xlsx,yaml]==3.5.0; python_version >= '3.8'
tblib==3.0.0; python_version >= '3.8' tblib==3.0.0; python_version >= '3.8'
typing-extensions==4.11.0; python_version >= '3.8' typing-extensions==4.12.2; python_version >= '3.8'
urllib3==2.2.1; python_version >= '3.8' urllib3==2.2.3; python_version >= '3.8'
whitenoise==6.6.0; python_version >= '3.8' whitenoise==6.7.0; python_version >= '3.8'
xlrd==2.0.1
xlwt==1.3.0
zope.event==5.0; python_version >= '3.7' 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'

View file

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